From a4a23dded48c17838041387ab4bce7a3e9437b5a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 31 Mar 2026 10:27:14 +0200 Subject: [PATCH 01/26] feat: inline iOS simulator handling --- action.yml | 45 +--- actions/ios/action.yml | 42 +--- packages/github-action/src/action.yml | 45 +--- packages/github-action/src/ios/action.yml | 42 +--- packages/jest/src/__tests__/harness.test.ts | 50 +++-- packages/jest/src/action-hooks.ts | 66 ++++++ packages/jest/src/harness.ts | 51 +++-- packages/jest/src/setup.ts | 13 +- .../src/__tests__/instance.test.ts | 201 +++++++++++++++++- packages/platform-ios/src/errors.ts | 12 ++ packages/platform-ios/src/index.ts | 3 +- packages/platform-ios/src/instance.ts | 42 +++- packages/platform-ios/src/xcrun/simctl.ts | 25 ++- packages/plugins/src/index.ts | 2 + packages/plugins/src/types.ts | 184 +++++++--------- 15 files changed, 489 insertions(+), 334 deletions(-) create mode 100644 packages/jest/src/action-hooks.ts create mode 100644 packages/platform-ios/src/errors.ts diff --git a/action.yml b/action.yml index 9ba94c28..b6808853 100644 --- a/action.yml +++ b/action.yml @@ -72,20 +72,7 @@ runs: ${{ runner.os }}-metro-cache- # ── iOS ────────────────────────────────────────────────────────────────── - - uses: futureware-tech/simulator-action@v4 - if: fromJson(steps.load-config.outputs.config).platformId == 'ios' - with: - model: ${{ fromJson(steps.load-config.outputs.config).config.device.name }} - os: iOS - os_version: ${{ fromJson(steps.load-config.outputs.config).config.device.systemVersion }} - wait_for_boot: true - erase_before_boot: false - - name: Install app - if: fromJson(steps.load-config.outputs.config).platformId == 'ios' - shell: bash - working-directory: ${{ steps.load-config.outputs.projectRoot }} - run: | - xcrun simctl install booted ${{ inputs.app }} + # iOS simulator boot and app installation are handled by Harness itself. # ── Android ────────────────────────────────────────────────────────────── - name: Verify Android config if: fromJson(steps.load-config.outputs.config).platformId == 'android' @@ -232,48 +219,20 @@ runs: PRE_RUN_HOOK: ${{ inputs.preRunHook }} AFTER_RUN_HOOK: ${{ inputs.afterRunHook }} HARNESS_RUNNER: ${{ inputs.runner }} + HARNESS_APP_PATH: ${{ inputs.app }} run: | export HARNESS_PROJECT_ROOT="$PWD" - if [ -n "$PRE_RUN_HOOK" ]; then - pre_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-pre-run.XXXXXX.sh")" - trap 'rm -f "$pre_hook_file"' EXIT - printf '%s\n' "$PRE_RUN_HOOK" > "$pre_hook_file" - chmod +x "$pre_hook_file" - bash "$pre_hook_file" - rm -f "$pre_hook_file" - trap - EXIT - fi - set +e ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} ${{ inputs.harnessArgs }} harness_exit_code=$? set -e - export HARNESS_EXIT_CODE="$harness_exit_code" - after_run_exit_code=0 - if [ -n "$AFTER_RUN_HOOK" ]; then - after_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-after-run.XXXXXX.sh")" - trap 'rm -f "$after_hook_file"' EXIT - printf '%s\n' "$AFTER_RUN_HOOK" > "$after_hook_file" - chmod +x "$after_hook_file" - set +e - bash "$after_hook_file" - after_run_exit_code=$? - set -e - rm -f "$after_hook_file" - trap - EXIT - fi - echo "harness_exit_code=$harness_exit_code" >> "$GITHUB_OUTPUT" if [ "$harness_exit_code" -ne 0 ]; then exit "$harness_exit_code" fi - - if [ "$after_run_exit_code" -ne 0 ]; then - exit "$after_run_exit_code" - fi - name: Run E2E tests id: run-tests-android if: fromJson(steps.load-config.outputs.config).platformId == 'android' diff --git a/actions/ios/action.yml b/actions/ios/action.yml index 1512c125..6866932c 100644 --- a/actions/ios/action.yml +++ b/actions/ios/action.yml @@ -43,18 +43,6 @@ runs: INPUT_PROJECTROOT: ${{ inputs.projectRoot }} run: | node ${{ github.action_path }}/../shared/index.cjs - - uses: futureware-tech/simulator-action@v4 - with: - model: ${{ fromJson(steps.load-config.outputs.config).config.device.name }} - os: iOS - os_version: ${{ fromJson(steps.load-config.outputs.config).config.device.systemVersion }} - wait_for_boot: true - erase_before_boot: false - - name: Install app - shell: bash - working-directory: ${{ inputs.projectRoot }} - run: | - xcrun simctl install booted ${{ inputs.app }} - name: Detect Package Manager id: detect-pm shell: bash @@ -83,48 +71,20 @@ runs: PRE_RUN_HOOK: ${{ inputs.preRunHook }} AFTER_RUN_HOOK: ${{ inputs.afterRunHook }} HARNESS_RUNNER: ${{ inputs.runner }} + HARNESS_APP_PATH: ${{ inputs.app }} run: | export HARNESS_PROJECT_ROOT="$PWD" - if [ -n "$PRE_RUN_HOOK" ]; then - pre_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-pre-run.XXXXXX.sh")" - trap 'rm -f "$pre_hook_file"' EXIT - printf '%s\n' "$PRE_RUN_HOOK" > "$pre_hook_file" - chmod +x "$pre_hook_file" - bash "$pre_hook_file" - rm -f "$pre_hook_file" - trap - EXIT - fi - set +e ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} ${{ inputs.harnessArgs }} harness_exit_code=$? set -e - export HARNESS_EXIT_CODE="$harness_exit_code" - after_run_exit_code=0 - if [ -n "$AFTER_RUN_HOOK" ]; then - after_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-after-run.XXXXXX.sh")" - trap 'rm -f "$after_hook_file"' EXIT - printf '%s\n' "$AFTER_RUN_HOOK" > "$after_hook_file" - chmod +x "$after_hook_file" - set +e - bash "$after_hook_file" - after_run_exit_code=$? - set -e - rm -f "$after_hook_file" - trap - EXIT - fi - echo "harness_exit_code=$harness_exit_code" >> "$GITHUB_OUTPUT" if [ "$harness_exit_code" -ne 0 ]; then exit "$harness_exit_code" fi - - if [ "$after_run_exit_code" -ne 0 ]; then - exit "$after_run_exit_code" - fi - name: Upload visual test artifacts if: always() && inputs.uploadVisualTestArtifacts == 'true' uses: actions/upload-artifact@v4 diff --git a/packages/github-action/src/action.yml b/packages/github-action/src/action.yml index 9ba94c28..b6808853 100644 --- a/packages/github-action/src/action.yml +++ b/packages/github-action/src/action.yml @@ -72,20 +72,7 @@ runs: ${{ runner.os }}-metro-cache- # ── iOS ────────────────────────────────────────────────────────────────── - - uses: futureware-tech/simulator-action@v4 - if: fromJson(steps.load-config.outputs.config).platformId == 'ios' - with: - model: ${{ fromJson(steps.load-config.outputs.config).config.device.name }} - os: iOS - os_version: ${{ fromJson(steps.load-config.outputs.config).config.device.systemVersion }} - wait_for_boot: true - erase_before_boot: false - - name: Install app - if: fromJson(steps.load-config.outputs.config).platformId == 'ios' - shell: bash - working-directory: ${{ steps.load-config.outputs.projectRoot }} - run: | - xcrun simctl install booted ${{ inputs.app }} + # iOS simulator boot and app installation are handled by Harness itself. # ── Android ────────────────────────────────────────────────────────────── - name: Verify Android config if: fromJson(steps.load-config.outputs.config).platformId == 'android' @@ -232,48 +219,20 @@ runs: PRE_RUN_HOOK: ${{ inputs.preRunHook }} AFTER_RUN_HOOK: ${{ inputs.afterRunHook }} HARNESS_RUNNER: ${{ inputs.runner }} + HARNESS_APP_PATH: ${{ inputs.app }} run: | export HARNESS_PROJECT_ROOT="$PWD" - if [ -n "$PRE_RUN_HOOK" ]; then - pre_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-pre-run.XXXXXX.sh")" - trap 'rm -f "$pre_hook_file"' EXIT - printf '%s\n' "$PRE_RUN_HOOK" > "$pre_hook_file" - chmod +x "$pre_hook_file" - bash "$pre_hook_file" - rm -f "$pre_hook_file" - trap - EXIT - fi - set +e ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} ${{ inputs.harnessArgs }} harness_exit_code=$? set -e - export HARNESS_EXIT_CODE="$harness_exit_code" - after_run_exit_code=0 - if [ -n "$AFTER_RUN_HOOK" ]; then - after_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-after-run.XXXXXX.sh")" - trap 'rm -f "$after_hook_file"' EXIT - printf '%s\n' "$AFTER_RUN_HOOK" > "$after_hook_file" - chmod +x "$after_hook_file" - set +e - bash "$after_hook_file" - after_run_exit_code=$? - set -e - rm -f "$after_hook_file" - trap - EXIT - fi - echo "harness_exit_code=$harness_exit_code" >> "$GITHUB_OUTPUT" if [ "$harness_exit_code" -ne 0 ]; then exit "$harness_exit_code" fi - - if [ "$after_run_exit_code" -ne 0 ]; then - exit "$after_run_exit_code" - fi - name: Run E2E tests id: run-tests-android if: fromJson(steps.load-config.outputs.config).platformId == 'android' diff --git a/packages/github-action/src/ios/action.yml b/packages/github-action/src/ios/action.yml index 1512c125..6866932c 100644 --- a/packages/github-action/src/ios/action.yml +++ b/packages/github-action/src/ios/action.yml @@ -43,18 +43,6 @@ runs: INPUT_PROJECTROOT: ${{ inputs.projectRoot }} run: | node ${{ github.action_path }}/../shared/index.cjs - - uses: futureware-tech/simulator-action@v4 - with: - model: ${{ fromJson(steps.load-config.outputs.config).config.device.name }} - os: iOS - os_version: ${{ fromJson(steps.load-config.outputs.config).config.device.systemVersion }} - wait_for_boot: true - erase_before_boot: false - - name: Install app - shell: bash - working-directory: ${{ inputs.projectRoot }} - run: | - xcrun simctl install booted ${{ inputs.app }} - name: Detect Package Manager id: detect-pm shell: bash @@ -83,48 +71,20 @@ runs: PRE_RUN_HOOK: ${{ inputs.preRunHook }} AFTER_RUN_HOOK: ${{ inputs.afterRunHook }} HARNESS_RUNNER: ${{ inputs.runner }} + HARNESS_APP_PATH: ${{ inputs.app }} run: | export HARNESS_PROJECT_ROOT="$PWD" - if [ -n "$PRE_RUN_HOOK" ]; then - pre_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-pre-run.XXXXXX.sh")" - trap 'rm -f "$pre_hook_file"' EXIT - printf '%s\n' "$PRE_RUN_HOOK" > "$pre_hook_file" - chmod +x "$pre_hook_file" - bash "$pre_hook_file" - rm -f "$pre_hook_file" - trap - EXIT - fi - set +e ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} ${{ inputs.harnessArgs }} harness_exit_code=$? set -e - export HARNESS_EXIT_CODE="$harness_exit_code" - after_run_exit_code=0 - if [ -n "$AFTER_RUN_HOOK" ]; then - after_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-after-run.XXXXXX.sh")" - trap 'rm -f "$after_hook_file"' EXIT - printf '%s\n' "$AFTER_RUN_HOOK" > "$after_hook_file" - chmod +x "$after_hook_file" - set +e - bash "$after_hook_file" - after_run_exit_code=$? - set -e - rm -f "$after_hook_file" - trap - EXIT - fi - echo "harness_exit_code=$harness_exit_code" >> "$GITHUB_OUTPUT" if [ "$harness_exit_code" -ne 0 ]; then exit "$harness_exit_code" fi - - if [ "$after_run_exit_code" -ne 0 ]; then - exit "$after_run_exit_code" - fi - name: Upload visual test artifacts if: always() && inputs.uploadVisualTestArtifacts == 'true' uses: actions/upload-artifact@v4 diff --git a/packages/jest/src/__tests__/harness.test.ts b/packages/jest/src/__tests__/harness.test.ts index 4c545e40..23c7c7fd 100644 --- a/packages/jest/src/__tests__/harness.test.ts +++ b/packages/jest/src/__tests__/harness.test.ts @@ -29,10 +29,9 @@ const mocks = vi.hoisted(() => ({ })); vi.mock('@react-native-harness/bundler-metro', async () => { - const actual = - await vi.importActual( - '@react-native-harness/bundler-metro' - ); + const actual = await vi.importActual< + typeof import('@react-native-harness/bundler-metro') + >('@react-native-harness/bundler-metro'); return { ...actual, @@ -51,10 +50,9 @@ vi.mock('../logs.js', () => ({ })); vi.mock('@react-native-harness/tools', async () => { - const actual = - await vi.importActual( - '@react-native-harness/tools' - ); + const actual = await vi.importActual< + typeof import('@react-native-harness/tools') + >('@react-native-harness/tools'); return { ...actual, @@ -186,7 +184,7 @@ const createHarnessConfig = ( unstable__skipAlreadyIncludedModules: false, webSocketPort: 3001, ...overrides, - }) as HarnessConfig; + } as HarnessConfig); beforeEach(() => { vi.clearAllMocks(); @@ -310,9 +308,7 @@ describe('getHarness', () => { mocks.waitForMetroBackedAppReady.mockImplementationOnce( async (options: WaitForMetroBackedAppReadyOptions) => { await options.startAttempt(); - const readyPromise = options.waitForReady( - new AbortController().signal - ); + const readyPromise = options.waitForReady(new AbortController().signal); emitReady(); await readyPromise; } @@ -375,9 +371,7 @@ describe('getHarness', () => { mocks.waitForMetroBackedAppReady.mockImplementationOnce( async (options: WaitForMetroBackedAppReadyOptions) => { await options.startAttempt(); - const readyPromise = options.waitForReady( - new AbortController().signal - ); + const readyPromise = options.waitForReady(new AbortController().signal); emitReady(); await readyPromise; } @@ -473,7 +467,25 @@ describe('plugins', () => { beforeCreation: (ctx) => { ctx.state.creationCount += 1; observedHooks.push( - `beforeCreation:${ctx.platform.platformId}:${ctx.appLaunchOptions == null ? 'no-launch-options' : 'launch-options'}` + `beforeCreation:${ctx.platform.platformId}:${ + ctx.appLaunchOptions == null + ? 'no-launch-options' + : 'launch-options' + }` + ); + }, + beforeRun: (ctx) => { + observedHooks.push( + `beforeRun:${ctx.platform.platformId}:${ + ctx.appLaunchOptions == null + ? 'no-launch-options' + : 'launch-options' + }` + ); + }, + afterRun: (ctx) => { + observedHooks.push( + `afterRun:${ctx.state.creationCount}:${ctx.reason}` ); }, beforeDispose: (ctx) => { @@ -484,7 +496,9 @@ describe('plugins', () => { }, runtime: { ready: (ctx) => { - observedHooks.push(`runtime.ready:${ctx.runId}:${ctx.device.platform}`); + observedHooks.push( + `runtime.ready:${ctx.runId}:${ctx.device.platform}` + ); }, disconnected: (ctx) => { observedHooks.push(`runtime.disconnected:${ctx.reason}`); @@ -547,9 +561,11 @@ describe('plugins', () => { expect(observedHooks).toEqual([ 'beforeCreation:ios:launch-options', + 'beforeRun:ios:launch-options', 'runtime.ready:run-1:ios', 'collection.started:example.harness.ts', 'runtime.disconnected:bridge-disconnected', + 'afterRun:1:normal', 'beforeDispose:1:normal', ]); }); diff --git a/packages/jest/src/action-hooks.ts b/packages/jest/src/action-hooks.ts new file mode 100644 index 00000000..c878050b --- /dev/null +++ b/packages/jest/src/action-hooks.ts @@ -0,0 +1,66 @@ +import { + definePlugin, + type HarnessPlugin, +} from '@react-native-harness/plugins'; +import type { Config as HarnessConfig } from '@react-native-harness/config'; +import type { HarnessPlatform } from '@react-native-harness/platforms'; +import { spawn } from '@react-native-harness/tools'; + +type ActionHookState = { + _unused?: never; +}; + +const getInlineHookScript = ( + name: 'PRE_RUN_HOOK' | 'AFTER_RUN_HOOK' +): string | null => { + const value = process.env[name]?.trim(); + + return value ? value : null; +}; + +const runInlineHook = async (script: string): Promise => { + const env = Object.fromEntries( + Object.entries(process.env).filter( + (entry): entry is [string, string] => entry[1] != null + ) + ); + + await spawn('bash', ['-lc', script], { + env, + cwd: process.env.HARNESS_PROJECT_ROOT, + }); +}; + +export const createActionHooksPlugin = (): HarnessPlugin< + ActionHookState, + HarnessConfig, + HarnessPlatform +> => + definePlugin({ + name: 'github-action-hooks', + createState: () => ({}), + hooks: { + harness: { + beforeRun: async () => { + const script = getInlineHookScript('PRE_RUN_HOOK'); + + if (!script) { + return; + } + + await runInlineHook(script); + }, + afterRun: async (ctx) => { + const script = getInlineHookScript('AFTER_RUN_HOOK'); + + if (!script) { + return; + } + + process.env.HARNESS_EXIT_CODE = ctx.status === 'passed' ? '0' : '1'; + + await runInlineHook(script); + }, + }, + }, + }); diff --git a/packages/jest/src/harness.ts b/packages/jest/src/harness.ts index bfbfe63b..1165f09d 100644 --- a/packages/jest/src/harness.ts +++ b/packages/jest/src/harness.ts @@ -78,10 +78,7 @@ export const maybeLogMetroCacheReuse = ( platform: HarnessPlatform, projectRoot: string ): void => { - if ( - config.unstable__enableMetroCache && - isMetroCacheReusable(projectRoot) - ) { + if (config.unstable__enableMetroCache && isMetroCacheReusable(projectRoot)) { logMetroCacheReused(platform); } }; @@ -211,7 +208,10 @@ const getHarnessInternal = async ( ); maybeLogMetroCacheReuse(config, platform, projectRoot); const pluginAbortController = new AbortController(); - const pluginManager = createHarnessPluginManager({ + const pluginManager = createHarnessPluginManager< + HarnessConfig, + HarnessPlatform + >({ plugins: (config.plugins ?? []) as Array< HarnessPlugin >, @@ -254,7 +254,11 @@ const getHarnessInternal = async ( pendingHookPromises.add(trackedPromise); }; const scheduleHook = < - TName extends keyof FlatHarnessHookContexts, + TName extends keyof FlatHarnessHookContexts< + object, + HarnessConfig, + HarnessPlatform + > >( name: TName, payload: Omit< @@ -303,12 +307,12 @@ const getHarnessInternal = async ( harnessLogger.debug('Metro initialized'); return instance; }), - import(platform.runner).then((module) => - module.default(platform.config, config) - ).then((instance) => { - harnessLogger.debug('platform runner initialized'); - return instance; - }), + import(platform.runner) + .then((module) => module.default(platform.config, config)) + .then((instance) => { + harnessLogger.debug('platform runner initialized'); + return instance; + }), ]); } catch (error) { serverBridge.dispose(); @@ -537,6 +541,14 @@ const getHarnessInternal = async ( let hookError: unknown; try { + await flushPendingHooks(); + await pluginManager.callHook('harness:after-run', { + runId: currentRun?.runId, + reason, + summary: currentRun?.summary, + status: currentRun?.status, + error: currentRun?.error, + }); await flushPendingHooks(); await pluginManager.callHook('harness:before-dispose', { runId: currentRun?.runId, @@ -582,8 +594,13 @@ const getHarnessInternal = async ( await pluginManager.callHook('harness:before-creation', { appLaunchOptions, }); + await flushPendingHooks(); await appMonitor.start(); harnessLogger.debug('app monitor started'); + await pluginManager.callHook('harness:before-run', { + appLaunchOptions, + }); + await flushPendingHooks(); } catch (error) { const runState = currentRun as HarnessRunState | null; @@ -591,7 +608,11 @@ const getHarnessInternal = async ( runState.error = error; currentRun = runState; } - await dispose(error instanceof DOMException && error.name === 'AbortError' ? 'abort' : 'error'); + await dispose( + error instanceof DOMException && error.name === 'AbortError' + ? 'abort' + : 'error' + ); throw error; } @@ -607,7 +628,9 @@ const getHarnessInternal = async ( } crashSupervisor.reset(); - harnessLogger.debug('app not ready, waiting for launch and runtime readiness'); + harnessLogger.debug( + 'app not ready, waiting for launch and runtime readiness' + ); await waitForAppReady({ metroInstance, serverBridge, diff --git a/packages/jest/src/setup.ts b/packages/jest/src/setup.ts index 07d541b3..ec20a6a2 100644 --- a/packages/jest/src/setup.ts +++ b/packages/jest/src/setup.ts @@ -11,6 +11,7 @@ import { logTestEnvironmentReady, logTestRunHeader } from './logs.js'; import { NoRunnerSpecifiedError, RunnerNotFoundError } from './errors.js'; import { HarnessPlatform } from '@react-native-harness/platforms'; import { logger } from '@react-native-harness/tools'; +import { createActionHooksPlugin } from './action-hooks.js'; const setupLogger = logger.child('setup'); @@ -66,13 +67,23 @@ export const setup = async (globalConfig: JestConfig.GlobalConfig) => { const cliArgs = getAdditionalCliArgs(); if (cliArgs.metroPort != null) { - setupLogger.debug('applying CLI metro port override: %d', cliArgs.metroPort); + setupLogger.debug( + 'applying CLI metro port override: %d', + cliArgs.metroPort + ); harnessConfig = ConfigSchema.parse({ ...harnessConfig, metroPort: cliArgs.metroPort, }); } + if (process.env.PRE_RUN_HOOK || process.env.AFTER_RUN_HOOK) { + harnessConfig = ConfigSchema.parse({ + ...harnessConfig, + plugins: [...(harnessConfig.plugins ?? []), createActionHooksPlugin()], + }); + } + const selectedRunner = getHarnessRunner(harnessConfig, cliArgs); if ( diff --git a/packages/platform-ios/src/__tests__/instance.test.ts b/packages/platform-ios/src/__tests__/instance.test.ts index 975863df..c3e1f6bd 100644 --- a/packages/platform-ios/src/__tests__/instance.test.ts +++ b/packages/platform-ios/src/__tests__/instance.test.ts @@ -10,6 +10,7 @@ import { import * as simctl from '../xcrun/simctl.js'; import * as devicectl from '../xcrun/devicectl.js'; import * as libimobiledevice from '../libimobiledevice.js'; +import { HarnessAppPathError } from '../errors.js'; const harnessConfig = { metroPort: DEFAULT_METRO_PORT, @@ -18,6 +19,7 @@ const harnessConfig = { describe('iOS platform instance dependency validation', () => { beforeEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); }); it('does not validate libimobiledevice before creating a simulator instance', async () => { @@ -33,7 +35,11 @@ describe('iOS platform instance dependency validation', () => { const config = { name: 'ios', - device: { type: 'simulator' as const, name: 'iPhone 16 Pro', systemVersion: '18.0' }, + device: { + type: 'simulator' as const, + name: 'iPhone 16 Pro', + systemVersion: '18.0', + }, bundleId: 'com.harnessplayground', }; @@ -61,12 +67,13 @@ describe('iOS platform instance dependency validation', () => { }); it('still discovers the simulator without libimobiledevice', async () => { - vi.spyOn(libimobiledevice, 'assertLibimobiledeviceInstalled').mockResolvedValue( - undefined - ); - const getSimulatorId = vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue( - 'sim-udid' - ); + vi.spyOn( + libimobiledevice, + 'assertLibimobiledeviceInstalled' + ).mockResolvedValue(undefined); + const getSimulatorId = vi + .spyOn(simctl, 'getSimulatorId') + .mockResolvedValue('sim-udid'); vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( @@ -75,7 +82,11 @@ describe('iOS platform instance dependency validation', () => { const config = { name: 'ios', - device: { type: 'simulator' as const, name: 'iPhone 16 Pro', systemVersion: '18.0' }, + device: { + type: 'simulator' as const, + name: 'iPhone 16 Pro', + systemVersion: '18.0', + }, bundleId: 'com.harnessplayground', }; @@ -86,9 +97,10 @@ describe('iOS platform instance dependency validation', () => { }); it('does not try to discover the physical device when the dependency is missing', async () => { - vi.spyOn(libimobiledevice, 'assertLibimobiledeviceInstalled').mockRejectedValue( - new Error('missing') - ); + vi.spyOn( + libimobiledevice, + 'assertLibimobiledeviceInstalled' + ).mockRejectedValue(new Error('missing')); const getDeviceId = vi.spyOn(devicectl, 'getDeviceId'); const config = { @@ -102,4 +114,171 @@ describe('iOS platform instance dependency validation', () => { ).rejects.toThrow('missing'); expect(getDeviceId).not.toHaveBeenCalled(); }); + + it('reuses a booted simulator and does not shut it down on dispose', async () => { + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); + const stopApp = vi.spyOn(simctl, 'stopApp').mockResolvedValue(undefined); + const clearOverride = vi + .spyOn(simctl, 'clearHarnessJsLocationOverride') + .mockResolvedValue(undefined); + const shutdownSimulator = vi + .spyOn(simctl, 'shutdownSimulator') + .mockResolvedValue(undefined); + const applyOverride = vi + .spyOn(simctl, 'applyHarnessJsLocationOverride') + .mockResolvedValue(undefined); + + const instance = await getAppleSimulatorPlatformInstance( + { + name: 'ios', + device: { + type: 'simulator', + name: 'iPhone 16 Pro', + systemVersion: '18.0', + }, + bundleId: 'com.harnessplayground', + }, + harnessConfig + ); + + expect(applyOverride).toHaveBeenCalledWith( + 'sim-udid', + 'com.harnessplayground', + 'localhost:8081' + ); + + await instance.dispose(); + + expect(stopApp).toHaveBeenCalledWith('sim-udid', 'com.harnessplayground'); + expect(clearOverride).toHaveBeenCalledWith( + 'sim-udid', + 'com.harnessplayground' + ); + expect(shutdownSimulator).not.toHaveBeenCalled(); + }); + + it('boots a shutdown simulator and shuts it down on dispose', async () => { + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Shutdown'); + const bootSimulator = vi + .spyOn(simctl, 'bootSimulator') + .mockResolvedValue(undefined); + const waitForBoot = vi + .spyOn(simctl, 'waitForBoot') + .mockResolvedValue(undefined); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( + undefined + ); + vi.spyOn(simctl, 'stopApp').mockResolvedValue(undefined); + vi.spyOn(simctl, 'clearHarnessJsLocationOverride').mockResolvedValue( + undefined + ); + const shutdownSimulator = vi + .spyOn(simctl, 'shutdownSimulator') + .mockResolvedValue(undefined); + + const instance = await getAppleSimulatorPlatformInstance( + { + name: 'ios', + device: { + type: 'simulator', + name: 'iPhone 16 Pro', + systemVersion: '18.0', + }, + bundleId: 'com.harnessplayground', + }, + harnessConfig + ); + + expect(bootSimulator).toHaveBeenCalledWith('sim-udid'); + expect(waitForBoot).toHaveBeenCalledWith('sim-udid'); + + await instance.dispose(); + + expect(shutdownSimulator).toHaveBeenCalledWith('sim-udid'); + }); + + it('installs the app from HARNESS_APP_PATH when missing', async () => { + vi.stubEnv('HARNESS_APP_PATH', '/tmp/HarnessPlayground.app'); + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(false); + const installApp = vi + .spyOn(simctl, 'installApp') + .mockResolvedValue(undefined); + vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( + undefined + ); + const existsSync = vi + .spyOn(await import('node:fs'), 'existsSync') + .mockReturnValue(true); + + await expect( + getAppleSimulatorPlatformInstance( + { + name: 'ios', + device: { + type: 'simulator', + name: 'iPhone 16 Pro', + systemVersion: '18.0', + }, + bundleId: 'com.harnessplayground', + }, + harnessConfig + ) + ).resolves.toBeDefined(); + + expect(existsSync).toHaveBeenCalledWith('/tmp/HarnessPlayground.app'); + expect(installApp).toHaveBeenCalledWith( + 'sim-udid', + '/tmp/HarnessPlayground.app' + ); + }); + + it('throws a HarnessAppPathError when HARNESS_APP_PATH is missing', async () => { + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(false); + + await expect( + getAppleSimulatorPlatformInstance( + { + name: 'ios', + device: { + type: 'simulator', + name: 'iPhone 16 Pro', + systemVersion: '18.0', + }, + bundleId: 'com.harnessplayground', + }, + harnessConfig + ) + ).rejects.toBeInstanceOf(HarnessAppPathError); + }); + + it('throws a HarnessAppPathError when HARNESS_APP_PATH points to a missing app', async () => { + vi.stubEnv('HARNESS_APP_PATH', '/tmp/missing.app'); + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(false); + vi.spyOn(await import('node:fs'), 'existsSync').mockReturnValue(false); + + await expect( + getAppleSimulatorPlatformInstance( + { + name: 'ios', + device: { + type: 'simulator', + name: 'iPhone 16 Pro', + systemVersion: '18.0', + }, + bundleId: 'com.harnessplayground', + }, + harnessConfig + ) + ).rejects.toBeInstanceOf(HarnessAppPathError); + }); }); diff --git a/packages/platform-ios/src/errors.ts b/packages/platform-ios/src/errors.ts new file mode 100644 index 00000000..e7f06902 --- /dev/null +++ b/packages/platform-ios/src/errors.ts @@ -0,0 +1,12 @@ +export class HarnessAppPathError extends Error { + constructor(reason: 'missing' | 'invalid', appPath?: string) { + super( + reason === 'missing' + ? 'App is not installed on the simulator and HARNESS_APP_PATH is not set.' + : `HARNESS_APP_PATH points to a missing app bundle: ${ + appPath ?? '' + }` + ); + this.name = 'HarnessAppPathError'; + } +} diff --git a/packages/platform-ios/src/index.ts b/packages/platform-ios/src/index.ts index 54998a0a..555b3e00 100644 --- a/packages/platform-ios/src/index.ts +++ b/packages/platform-ios/src/index.ts @@ -4,4 +4,5 @@ export { applePhysicalDevice, } from './factory.js'; export type { ApplePlatformConfig } from './config.js'; -export { getRunTargets } from './targets.js'; \ No newline at end of file +export { HarnessAppPathError } from './errors.js'; +export { getRunTargets } from './targets.js'; diff --git a/packages/platform-ios/src/instance.ts b/packages/platform-ios/src/instance.ts index 13864667..543c843c 100644 --- a/packages/platform-ios/src/instance.ts +++ b/packages/platform-ios/src/instance.ts @@ -21,6 +21,22 @@ import { createIosSimulatorAppMonitor, } from './app-monitor.js'; import { assertLibimobiledeviceInstalled } from './libimobiledevice.js'; +import { HarnessAppPathError } from './errors.js'; +import fs from 'node:fs'; + +const getHarnessAppPath = (): string => { + const appPath = process.env.HARNESS_APP_PATH; + + if (!appPath) { + throw new HarnessAppPathError('missing'); + } + + if (!fs.existsSync(appPath)) { + throw new HarnessAppPathError('invalid', appPath); + } + + return appPath; +}; export const getAppleSimulatorPlatformInstance = async ( config: ApplePlatformConfig, @@ -37,19 +53,23 @@ export const getAppleSimulatorPlatformInstance = async ( throw new DeviceNotFoundError(getDeviceName(config.device)); } - const isInstalled = await simctl.isAppInstalled(udid, config.bundleId); + const simulatorStatus = await simctl.getSimulatorStatus(udid); + let startedByHarness = false; - if (!isInstalled) { - throw new AppNotInstalledError( - config.bundleId, - getDeviceName(config.device) - ); + if (simulatorStatus === 'Shutdown') { + await simctl.bootSimulator(udid); + startedByHarness = true; } - const simulatorStatus = await simctl.getSimulatorStatus(udid); + if (simulatorStatus === 'Shutdown' || simulatorStatus === 'Booting') { + await simctl.waitForBoot(udid); + } + + const isInstalled = await simctl.isAppInstalled(udid, config.bundleId); - if (simulatorStatus !== 'Booted') { - throw new Error('Simulator is not booted'); + if (!isInstalled) { + const appPath = getHarnessAppPath(); + await simctl.installApp(udid, appPath); } await simctl.applyHarnessJsLocationOverride( @@ -82,6 +102,10 @@ export const getAppleSimulatorPlatformInstance = async ( dispose: async () => { await simctl.stopApp(udid, config.bundleId); await simctl.clearHarnessJsLocationOverride(udid, config.bundleId); + + if (startedByHarness) { + await simctl.shutdownSimulator(udid); + } }, isAppRunning: async () => { return await simctl.isAppRunning(udid, config.bundleId); diff --git a/packages/platform-ios/src/xcrun/simctl.ts b/packages/platform-ios/src/xcrun/simctl.ts index fb007103..6619aefb 100644 --- a/packages/platform-ios/src/xcrun/simctl.ts +++ b/packages/platform-ios/src/xcrun/simctl.ts @@ -285,6 +285,25 @@ export const stopApp = async ( await spawnAndForget('xcrun', ['simctl', 'terminate', udid, bundleId]); }; +export const bootSimulator = async (udid: string): Promise => { + await spawn('xcrun', ['simctl', 'boot', udid]); +}; + +export const waitForBoot = async (udid: string): Promise => { + await spawn('xcrun', ['simctl', 'bootstatus', udid, '-b']); +}; + +export const shutdownSimulator = async (udid: string): Promise => { + await spawnAndForget('xcrun', ['simctl', 'shutdown', udid]); +}; + +export const installApp = async ( + udid: string, + appPath: string +): Promise => { + await spawn('xcrun', ['simctl', 'install', udid, appPath]); +}; + export const getSimulatorId = async ( name: string, systemVersion: string @@ -399,7 +418,11 @@ export const applyHarnessJsLocationOverride = async ( ); if (backupValue === null) { - const existingValue = await getDefaultsValue(udid, bundleId, 'RCT_jsLocation'); + const existingValue = await getDefaultsValue( + udid, + bundleId, + 'RCT_jsLocation' + ); await writeDefaultsValue( udid, bundleId, diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts index a8f2b861..8ab0fea6 100644 --- a/packages/plugins/src/index.ts +++ b/packages/plugins/src/index.ts @@ -9,8 +9,10 @@ export type { CollectionStartedContext, FlatHarnessHookContexts, FlatHarnessHookName, + HarnessAfterRunContext, HarnessBaseHookContext, HarnessBeforeCreationContext, + HarnessBeforeRunContext, HarnessBeforeDisposeContext, HarnessHookHandler, HarnessHookMeta, diff --git a/packages/plugins/src/types.ts b/packages/plugins/src/types.ts index 2b2e6e2f..32560f34 100644 --- a/packages/plugins/src/types.ts +++ b/packages/plugins/src/types.ts @@ -43,7 +43,7 @@ export type HarnessBaseHookContext< TState extends object, TConfig, TRunner extends HarnessPlatform, - TName extends string, + TName extends string > = { plugin: { name: string; @@ -62,7 +62,7 @@ export type HarnessBaseHookContext< export type HarnessBeforeCreationContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, + TRunner extends HarnessPlatform > = HarnessBaseHookContext< TState, TConfig, @@ -75,7 +75,7 @@ export type HarnessBeforeCreationContext< export type HarnessBeforeDisposeContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, + TRunner extends HarnessPlatform > = HarnessBaseHookContext< TState, TConfig, @@ -89,10 +89,30 @@ export type HarnessBeforeDisposeContext< error?: unknown; }; +export type HarnessBeforeRunContext< + TState extends object, + TConfig, + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { + appLaunchOptions?: AppLaunchOptions; +}; + +export type HarnessAfterRunContext< + TState extends object, + TConfig, + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { + runId?: string; + reason?: 'normal' | 'abort' | 'error'; + summary?: HarnessRunSummary; + status?: HarnessRunStatus; + error?: unknown; +}; + export type RunStartedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, + TRunner extends HarnessPlatform > = HarnessBaseHookContext & { runId: string; startTime: number; @@ -104,7 +124,7 @@ export type RunStartedContext< export type RunFinishedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, + TRunner extends HarnessPlatform > = HarnessBaseHookContext & { runId: string; startTime: number; @@ -118,7 +138,7 @@ export type RunFinishedContext< export type RuntimeReadyContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, + TRunner extends HarnessPlatform > = HarnessBaseHookContext & { runId: string; device: DeviceDescriptor; @@ -127,13 +147,8 @@ export type RuntimeReadyContext< export type RuntimeDisconnectedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, -> = HarnessBaseHookContext< - TState, - TConfig, - TRunner, - 'runtime:disconnected' -> & { + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { runId: string; reason?: string; }; @@ -141,13 +156,8 @@ export type RuntimeDisconnectedContext< export type MetroInitializedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, -> = HarnessBaseHookContext< - TState, - TConfig, - TRunner, - 'metro:initialized' -> & { + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { runId: string; port: number; host?: string; @@ -158,13 +168,8 @@ export type MetroBundleTarget = 'module' | 'setupFile'; export type MetroBundleStartedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, -> = HarnessBaseHookContext< - TState, - TConfig, - TRunner, - 'metro:bundle-started' -> & { + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { runId: string; target: MetroBundleTarget; file: string; @@ -174,7 +179,7 @@ export type MetroBundleStartedContext< export type MetroBundleFinishedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, + TRunner extends HarnessPlatform > = HarnessBaseHookContext< TState, TConfig, @@ -191,13 +196,8 @@ export type MetroBundleFinishedContext< export type MetroBundleFailedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, -> = HarnessBaseHookContext< - TState, - TConfig, - TRunner, - 'metro:bundle-failed' -> & { + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { runId: string; target: MetroBundleTarget; file: string; @@ -220,13 +220,8 @@ export type MetroClientLogLevel = export type MetroClientLogContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, -> = HarnessBaseHookContext< - TState, - TConfig, - TRunner, - 'metro:client-log' -> & { + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { runId: string; level: MetroClientLogLevel; data: unknown[]; @@ -235,7 +230,7 @@ export type MetroClientLogContext< export type AppStartedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, + TRunner extends HarnessPlatform > = HarnessBaseHookContext & { runId: string; testFile?: string; @@ -247,7 +242,7 @@ export type AppStartedContext< export type AppExitedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, + TRunner extends HarnessPlatform > = HarnessBaseHookContext & { runId: string; testFile?: string; @@ -261,13 +256,8 @@ export type AppExitedContext< export type AppPossibleCrashContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, -> = HarnessBaseHookContext< - TState, - TConfig, - TRunner, - 'app:possible-crash' -> & { + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { runId: string; testFile?: string; pid?: number; @@ -280,13 +270,8 @@ export type AppPossibleCrashContext< export type CollectionStartedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, -> = HarnessBaseHookContext< - TState, - TConfig, - TRunner, - 'collection:started' -> & { + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { runId: string; file: string; }; @@ -294,13 +279,8 @@ export type CollectionStartedContext< export type CollectionFinishedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, -> = HarnessBaseHookContext< - TState, - TConfig, - TRunner, - 'collection:finished' -> & { + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { runId: string; file: string; duration: number; @@ -310,13 +290,8 @@ export type CollectionFinishedContext< export type TestFileStartedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, -> = HarnessBaseHookContext< - TState, - TConfig, - TRunner, - 'test-file:started' -> & { + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { runId: string; file: string; }; @@ -324,13 +299,8 @@ export type TestFileStartedContext< export type TestFileFinishedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, -> = HarnessBaseHookContext< - TState, - TConfig, - TRunner, - 'test-file:finished' -> & { + TRunner extends HarnessPlatform +> = HarnessBaseHookContext & { runId: string; file: string; duration: number; @@ -341,7 +311,7 @@ export type TestFileFinishedContext< export type SuiteStartedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, + TRunner extends HarnessPlatform > = HarnessBaseHookContext & { runId: string; file: string; @@ -351,7 +321,7 @@ export type SuiteStartedContext< export type SuiteFinishedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, + TRunner extends HarnessPlatform > = HarnessBaseHookContext & { runId: string; file: string; @@ -364,7 +334,7 @@ export type SuiteFinishedContext< export type TestStartedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, + TRunner extends HarnessPlatform > = HarnessBaseHookContext & { runId: string; file: string; @@ -375,7 +345,7 @@ export type TestStartedContext< export type TestFinishedContext< TState extends object, TConfig, - TRunner extends HarnessPlatform, + TRunner extends HarnessPlatform > = HarnessBaseHookContext & { runId: string; file: string; @@ -389,7 +359,7 @@ export type TestFinishedContext< export type FlatHarnessHookContexts< TState extends object, TConfig, - TRunner extends HarnessPlatform, + TRunner extends HarnessPlatform > = { 'harness:before-creation': HarnessBeforeCreationContext< TState, @@ -401,30 +371,16 @@ export type FlatHarnessHookContexts< TConfig, TRunner >; + 'harness:before-run': HarnessBeforeRunContext; + 'harness:after-run': HarnessAfterRunContext; 'run:started': RunStartedContext; 'run:finished': RunFinishedContext; 'runtime:ready': RuntimeReadyContext; - 'runtime:disconnected': RuntimeDisconnectedContext< - TState, - TConfig, - TRunner - >; + 'runtime:disconnected': RuntimeDisconnectedContext; 'metro:initialized': MetroInitializedContext; - 'metro:bundle-started': MetroBundleStartedContext< - TState, - TConfig, - TRunner - >; - 'metro:bundle-finished': MetroBundleFinishedContext< - TState, - TConfig, - TRunner - >; - 'metro:bundle-failed': MetroBundleFailedContext< - TState, - TConfig, - TRunner - >; + 'metro:bundle-started': MetroBundleStartedContext; + 'metro:bundle-finished': MetroBundleFinishedContext; + 'metro:bundle-failed': MetroBundleFailedContext; 'metro:client-log': MetroClientLogContext; 'app:started': AppStartedContext; 'app:exited': AppExitedContext; @@ -450,26 +406,28 @@ export type HarnessHookHandler = (ctx: TContext) => Awaitable; export type HarnessPluginHooks< TState extends object = Record, TConfig = unknown, - TRunner extends HarnessPlatform = HarnessPlatform, + TRunner extends HarnessPlatform = HarnessPlatform > = { harness?: { beforeCreation?: HarnessHookHandler< HarnessBeforeCreationContext >; + beforeRun?: HarnessHookHandler< + HarnessBeforeRunContext + >; + afterRun?: HarnessHookHandler< + HarnessAfterRunContext + >; beforeDispose?: HarnessHookHandler< HarnessBeforeDisposeContext >; }; run?: { started?: HarnessHookHandler>; - finished?: HarnessHookHandler< - RunFinishedContext - >; + finished?: HarnessHookHandler>; }; runtime?: { - ready?: HarnessHookHandler< - RuntimeReadyContext - >; + ready?: HarnessHookHandler>; disconnected?: HarnessHookHandler< RuntimeDisconnectedContext >; @@ -531,7 +489,7 @@ export type HarnessPluginHooks< export type HarnessPlugin< TState extends object = Record, TConfig = unknown, - TRunner extends HarnessPlatform = HarnessPlatform, + TRunner extends HarnessPlatform = HarnessPlatform > = { name: string; hooks?: HarnessPluginHooks; @@ -540,6 +498,8 @@ export type HarnessPlugin< export const HARNESS_HOOKS = [ { flatName: 'harness:before-creation', path: ['harness', 'beforeCreation'] }, + { flatName: 'harness:before-run', path: ['harness', 'beforeRun'] }, + { flatName: 'harness:after-run', path: ['harness', 'afterRun'] }, { flatName: 'harness:before-dispose', path: ['harness', 'beforeDispose'] }, { flatName: 'run:started', path: ['run', 'started'] }, { flatName: 'run:finished', path: ['run', 'finished'] }, From 864ab593fb9b121535e9c39159c798c37f839ad2 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 31 Mar 2026 11:37:09 +0200 Subject: [PATCH 02/26] feat: inline Android emulator handling --- action.yml | 79 +---- actions/android/action.yml | 77 ++--- packages/github-action/src/action.yml | 79 +---- packages/github-action/src/android/action.yml | 77 ++--- .../src/__tests__/adb.test.ts | 78 ++++- .../src/__tests__/instance.test.ts | 271 ++++++++++++++++++ packages/platform-android/src/adb.ts | 122 +++++++- packages/platform-android/src/errors.ts | 19 ++ packages/platform-android/src/index.ts | 3 +- packages/platform-android/src/instance.ts | 208 ++++++++++++++ packages/platform-android/src/runner.ts | 82 +----- 11 files changed, 753 insertions(+), 342 deletions(-) create mode 100644 packages/platform-android/src/__tests__/instance.test.ts create mode 100644 packages/platform-android/src/errors.ts create mode 100644 packages/platform-android/src/instance.ts diff --git a/action.yml b/action.yml index b6808853..c41aac43 100644 --- a/action.yml +++ b/action.yml @@ -75,18 +75,17 @@ runs: # iOS simulator boot and app installation are handled by Harness itself. # ── Android ────────────────────────────────────────────────────────────── - name: Verify Android config - if: fromJson(steps.load-config.outputs.config).platformId == 'android' + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} shell: bash run: | - CONFIG='${{ steps.load-config.outputs.config }}' - if [ -z "$CONFIG.config.device.avd" ] || [ "$CONFIG.config.device.avd" = "null" ]; then + if [ '${{ fromJson(steps.load-config.outputs.config).config.device.avd }}' = 'null' ]; then echo "Error: AVD config is required for Android emulators" echo "Please define the 'avd' property in the runner config" exit 1 fi - name: Get architecture of the runner id: arch - if: fromJson(steps.load-config.outputs.config).platformId == 'android' + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} shell: bash run: | case "${{ runner.arch }}" in @@ -104,7 +103,7 @@ runs: ;; esac - name: Enable KVM group perms - if: fromJson(steps.load-config.outputs.config).platformId == 'android' + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} shell: bash run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules @@ -113,7 +112,7 @@ runs: ls /dev/kvm - name: Compute AVD cache key id: avd-key - if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJSON(inputs.cacheAvd) }} + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) }} shell: bash run: | CONFIG='${{ steps.load-config.outputs.config }}' @@ -125,14 +124,14 @@ runs: - name: Restore AVD cache uses: actions/cache/restore@v4 id: avd-cache - if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJSON(inputs.cacheAvd) }} + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) }} with: path: | ~/.android/avd ~/.android/adb* key: ${{ steps.avd-key.outputs.key }} - name: Create AVD and generate snapshot for caching - if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }} @@ -146,7 +145,7 @@ runs: emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none script: echo "Generated AVD snapshot for caching." - name: Save AVD cache - if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} uses: actions/cache/save@v4 with: path: | @@ -212,7 +211,6 @@ runs: fi - name: Run E2E tests id: run-tests - if: fromJson(steps.load-config.outputs.config).platformId != 'android' shell: bash working-directory: ${{ steps.load-config.outputs.projectRoot }} env: @@ -233,67 +231,6 @@ runs: if [ "$harness_exit_code" -ne 0 ]; then exit "$harness_exit_code" fi - - name: Run E2E tests - id: run-tests-android - if: fromJson(steps.load-config.outputs.config).platformId == 'android' - uses: reactivecircus/android-emulator-runner@v2 - env: - PRE_RUN_HOOK: ${{ inputs.preRunHook }} - AFTER_RUN_HOOK: ${{ inputs.afterRunHook }} - HARNESS_RUNNER: ${{ inputs.runner }} - # android-emulator-runner executes each script line via `sh -c`, so multi-line - # shell control flow must live in a separate bash script instead of `with.script`. - HARNESS_ANDROID_SESSION_SCRIPT: |- - export HARNESS_PROJECT_ROOT="$PWD" - - adb install -r "${{ inputs.app }}" - - if [ -n "$PRE_RUN_HOOK" ]; then - pre_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-pre-run.XXXXXX.sh")" - printf "%s\n" "$PRE_RUN_HOOK" > "$pre_hook_file" - chmod +x "$pre_hook_file" - bash "$pre_hook_file" - rm -f "$pre_hook_file" - fi - - set +e - ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner "${{ inputs.runner }}" ${{ inputs.harnessArgs }} - harness_exit_code=$? - echo "harness_exit_code=$harness_exit_code" >> "$GITHUB_OUTPUT" - set -e - - export HARNESS_EXIT_CODE="$harness_exit_code" - after_run_exit_code=0 - if [ -n "$AFTER_RUN_HOOK" ]; then - after_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-after-run.XXXXXX.sh")" - printf "%s\n" "$AFTER_RUN_HOOK" > "$after_hook_file" - chmod +x "$after_hook_file" - set +e - bash "$after_hook_file" - after_run_exit_code=$? - set -e - rm -f "$after_hook_file" - fi - - if [ "$harness_exit_code" -ne 0 ]; then - exit "$harness_exit_code" - fi - - if [ "$after_run_exit_code" -ne 0 ]; then - exit "$after_run_exit_code" - fi - with: - working-directory: ${{ steps.load-config.outputs.projectRoot }} - api-level: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }} - arch: ${{ steps.arch.outputs.arch }} - force-avd-creation: false - avd-name: ${{ fromJson(steps.load-config.outputs.config).config.device.name }} - disable-animations: true - emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - # Keep `script` to a single line so the emulator action does not split our bash - # session apart before the hooks and Harness command run. - script: >- - harness_script_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-android-run.XXXXXX.sh")"; printf '%s\n' "$HARNESS_ANDROID_SESSION_SCRIPT" > "$harness_script_file"; chmod +x "$harness_script_file"; bash "$harness_script_file"; status=$?; rm -f "$harness_script_file"; exit "$status" - name: Upload visual test artifacts if: always() && inputs.uploadVisualTestArtifacts == 'true' uses: actions/upload-artifact@v4 diff --git a/actions/android/action.yml b/actions/android/action.yml index 2560545b..4dc1ed2b 100644 --- a/actions/android/action.yml +++ b/actions/android/action.yml @@ -49,16 +49,17 @@ runs: run: | node ${{ github.action_path }}/../shared/index.cjs - name: Verify Android config + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} shell: bash run: | - CONFIG='${{ steps.load-config.outputs.config }}' - if [ -z "$CONFIG.config.device.avd" ] || [ "$CONFIG.config.device.avd" = "null" ]; then + if [ '${{ fromJson(steps.load-config.outputs.config).config.device.avd }}' = 'null' ]; then echo "Error: AVD config is required for Android emulators" echo "Please define the 'avd' property in the runner config" exit 1 fi - name: Get architecture of the runner id: arch + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} shell: bash run: | case "${{ runner.arch }}" in @@ -76,6 +77,7 @@ runs: ;; esac - name: Enable KVM group perms + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} shell: bash run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules @@ -83,7 +85,7 @@ runs: sudo udevadm trigger --name-match=kvm ls /dev/kvm - name: Compute AVD cache key - if: ${{ fromJSON(inputs.cacheAvd) }} + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) }} id: avd-key shell: bash run: | @@ -94,7 +96,7 @@ runs: CACHE_KEY="avd-$ARCH-$AVD_CONFIG_HASH" echo "key=$CACHE_KEY" >> $GITHUB_OUTPUT - name: Restore AVD cache - if: ${{ fromJSON(inputs.cacheAvd) }} + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) }} uses: actions/cache/restore@v4 id: avd-cache with: @@ -103,7 +105,7 @@ runs: ~/.android/adb* key: ${{ steps.avd-key.outputs.key }} - name: Create AVD and generate snapshot for caching - if: ${{ fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }} @@ -117,7 +119,7 @@ runs: emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none script: echo "Generated AVD snapshot for caching." - name: Save AVD cache - if: ${{ fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} uses: actions/cache/save@v4 with: path: | @@ -146,63 +148,26 @@ runs: fi - name: Run E2E tests id: run-tests - uses: reactivecircus/android-emulator-runner@v2 + shell: bash + working-directory: ${{ inputs.projectRoot }} env: PRE_RUN_HOOK: ${{ inputs.preRunHook }} AFTER_RUN_HOOK: ${{ inputs.afterRunHook }} HARNESS_RUNNER: ${{ inputs.runner }} - # android-emulator-runner executes each script line via `sh -c`, so multi-line - # shell control flow must live in a separate bash script instead of `with.script`. - HARNESS_ANDROID_SESSION_SCRIPT: |- - export HARNESS_PROJECT_ROOT="$PWD" - - adb install -r "${{ inputs.app }}" - - if [ -n "$PRE_RUN_HOOK" ]; then - pre_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-pre-run.XXXXXX.sh")" - printf "%s\n" "$PRE_RUN_HOOK" > "$pre_hook_file" - chmod +x "$pre_hook_file" - bash "$pre_hook_file" - rm -f "$pre_hook_file" - fi - - set +e - ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner "${{ inputs.runner }}" ${{ inputs.harnessArgs }} - harness_exit_code=$? - set -e + HARNESS_APP_PATH: ${{ inputs.app }} + run: | + export HARNESS_PROJECT_ROOT="$PWD" - export HARNESS_EXIT_CODE="$harness_exit_code" - after_run_exit_code=0 - if [ -n "$AFTER_RUN_HOOK" ]; then - after_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-after-run.XXXXXX.sh")" - printf "%s\n" "$AFTER_RUN_HOOK" > "$after_hook_file" - chmod +x "$after_hook_file" - set +e - bash "$after_hook_file" - after_run_exit_code=$? - set -e - rm -f "$after_hook_file" - fi + set +e + ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} ${{ inputs.harnessArgs }} + harness_exit_code=$? + set -e - if [ "$harness_exit_code" -ne 0 ]; then - exit "$harness_exit_code" - fi + echo "harness_exit_code=$harness_exit_code" >> "$GITHUB_OUTPUT" - if [ "$after_run_exit_code" -ne 0 ]; then - exit "$after_run_exit_code" - fi - with: - working-directory: ${{ inputs.projectRoot }} - api-level: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }} - arch: ${{ steps.arch.outputs.arch }} - force-avd-creation: false - avd-name: ${{ fromJson(steps.load-config.outputs.config).config.device.name }} - disable-animations: true - emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - # Keep `script` to a single line so the emulator action does not split our bash - # session apart before the hooks and Harness command run. - script: >- - harness_script_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-android-run.XXXXXX.sh")"; printf '%s\n' "$HARNESS_ANDROID_SESSION_SCRIPT" > "$harness_script_file"; chmod +x "$harness_script_file"; bash "$harness_script_file"; status=$?; rm -f "$harness_script_file"; exit "$status" + if [ "$harness_exit_code" -ne 0 ]; then + exit "$harness_exit_code" + fi - name: Upload visual test artifacts if: always() && inputs.uploadVisualTestArtifacts == 'true' uses: actions/upload-artifact@v4 diff --git a/packages/github-action/src/action.yml b/packages/github-action/src/action.yml index b6808853..c41aac43 100644 --- a/packages/github-action/src/action.yml +++ b/packages/github-action/src/action.yml @@ -75,18 +75,17 @@ runs: # iOS simulator boot and app installation are handled by Harness itself. # ── Android ────────────────────────────────────────────────────────────── - name: Verify Android config - if: fromJson(steps.load-config.outputs.config).platformId == 'android' + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} shell: bash run: | - CONFIG='${{ steps.load-config.outputs.config }}' - if [ -z "$CONFIG.config.device.avd" ] || [ "$CONFIG.config.device.avd" = "null" ]; then + if [ '${{ fromJson(steps.load-config.outputs.config).config.device.avd }}' = 'null' ]; then echo "Error: AVD config is required for Android emulators" echo "Please define the 'avd' property in the runner config" exit 1 fi - name: Get architecture of the runner id: arch - if: fromJson(steps.load-config.outputs.config).platformId == 'android' + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} shell: bash run: | case "${{ runner.arch }}" in @@ -104,7 +103,7 @@ runs: ;; esac - name: Enable KVM group perms - if: fromJson(steps.load-config.outputs.config).platformId == 'android' + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} shell: bash run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules @@ -113,7 +112,7 @@ runs: ls /dev/kvm - name: Compute AVD cache key id: avd-key - if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJSON(inputs.cacheAvd) }} + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) }} shell: bash run: | CONFIG='${{ steps.load-config.outputs.config }}' @@ -125,14 +124,14 @@ runs: - name: Restore AVD cache uses: actions/cache/restore@v4 id: avd-cache - if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJSON(inputs.cacheAvd) }} + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) }} with: path: | ~/.android/avd ~/.android/adb* key: ${{ steps.avd-key.outputs.key }} - name: Create AVD and generate snapshot for caching - if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }} @@ -146,7 +145,7 @@ runs: emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none script: echo "Generated AVD snapshot for caching." - name: Save AVD cache - if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} uses: actions/cache/save@v4 with: path: | @@ -212,7 +211,6 @@ runs: fi - name: Run E2E tests id: run-tests - if: fromJson(steps.load-config.outputs.config).platformId != 'android' shell: bash working-directory: ${{ steps.load-config.outputs.projectRoot }} env: @@ -233,67 +231,6 @@ runs: if [ "$harness_exit_code" -ne 0 ]; then exit "$harness_exit_code" fi - - name: Run E2E tests - id: run-tests-android - if: fromJson(steps.load-config.outputs.config).platformId == 'android' - uses: reactivecircus/android-emulator-runner@v2 - env: - PRE_RUN_HOOK: ${{ inputs.preRunHook }} - AFTER_RUN_HOOK: ${{ inputs.afterRunHook }} - HARNESS_RUNNER: ${{ inputs.runner }} - # android-emulator-runner executes each script line via `sh -c`, so multi-line - # shell control flow must live in a separate bash script instead of `with.script`. - HARNESS_ANDROID_SESSION_SCRIPT: |- - export HARNESS_PROJECT_ROOT="$PWD" - - adb install -r "${{ inputs.app }}" - - if [ -n "$PRE_RUN_HOOK" ]; then - pre_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-pre-run.XXXXXX.sh")" - printf "%s\n" "$PRE_RUN_HOOK" > "$pre_hook_file" - chmod +x "$pre_hook_file" - bash "$pre_hook_file" - rm -f "$pre_hook_file" - fi - - set +e - ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner "${{ inputs.runner }}" ${{ inputs.harnessArgs }} - harness_exit_code=$? - echo "harness_exit_code=$harness_exit_code" >> "$GITHUB_OUTPUT" - set -e - - export HARNESS_EXIT_CODE="$harness_exit_code" - after_run_exit_code=0 - if [ -n "$AFTER_RUN_HOOK" ]; then - after_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-after-run.XXXXXX.sh")" - printf "%s\n" "$AFTER_RUN_HOOK" > "$after_hook_file" - chmod +x "$after_hook_file" - set +e - bash "$after_hook_file" - after_run_exit_code=$? - set -e - rm -f "$after_hook_file" - fi - - if [ "$harness_exit_code" -ne 0 ]; then - exit "$harness_exit_code" - fi - - if [ "$after_run_exit_code" -ne 0 ]; then - exit "$after_run_exit_code" - fi - with: - working-directory: ${{ steps.load-config.outputs.projectRoot }} - api-level: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }} - arch: ${{ steps.arch.outputs.arch }} - force-avd-creation: false - avd-name: ${{ fromJson(steps.load-config.outputs.config).config.device.name }} - disable-animations: true - emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - # Keep `script` to a single line so the emulator action does not split our bash - # session apart before the hooks and Harness command run. - script: >- - harness_script_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-android-run.XXXXXX.sh")"; printf '%s\n' "$HARNESS_ANDROID_SESSION_SCRIPT" > "$harness_script_file"; chmod +x "$harness_script_file"; bash "$harness_script_file"; status=$?; rm -f "$harness_script_file"; exit "$status" - name: Upload visual test artifacts if: always() && inputs.uploadVisualTestArtifacts == 'true' uses: actions/upload-artifact@v4 diff --git a/packages/github-action/src/android/action.yml b/packages/github-action/src/android/action.yml index 2560545b..4dc1ed2b 100644 --- a/packages/github-action/src/android/action.yml +++ b/packages/github-action/src/android/action.yml @@ -49,16 +49,17 @@ runs: run: | node ${{ github.action_path }}/../shared/index.cjs - name: Verify Android config + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} shell: bash run: | - CONFIG='${{ steps.load-config.outputs.config }}' - if [ -z "$CONFIG.config.device.avd" ] || [ "$CONFIG.config.device.avd" = "null" ]; then + if [ '${{ fromJson(steps.load-config.outputs.config).config.device.avd }}' = 'null' ]; then echo "Error: AVD config is required for Android emulators" echo "Please define the 'avd' property in the runner config" exit 1 fi - name: Get architecture of the runner id: arch + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} shell: bash run: | case "${{ runner.arch }}" in @@ -76,6 +77,7 @@ runs: ;; esac - name: Enable KVM group perms + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} shell: bash run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules @@ -83,7 +85,7 @@ runs: sudo udevadm trigger --name-match=kvm ls /dev/kvm - name: Compute AVD cache key - if: ${{ fromJSON(inputs.cacheAvd) }} + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) }} id: avd-key shell: bash run: | @@ -94,7 +96,7 @@ runs: CACHE_KEY="avd-$ARCH-$AVD_CONFIG_HASH" echo "key=$CACHE_KEY" >> $GITHUB_OUTPUT - name: Restore AVD cache - if: ${{ fromJSON(inputs.cacheAvd) }} + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) }} uses: actions/cache/restore@v4 id: avd-cache with: @@ -103,7 +105,7 @@ runs: ~/.android/adb* key: ${{ steps.avd-key.outputs.key }} - name: Create AVD and generate snapshot for caching - if: ${{ fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }} @@ -117,7 +119,7 @@ runs: emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none script: echo "Generated AVD snapshot for caching." - name: Save AVD cache - if: ${{ fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} uses: actions/cache/save@v4 with: path: | @@ -146,63 +148,26 @@ runs: fi - name: Run E2E tests id: run-tests - uses: reactivecircus/android-emulator-runner@v2 + shell: bash + working-directory: ${{ inputs.projectRoot }} env: PRE_RUN_HOOK: ${{ inputs.preRunHook }} AFTER_RUN_HOOK: ${{ inputs.afterRunHook }} HARNESS_RUNNER: ${{ inputs.runner }} - # android-emulator-runner executes each script line via `sh -c`, so multi-line - # shell control flow must live in a separate bash script instead of `with.script`. - HARNESS_ANDROID_SESSION_SCRIPT: |- - export HARNESS_PROJECT_ROOT="$PWD" - - adb install -r "${{ inputs.app }}" - - if [ -n "$PRE_RUN_HOOK" ]; then - pre_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-pre-run.XXXXXX.sh")" - printf "%s\n" "$PRE_RUN_HOOK" > "$pre_hook_file" - chmod +x "$pre_hook_file" - bash "$pre_hook_file" - rm -f "$pre_hook_file" - fi - - set +e - ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner "${{ inputs.runner }}" ${{ inputs.harnessArgs }} - harness_exit_code=$? - set -e + HARNESS_APP_PATH: ${{ inputs.app }} + run: | + export HARNESS_PROJECT_ROOT="$PWD" - export HARNESS_EXIT_CODE="$harness_exit_code" - after_run_exit_code=0 - if [ -n "$AFTER_RUN_HOOK" ]; then - after_hook_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-after-run.XXXXXX.sh")" - printf "%s\n" "$AFTER_RUN_HOOK" > "$after_hook_file" - chmod +x "$after_hook_file" - set +e - bash "$after_hook_file" - after_run_exit_code=$? - set -e - rm -f "$after_hook_file" - fi + set +e + ${{ steps.detect-pm.outputs.runner }}react-native-harness --harnessRunner ${{ inputs.runner }} ${{ inputs.harnessArgs }} + harness_exit_code=$? + set -e - if [ "$harness_exit_code" -ne 0 ]; then - exit "$harness_exit_code" - fi + echo "harness_exit_code=$harness_exit_code" >> "$GITHUB_OUTPUT" - if [ "$after_run_exit_code" -ne 0 ]; then - exit "$after_run_exit_code" - fi - with: - working-directory: ${{ inputs.projectRoot }} - api-level: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }} - arch: ${{ steps.arch.outputs.arch }} - force-avd-creation: false - avd-name: ${{ fromJson(steps.load-config.outputs.config).config.device.name }} - disable-animations: true - emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - # Keep `script` to a single line so the emulator action does not split our bash - # session apart before the hooks and Harness command run. - script: >- - harness_script_file="$(mktemp "${RUNNER_TEMP:-/tmp}/harness-android-run.XXXXXX.sh")"; printf '%s\n' "$HARNESS_ANDROID_SESSION_SCRIPT" > "$harness_script_file"; chmod +x "$harness_script_file"; bash "$harness_script_file"; status=$?; rm -f "$harness_script_file"; exit "$status" + if [ "$harness_exit_code" -ne 0 ]; then + exit "$harness_exit_code" + fi - name: Upload visual test artifacts if: always() && inputs.uploadVisualTestArtifacts == 'true' uses: actions/upload-artifact@v4 diff --git a/packages/platform-android/src/__tests__/adb.test.ts b/packages/platform-android/src/__tests__/adb.test.ts index 6618e81e..d63d896f 100644 --- a/packages/platform-android/src/__tests__/adb.test.ts +++ b/packages/platform-android/src/__tests__/adb.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it, vi } from 'vitest'; -import { getAppUid, getLogcatTimestamp, getStartAppArgs } from '../adb.js'; +import { + createAvd, + getAppUid, + getLogcatTimestamp, + getStartAppArgs, + hasAvd, + installApp, +} from '../adb.js'; import * as tools from '@react-native-harness/tools'; describe('getStartAppArgs', () => { @@ -45,12 +52,10 @@ describe('getStartAppArgs', () => { }); it('extracts app uid from pm list packages output', async () => { - const spawnSpy = vi - .spyOn(tools, 'spawn') - .mockResolvedValueOnce({ - stdout: - 'package:com.other.app uid:10123\npackage:com.example.app uid:10234\n', - } as Awaited>); + const spawnSpy = vi.spyOn(tools, 'spawn').mockResolvedValueOnce({ + stdout: + 'package:com.other.app uid:10123\npackage:com.example.app uid:10234\n', + } as Awaited>); await expect(getAppUid('emulator-5554', 'com.example.app')).resolves.toBe( 10234 @@ -68,11 +73,9 @@ describe('getStartAppArgs', () => { }); it('reads the device timestamp in logcat format', async () => { - const spawnSpy = vi - .spyOn(tools, 'spawn') - .mockResolvedValueOnce({ - stdout: "'03-12 11:35:08.000'\n", - } as Awaited>); + const spawnSpy = vi.spyOn(tools, 'spawn').mockResolvedValueOnce({ + stdout: "'03-12 11:35:08.000'\n", + } as Awaited>); await expect(getLogcatTimestamp('emulator-5554')).resolves.toBe( '03-12 11:35:08.000' @@ -86,4 +89,55 @@ describe('getStartAppArgs', () => { "+'%m-%d %H:%M:%S.000'", ]); }); + + it('checks whether an AVD exists', async () => { + vi.spyOn(tools, 'spawn').mockResolvedValueOnce({ + stdout: 'Pixel_6_API_33\nPixel_8_API_35\n', + } as Awaited>); + + await expect(hasAvd('Pixel_8_API_35')).resolves.toBe(true); + await expect(hasAvd('Missing_AVD')).resolves.toBe(false); + }); + + it('installs the app via adb', async () => { + const spawnSpy = vi + .spyOn(tools, 'spawn') + .mockResolvedValueOnce({} as Awaited>); + + await installApp('emulator-5554', '/tmp/app.apk'); + + expect(spawnSpy).toHaveBeenCalledWith('adb', [ + '-s', + 'emulator-5554', + 'install', + '-r', + '/tmp/app.apk', + ]); + }); + + it('creates an AVD and appends config overrides', async () => { + const spawnSpy = vi + .spyOn(tools, 'spawn') + .mockResolvedValue({} as Awaited>); + + await createAvd({ + name: 'Pixel_8_API_35', + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + }); + + expect(spawnSpy).toHaveBeenNthCalledWith(1, 'sdkmanager', [ + 'system-images;android-35;default;x86_64', + ]); + expect(spawnSpy).toHaveBeenNthCalledWith(2, 'bash', [ + '-lc', + `printf 'no\n' | avdmanager create avd --force --name "Pixel_8_API_35" --package "system-images;android-35;default;x86_64" --device "pixel_8"`, + ]); + expect(spawnSpy).toHaveBeenNthCalledWith(3, 'bash', [ + '-lc', + `printf '%s\n%s\n' 'disk.dataPartition.size=1G' 'vm.heapSize=1G' >> "$HOME/.android/avd/Pixel_8_API_35.avd/config.ini"`, + ]); + }); }); diff --git a/packages/platform-android/src/__tests__/instance.test.ts b/packages/platform-android/src/__tests__/instance.test.ts new file mode 100644 index 00000000..23e99215 --- /dev/null +++ b/packages/platform-android/src/__tests__/instance.test.ts @@ -0,0 +1,271 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_METRO_PORT, + type Config as HarnessConfig, +} from '@react-native-harness/config'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + getAndroidEmulatorPlatformInstance, + getAndroidPhysicalDevicePlatformInstance, +} from '../instance.js'; +import * as adb from '../adb.js'; +import * as sharedPrefs from '../shared-prefs.js'; +import { HarnessAppPathError, HarnessEmulatorConfigError } from '../errors.js'; + +const harnessConfig = { + metroPort: DEFAULT_METRO_PORT, +} as HarnessConfig; + +describe('Android platform instance', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it('reuses a running emulator and does not shut it down on dispose', async () => { + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']); + vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35'); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue(undefined); + vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); + vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); + vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( + undefined + ); + vi.spyOn(sharedPrefs, 'clearHarnessDebugHttpHost').mockResolvedValue( + undefined + ); + vi.spyOn(adb, 'stopApp').mockResolvedValue(undefined); + const stopEmulator = vi.spyOn(adb, 'stopEmulator').mockResolvedValue(); + + const instance = await getAndroidEmulatorPlatformInstance( + { + name: 'android', + device: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + }, + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfig + ); + + await instance.dispose(); + + expect(stopEmulator).not.toHaveBeenCalled(); + }); + + it('creates and boots an emulator when missing and shuts it down on dispose', async () => { + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue([]); + vi.spyOn(adb, 'hasAvd').mockResolvedValue(false); + const createAvd = vi.spyOn(adb, 'createAvd').mockResolvedValue(undefined); + const startEmulator = vi + .spyOn(adb, 'startEmulator') + .mockResolvedValue(undefined); + vi.spyOn(adb, 'waitForEmulator').mockResolvedValue('emulator-5554'); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue(undefined); + vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); + vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); + vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( + undefined + ); + vi.spyOn(sharedPrefs, 'clearHarnessDebugHttpHost').mockResolvedValue( + undefined + ); + vi.spyOn(adb, 'stopApp').mockResolvedValue(undefined); + const stopEmulator = vi.spyOn(adb, 'stopEmulator').mockResolvedValue(); + + const instance = await getAndroidEmulatorPlatformInstance( + { + name: 'android', + device: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + }, + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfig + ); + + expect(createAvd).toHaveBeenCalledWith({ + name: 'Pixel_8_API_35', + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + }); + expect(startEmulator).toHaveBeenCalledWith('Pixel_8_API_35'); + + await instance.dispose(); + + expect(stopEmulator).toHaveBeenCalledWith('emulator-5554'); + }); + + it('installs the app from HARNESS_APP_PATH when missing', async () => { + const appPath = path.join(os.tmpdir(), 'HarnessPlayground.apk'); + fs.writeFileSync(appPath, 'apk'); + vi.stubEnv('HARNESS_APP_PATH', appPath); + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']); + vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35'); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue(undefined); + vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(false); + const installApp = vi.spyOn(adb, 'installApp').mockResolvedValue(undefined); + vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); + vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); + vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( + undefined + ); + + await expect( + getAndroidEmulatorPlatformInstance( + { + name: 'android', + device: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + }, + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfig + ) + ).resolves.toBeDefined(); + + expect(installApp).toHaveBeenCalledWith('emulator-5554', appPath); + + fs.rmSync(appPath, { force: true }); + }); + + it('throws a HarnessAppPathError when HARNESS_APP_PATH is missing', async () => { + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']); + vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35'); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue(undefined); + vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(false); + + await expect( + getAndroidEmulatorPlatformInstance( + { + name: 'android', + device: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + }, + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfig + ) + ).rejects.toBeInstanceOf(HarnessAppPathError); + }); + + it('throws a HarnessAppPathError when HARNESS_APP_PATH points to a missing app', async () => { + vi.stubEnv('HARNESS_APP_PATH', '/tmp/missing.apk'); + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']); + vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35'); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue(undefined); + vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(false); + + await expect( + getAndroidEmulatorPlatformInstance( + { + name: 'android', + device: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + }, + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfig + ) + ).rejects.toBeInstanceOf(HarnessAppPathError); + }); + + it('throws a HarnessEmulatorConfigError when the emulator is missing and avd config is absent', async () => { + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue([]); + + await expect( + getAndroidEmulatorPlatformInstance( + { + name: 'android', + device: { + type: 'emulator', + name: 'Pixel_8_API_35', + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfig + ) + ).rejects.toBeInstanceOf(HarnessEmulatorConfigError); + }); + + it('keeps physical device behavior unchanged', async () => { + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['012345']); + vi.spyOn(adb, 'getDeviceInfo').mockResolvedValue({ + manufacturer: 'motorola', + model: 'moto g72', + }); + vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); + vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); + vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( + undefined + ); + + await expect( + getAndroidPhysicalDevicePlatformInstance( + { + name: 'android-device', + device: { + type: 'physical', + manufacturer: 'motorola', + model: 'moto g72', + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfig + ) + ).resolves.toBeDefined(); + }); +}); diff --git a/packages/platform-android/src/adb.ts b/packages/platform-android/src/adb.ts index 11c40bb6..fdeb55a2 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -1,6 +1,24 @@ import { type AndroidAppLaunchOptions } from '@react-native-harness/platforms'; import { spawn, SubprocessError } from '@react-native-harness/tools'; +const wait = async (ms: number): Promise => { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +}; + +const getSystemImagePackage = (apiLevel: number): string => { + return `system-images;android-${apiLevel};default;x86_64`; +}; + +export type CreateAvdOptions = { + name: string; + apiLevel: number; + profile: string; + diskSize: string; + heapSize: string; +}; + export const getStartAppArgs = ( bundleId: string, activityName: string, @@ -86,7 +104,11 @@ export const startApp = async ( activityName: string, options?: AndroidAppLaunchOptions ): Promise => { - await spawn('adb', ['-s', adbId, ...getStartAppArgs(bundleId, activityName, options)]); + await spawn('adb', [ + '-s', + adbId, + ...getStartAppArgs(bundleId, activityName, options), + ]); }; export const getDeviceIds = async (): Promise => { @@ -141,6 +163,104 @@ export const stopEmulator = async (adbId: string): Promise => { await spawn('adb', ['-s', adbId, 'emu', 'kill']); }; +export const installApp = async ( + adbId: string, + appPath: string +): Promise => { + await spawn('adb', ['-s', adbId, 'install', '-r', appPath]); +}; + +export const hasAvd = async (name: string): Promise => { + const avds = await getAvds(); + return avds.includes(name); +}; + +export const createAvd = async ({ + name, + apiLevel, + profile, + diskSize, + heapSize, +}: CreateAvdOptions): Promise => { + const systemImagePackage = getSystemImagePackage(apiLevel); + + await spawn('sdkmanager', [systemImagePackage]); + await spawn('bash', [ + '-lc', + `printf 'no\n' | avdmanager create avd --force --name "${name}" --package "${systemImagePackage}" --device "${profile}"`, + ]); + await spawn('bash', [ + '-lc', + `printf '%s\n%s\n' 'disk.dataPartition.size=${diskSize}' 'vm.heapSize=${heapSize}' >> "$HOME/.android/avd/${name}.avd/config.ini"`, + ]); +}; + +export const startEmulator = async (name: string): Promise => { + void spawn( + 'emulator', + [ + `@${name}`, + '-no-snapshot-save', + '-no-window', + '-gpu', + 'swiftshader_indirect', + '-noaudio', + '-no-boot-anim', + '-camera-back', + 'none', + ], + { + detached: true, + stdout: 'ignore', + stderr: 'ignore', + } + ); +}; + +export const waitForEmulator = async ( + name: string, + timeoutMs: number = 120000 +): Promise => { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + const adbIds = await getDeviceIds(); + + for (const adbId of adbIds) { + if (!adbId.startsWith('emulator-')) { + continue; + } + + const emulatorName = await getEmulatorName(adbId); + + if (emulatorName === name) { + return adbId; + } + } + + await wait(1000); + } + + throw new Error(`Timed out waiting for emulator "${name}" to appear in adb.`); +}; + +export const waitForBoot = async ( + adbId: string, + timeoutMs: number = 300000 +): Promise => { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + if (await isBootCompleted(adbId)) { + return; + } + + await wait(1000); + } + + throw new Error(`Timed out waiting for emulator "${adbId}" to boot.`); +}; + export const isAppRunning = async ( adbId: string, bundleId: string diff --git a/packages/platform-android/src/errors.ts b/packages/platform-android/src/errors.ts new file mode 100644 index 00000000..aad1e0ca --- /dev/null +++ b/packages/platform-android/src/errors.ts @@ -0,0 +1,19 @@ +export class HarnessAppPathError extends Error { + constructor(reason: 'missing' | 'invalid', appPath?: string) { + super( + reason === 'missing' + ? 'App is not installed on the emulator and HARNESS_APP_PATH is not set.' + : `HARNESS_APP_PATH points to a missing APK: ${appPath ?? ''}` + ); + this.name = 'HarnessAppPathError'; + } +} + +export class HarnessEmulatorConfigError extends Error { + constructor(deviceName: string) { + super( + `Android emulator "${deviceName}" is not running and no AVD config was provided. Add the "avd" property to this runner config so Harness can create and boot the emulator.` + ); + this.name = 'HarnessEmulatorConfigError'; + } +} diff --git a/packages/platform-android/src/index.ts b/packages/platform-android/src/index.ts index ba6b93c6..7f02c37f 100644 --- a/packages/platform-android/src/index.ts +++ b/packages/platform-android/src/index.ts @@ -4,4 +4,5 @@ export { androidPlatform, } from './factory.js'; export type { AndroidPlatformConfig } from './config.js'; -export { getRunTargets } from './targets.js'; \ No newline at end of file +export { HarnessAppPathError, HarnessEmulatorConfigError } from './errors.js'; +export { getRunTargets } from './targets.js'; diff --git a/packages/platform-android/src/instance.ts b/packages/platform-android/src/instance.ts new file mode 100644 index 00000000..72664b45 --- /dev/null +++ b/packages/platform-android/src/instance.ts @@ -0,0 +1,208 @@ +import { + AppNotInstalledError, + CreateAppMonitorOptions, + DeviceNotFoundError, + HarnessPlatformRunner, +} from '@react-native-harness/platforms'; +import type { Config as HarnessConfig } from '@react-native-harness/config'; +import { + AndroidPlatformConfig, + assertAndroidDeviceEmulator, + assertAndroidDevicePhysical, +} from './config.js'; +import { getAdbId } from './adb-id.js'; +import * as adb from './adb.js'; +import { + applyHarnessDebugHttpHost, + clearHarnessDebugHttpHost, +} from './shared-prefs.js'; +import { getDeviceName } from './utils.js'; +import { createAndroidAppMonitor } from './app-monitor.js'; +import { HarnessAppPathError, HarnessEmulatorConfigError } from './errors.js'; +import fs from 'node:fs'; + +const getHarnessAppPath = (): string => { + const appPath = process.env.HARNESS_APP_PATH; + + if (!appPath) { + throw new HarnessAppPathError('missing'); + } + + if (!fs.existsSync(appPath)) { + throw new HarnessAppPathError('invalid', appPath); + } + + return appPath; +}; + +const configureAndroidRuntime = async ( + adbId: string, + config: AndroidPlatformConfig, + harnessConfig: HarnessConfig +): Promise => { + const metroPort = harnessConfig.metroPort; + + await Promise.all([ + adb.reversePort(adbId, metroPort), + adb.reversePort(adbId, 8080), + adb.setHideErrorDialogs(adbId, true), + applyHarnessDebugHttpHost(adbId, config.bundleId, `localhost:${metroPort}`), + ]); + + return adb.getAppUid(adbId, config.bundleId); +}; + +export const getAndroidEmulatorPlatformInstance = async ( + config: AndroidPlatformConfig, + harnessConfig: HarnessConfig +): Promise => { + assertAndroidDeviceEmulator(config.device); + + let adbId = await getAdbId(config.device); + let startedByHarness = false; + + if (!adbId) { + const avdConfig = config.device.avd; + + if (!avdConfig) { + throw new HarnessEmulatorConfigError(config.device.name); + } + + if (!(await adb.hasAvd(config.device.name))) { + await adb.createAvd({ + name: config.device.name, + apiLevel: avdConfig.apiLevel, + profile: avdConfig.profile, + diskSize: avdConfig.diskSize, + heapSize: avdConfig.heapSize, + }); + } + + await adb.startEmulator(config.device.name); + adbId = await adb.waitForEmulator(config.device.name); + startedByHarness = true; + } + + if (!adbId) { + throw new DeviceNotFoundError(getDeviceName(config.device)); + } + + await adb.waitForBoot(adbId); + + const isInstalled = await adb.isAppInstalled(adbId, config.bundleId); + + if (!isInstalled) { + const appPath = getHarnessAppPath(); + await adb.installApp(adbId, appPath); + } + + const appUid = await configureAndroidRuntime(adbId, config, harnessConfig); + + return { + startApp: async (options) => { + await adb.startApp( + adbId, + config.bundleId, + config.activityName, + (options as typeof config.appLaunchOptions | undefined) ?? + config.appLaunchOptions + ); + }, + restartApp: async (options) => { + await adb.stopApp(adbId, config.bundleId); + await adb.startApp( + adbId, + config.bundleId, + config.activityName, + (options as typeof config.appLaunchOptions | undefined) ?? + config.appLaunchOptions + ); + }, + stopApp: async () => { + await adb.stopApp(adbId, config.bundleId); + }, + dispose: async () => { + await adb.stopApp(adbId, config.bundleId); + await clearHarnessDebugHttpHost(adbId, config.bundleId); + await adb.setHideErrorDialogs(adbId, false); + + if (startedByHarness) { + await adb.stopEmulator(adbId); + } + }, + isAppRunning: async () => { + return await adb.isAppRunning(adbId, config.bundleId); + }, + createAppMonitor: (options?: CreateAppMonitorOptions) => + createAndroidAppMonitor({ + adbId, + bundleId: config.bundleId, + appUid, + crashArtifactWriter: options?.crashArtifactWriter, + }), + }; +}; + +export const getAndroidPhysicalDevicePlatformInstance = async ( + config: AndroidPlatformConfig, + harnessConfig: HarnessConfig +): Promise => { + assertAndroidDevicePhysical(config.device); + + const adbId = await getAdbId(config.device); + + if (!adbId) { + throw new DeviceNotFoundError(getDeviceName(config.device)); + } + + const isInstalled = await adb.isAppInstalled(adbId, config.bundleId); + + if (!isInstalled) { + throw new AppNotInstalledError( + config.bundleId, + getDeviceName(config.device) + ); + } + + const appUid = await configureAndroidRuntime(adbId, config, harnessConfig); + + return { + startApp: async (options) => { + await adb.startApp( + adbId, + config.bundleId, + config.activityName, + (options as typeof config.appLaunchOptions | undefined) ?? + config.appLaunchOptions + ); + }, + restartApp: async (options) => { + await adb.stopApp(adbId, config.bundleId); + await adb.startApp( + adbId, + config.bundleId, + config.activityName, + (options as typeof config.appLaunchOptions | undefined) ?? + config.appLaunchOptions + ); + }, + stopApp: async () => { + await adb.stopApp(adbId, config.bundleId); + }, + dispose: async () => { + await adb.stopApp(adbId, config.bundleId); + await clearHarnessDebugHttpHost(adbId, config.bundleId); + await adb.setHideErrorDialogs(adbId, false); + }, + isAppRunning: async () => { + return await adb.isAppRunning(adbId, config.bundleId); + }, + createAppMonitor: (options?: CreateAppMonitorOptions) => + createAndroidAppMonitor({ + adbId, + bundleId: config.bundleId, + appUid, + crashArtifactWriter: options?.crashArtifactWriter, + }), + }; +}; diff --git a/packages/platform-android/src/runner.ts b/packages/platform-android/src/runner.ts index a2f220c0..fc7a445c 100644 --- a/packages/platform-android/src/runner.ts +++ b/packages/platform-android/src/runner.ts @@ -1,92 +1,26 @@ -import { - DeviceNotFoundError, - AppNotInstalledError, - CreateAppMonitorOptions, - HarnessPlatformRunner, -} from '@react-native-harness/platforms'; +import { HarnessPlatformRunner } from '@react-native-harness/platforms'; import type { Config as HarnessConfig } from '@react-native-harness/config'; import { AndroidPlatformConfigSchema, type AndroidPlatformConfig, + isAndroidDeviceEmulator, } from './config.js'; -import { getAdbId } from './adb-id.js'; -import * as adb from './adb.js'; import { - applyHarnessDebugHttpHost, - clearHarnessDebugHttpHost, -} from './shared-prefs.js'; -import { getDeviceName } from './utils.js'; -import { createAndroidAppMonitor } from './app-monitor.js'; + getAndroidEmulatorPlatformInstance, + getAndroidPhysicalDevicePlatformInstance, +} from './instance.js'; const getAndroidRunner = async ( config: AndroidPlatformConfig, harnessConfig: HarnessConfig ): Promise => { const parsedConfig = AndroidPlatformConfigSchema.parse(config); - const adbId = await getAdbId(parsedConfig.device); - - if (!adbId) { - throw new DeviceNotFoundError(getDeviceName(parsedConfig.device)); - } - const isInstalled = await adb.isAppInstalled(adbId, parsedConfig.bundleId); - - if (!isInstalled) { - throw new AppNotInstalledError( - parsedConfig.bundleId, - getDeviceName(parsedConfig.device) - ); + if (isAndroidDeviceEmulator(parsedConfig.device)) { + return getAndroidEmulatorPlatformInstance(parsedConfig, harnessConfig); } - const metroPort = harnessConfig.metroPort; - - await Promise.all([ - adb.reversePort(adbId, metroPort), - adb.reversePort(adbId, 8080), - adb.setHideErrorDialogs(adbId, true), - applyHarnessDebugHttpHost(adbId, parsedConfig.bundleId, `localhost:${metroPort}`), - ]); - const appUid = await adb.getAppUid(adbId, parsedConfig.bundleId); - - return { - startApp: async (options) => { - await adb.startApp( - adbId, - parsedConfig.bundleId, - parsedConfig.activityName, - (options as typeof parsedConfig.appLaunchOptions | undefined) ?? - parsedConfig.appLaunchOptions - ); - }, - restartApp: async (options) => { - await adb.stopApp(adbId, parsedConfig.bundleId); - await adb.startApp( - adbId, - parsedConfig.bundleId, - parsedConfig.activityName, - (options as typeof parsedConfig.appLaunchOptions | undefined) ?? - parsedConfig.appLaunchOptions - ); - }, - stopApp: async () => { - await adb.stopApp(adbId, parsedConfig.bundleId); - }, - dispose: async () => { - await adb.stopApp(adbId, parsedConfig.bundleId); - await clearHarnessDebugHttpHost(adbId, parsedConfig.bundleId); - await adb.setHideErrorDialogs(adbId, false); - }, - isAppRunning: async () => { - return await adb.isAppRunning(adbId, parsedConfig.bundleId); - }, - createAppMonitor: (options?: CreateAppMonitorOptions) => - createAndroidAppMonitor({ - adbId, - bundleId: parsedConfig.bundleId, - appUid, - crashArtifactWriter: options?.crashArtifactWriter, - }), - }; + return getAndroidPhysicalDevicePlatformInstance(parsedConfig, harnessConfig); }; export default getAndroidRunner; From 5491c87aa2b4824bec192d8428120c3b708b5f08 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 31 Mar 2026 13:01:53 +0200 Subject: [PATCH 03/26] chore: build artifacts --- actions/shared/index.cjs | 2 +- packages/bundler-metro/tsconfig.json | 3 --- packages/bundler-metro/tsconfig.lib.json | 3 --- 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/actions/shared/index.cjs b/actions/shared/index.cjs index 6ec60939..98717f57 100644 --- a/actions/shared/index.cjs +++ b/actions/shared/index.cjs @@ -4390,7 +4390,7 @@ var ConfigSchema = external_exports.object({ metroPort: external_exports.number().int("Metro port must be an integer").min(1, "Metro port must be at least 1").max(65535, "Metro port must be at most 65535").optional().default(DEFAULT_METRO_PORT), webSocketPort: external_exports.number().optional().describe("Deprecated. Bridge traffic now uses metroPort and this value is ignored."), bridgeTimeout: external_exports.number().min(1e3, "Bridge timeout must be at least 1 second").default(6e4), - bundleStartTimeout: external_exports.number().min(1e3, "Bundle start timeout must be at least 1 second").default(15e3), + bundleStartTimeout: external_exports.number().min(1e3, "Bundle start timeout must be at least 1 second").default(6e4), maxAppRestarts: external_exports.number().min(0, "Max app restarts must be at least 0").default(2), resetEnvironmentBetweenTestFiles: external_exports.boolean().optional().default(true), unstable__skipAlreadyIncludedModules: external_exports.boolean().optional().default(false), diff --git a/packages/bundler-metro/tsconfig.json b/packages/bundler-metro/tsconfig.json index 0d5c5349..403a9dfe 100644 --- a/packages/bundler-metro/tsconfig.json +++ b/packages/bundler-metro/tsconfig.json @@ -12,9 +12,6 @@ { "path": "../tools" }, - { - "path": "../bridge" - }, { "path": "../babel-preset" }, diff --git a/packages/bundler-metro/tsconfig.lib.json b/packages/bundler-metro/tsconfig.lib.json index fa1ce340..8f6398da 100644 --- a/packages/bundler-metro/tsconfig.lib.json +++ b/packages/bundler-metro/tsconfig.lib.json @@ -21,9 +21,6 @@ { "path": "../tools/tsconfig.lib.json" }, - { - "path": "../bridge/tsconfig.lib.json" - }, { "path": "../babel-preset/tsconfig.lib.json" } From ca1f17a3b7fd33ef7ad13cfca14dca560629c7c7 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 31 Mar 2026 13:37:44 +0200 Subject: [PATCH 04/26] fix: bootstrap Android SDK tools for Harness subprocesses --- packages/platform-android/src/adb.ts | 139 +++++++++--------- packages/platform-android/src/app-monitor.ts | 76 +++++++--- packages/platform-android/src/environment.ts | 47 ++++++ packages/platform-android/src/shared-prefs.ts | 19 ++- 4 files changed, 189 insertions(+), 92 deletions(-) create mode 100644 packages/platform-android/src/environment.ts diff --git a/packages/platform-android/src/adb.ts b/packages/platform-android/src/adb.ts index fdeb55a2..7f5db0dc 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -1,5 +1,6 @@ import { type AndroidAppLaunchOptions } from '@react-native-harness/platforms'; import { spawn, SubprocessError } from '@react-native-harness/tools'; +import { withAndroidProcessEnv } from './environment.js'; const wait = async (ms: number): Promise => { await new Promise((resolve) => { @@ -65,15 +66,11 @@ export const isAppInstalled = async ( adbId: string, bundleId: string ): Promise => { - const { stdout } = await spawn('adb', [ - '-s', - adbId, - 'shell', - 'pm', - 'list', - 'packages', - bundleId, - ]); + const { stdout } = await spawn( + 'adb', + ['-s', adbId, 'shell', 'pm', 'list', 'packages', bundleId], + withAndroidProcessEnv() + ); return stdout.trim() !== ''; }; @@ -82,20 +79,22 @@ export const reversePort = async ( port: number, hostPort: number = port ): Promise => { - await spawn('adb', [ - '-s', - adbId, - 'reverse', - `tcp:${port}`, - `tcp:${hostPort}`, - ]); + await spawn( + 'adb', + ['-s', adbId, 'reverse', `tcp:${port}`, `tcp:${hostPort}`], + withAndroidProcessEnv() + ); }; export const stopApp = async ( adbId: string, bundleId: string ): Promise => { - await spawn('adb', ['-s', adbId, 'shell', 'am', 'force-stop', bundleId]); + await spawn( + 'adb', + ['-s', adbId, 'shell', 'am', 'force-stop', bundleId], + withAndroidProcessEnv() + ); }; export const startApp = async ( @@ -104,15 +103,15 @@ export const startApp = async ( activityName: string, options?: AndroidAppLaunchOptions ): Promise => { - await spawn('adb', [ - '-s', - adbId, - ...getStartAppArgs(bundleId, activityName, options), - ]); + await spawn( + 'adb', + ['-s', adbId, ...getStartAppArgs(bundleId, activityName, options)], + withAndroidProcessEnv() + ); }; export const getDeviceIds = async (): Promise => { - const { stdout } = await spawn('adb', ['devices']); + const { stdout } = await spawn('adb', ['devices'], withAndroidProcessEnv()); return stdout .split('\n') .slice(1) // Skip header @@ -123,7 +122,11 @@ export const getDeviceIds = async (): Promise => { export const getEmulatorName = async ( adbId: string ): Promise => { - const { stdout } = await spawn('adb', ['-s', adbId, 'emu', 'avd', 'name']); + const { stdout } = await spawn( + 'adb', + ['-s', adbId, 'emu', 'avd', 'name'], + withAndroidProcessEnv() + ); return stdout.split('\n')[0].trim() || null; }; @@ -131,13 +134,11 @@ export const getShellProperty = async ( adbId: string, property: string ): Promise => { - const { stdout } = await spawn('adb', [ - '-s', - adbId, - 'shell', - 'getprop', - property, - ]); + const { stdout } = await spawn( + 'adb', + ['-s', adbId, 'shell', 'getprop', property], + withAndroidProcessEnv() + ); return stdout.trim() || null; }; @@ -160,7 +161,7 @@ export const isBootCompleted = async (adbId: string): Promise => { }; export const stopEmulator = async (adbId: string): Promise => { - await spawn('adb', ['-s', adbId, 'emu', 'kill']); + await spawn('adb', ['-s', adbId, 'emu', 'kill'], withAndroidProcessEnv()); }; export const installApp = async ( @@ -266,13 +267,11 @@ export const isAppRunning = async ( bundleId: string ): Promise => { try { - const { stdout } = await spawn('adb', [ - '-s', - adbId, - 'shell', - 'pidof', - bundleId, - ]); + const { stdout } = await spawn( + 'adb', + ['-s', adbId, 'shell', 'pidof', bundleId], + withAndroidProcessEnv() + ); return stdout.trim() !== ''; } catch (error) { if (error instanceof SubprocessError && error.exitCode === 1) { @@ -287,15 +286,11 @@ export const getAppUid = async ( adbId: string, bundleId: string ): Promise => { - const { stdout } = await spawn('adb', [ - '-s', - adbId, - 'shell', - 'pm', - 'list', - 'packages', - '-U', - ]); + const { stdout } = await spawn( + 'adb', + ['-s', adbId, 'shell', 'pm', 'list', 'packages', '-U'], + withAndroidProcessEnv() + ); const line = stdout .split('\n') .find((entry) => entry.includes(`package:${bundleId}`)); @@ -312,33 +307,39 @@ export const setHideErrorDialogs = async ( adbId: string, hide: boolean ): Promise => { - await spawn('adb', [ - '-s', - adbId, - 'shell', - 'settings', - 'put', - 'global', - 'hide_error_dialogs', - hide ? '1' : '0', - ]); + await spawn( + 'adb', + [ + '-s', + adbId, + 'shell', + 'settings', + 'put', + 'global', + 'hide_error_dialogs', + hide ? '1' : '0', + ], + withAndroidProcessEnv() + ); }; export const getLogcatTimestamp = async (adbId: string): Promise => { - const { stdout } = await spawn('adb', [ - '-s', - adbId, - 'shell', - 'date', - "+'%m-%d %H:%M:%S.000'", - ]); + const { stdout } = await spawn( + 'adb', + ['-s', adbId, 'shell', 'date', "+'%m-%d %H:%M:%S.000'"], + withAndroidProcessEnv() + ); return stdout.trim().replace(/^'+|'+$/g, ''); }; export const getAvds = async (): Promise => { try { - const { stdout } = await spawn('emulator', ['-list-avds']); + const { stdout } = await spawn( + 'emulator', + ['-list-avds'], + withAndroidProcessEnv() + ); return stdout .split('\n') .map((line) => line.trim()) @@ -355,7 +356,11 @@ export type AdbDevice = { }; export const getConnectedDevices = async (): Promise => { - const { stdout } = await spawn('adb', ['devices', '-l']); + const { stdout } = await spawn( + 'adb', + ['devices', '-l'], + withAndroidProcessEnv() + ); const lines = stdout.split('\n').slice(1); const devices: AdbDevice[] = []; diff --git a/packages/platform-android/src/app-monitor.ts b/packages/platform-android/src/app-monitor.ts index fffbe513..91478eae 100644 --- a/packages/platform-android/src/app-monitor.ts +++ b/packages/platform-android/src/app-monitor.ts @@ -6,14 +6,31 @@ import { type AppMonitorEvent, type AppMonitorListener, } from '@react-native-harness/platforms'; -import { escapeRegExp, getEmitter, logger, spawn, SubprocessError, type Subprocess } from '@react-native-harness/tools'; +import { + escapeRegExp, + getEmitter, + logger, + spawn, + SubprocessError, + type Subprocess, +} from '@react-native-harness/tools'; import * as adb from './adb.js'; import { androidCrashParser } from './crash-parser.js'; +import { withAndroidProcessEnv } from './environment.js'; const androidAppMonitorLogger = logger.child('android-app-monitor'); const getLogcatArgs = (uid: number, fromTime: string) => - ['logcat', '-v', 'threadtime', '-b', 'crash', `--uid=${uid}`, '-T', fromTime] as const; + [ + 'logcat', + '-v', + 'threadtime', + '-b', + 'crash', + `--uid=${uid}`, + '-T', + fromTime, + ] as const; const MAX_RECENT_LOG_LINES = 200; const MAX_RECENT_CRASH_ARTIFACTS = 10; const CRASH_ARTIFACT_SETTLE_DELAY_MS = 100; @@ -29,7 +46,9 @@ const nativeCrashPattern = (bundleId: string) => const processDiedPattern = (bundleId: string) => new RegExp( - `Process\\s+${escapeRegExp(bundleId)}\\s+\\(pid\\s+(\\d+)\\)\\s+has\\s+died`, + `Process\\s+${escapeRegExp( + bundleId + )}\\s+\\(pid\\s+(\\d+)\\)\\s+has\\s+died`, 'i' ); @@ -66,7 +85,11 @@ const getAndroidLogLineCrashDetails = ({ summary: line.trim(), signal: getSignal(line), exceptionType: fatalExceptionMatch?.[1]?.trim(), - processName: processMatch ? bundleId : line.includes(bundleId) ? bundleId : undefined, + processName: processMatch + ? bundleId + : line.includes(bundleId) + ? bundleId + : undefined, pid: pid ?? (processMatch ? Number(processMatch[1]) : undefined), rawLines: [line], }; @@ -211,7 +234,9 @@ const createCrashArtifact = ({ triggerOccurredAt, artifactType: 'logcat', rawLines: - rawLines.length > 0 ? rawLines : parsedDetails.rawLines ?? details.rawLines, + rawLines.length > 0 + ? rawLines + : parsedDetails.rawLines ?? details.rawLines, }; }; @@ -265,11 +290,12 @@ const getLatestCrashArtifact = ({ matchingByPid.length > 0 ? matchingByPid : matchingByProcess.length > 0 - ? matchingByProcess - : crashArtifacts; + ? matchingByProcess + : crashArtifacts; const sortedCandidates = [...candidates].sort( (left, right) => - Math.abs(left.occurredAt - occurredAt) - Math.abs(right.occurredAt - occurredAt) + Math.abs(left.occurredAt - occurredAt) - + Math.abs(right.occurredAt - occurredAt) ); const artifact = sortedCandidates[0]; @@ -385,9 +411,10 @@ export const createAndroidAppMonitor = ({ }; const recordLogLine = (line: string) => { - recentLogLines = [...recentLogLines, { line, occurredAt: Date.now() }].slice( - -MAX_RECENT_LOG_LINES - ); + recentLogLines = [ + ...recentLogLines, + { line, occurredAt: Date.now() }, + ].slice(-MAX_RECENT_LOG_LINES); }; const recordCrashArtifact = (details?: AppCrashDetails) => { @@ -419,10 +446,15 @@ export const createAndroidAppMonitor = ({ const startLogcat = async () => { const logcatTimestamp = await adb.getLogcatTimestamp(adbId); - logcatProcess = spawn('adb', ['-s', adbId, ...getLogcatArgs(appUid, logcatTimestamp)], { - stdout: 'pipe', - stderr: 'pipe', - }); + logcatProcess = spawn( + 'adb', + ['-s', adbId, ...getLogcatArgs(appUid, logcatTimestamp)], + { + stdout: 'pipe', + stderr: 'pipe', + ...withAndroidProcessEnv(), + } + ); const currentProcess = logcatProcess; @@ -439,15 +471,23 @@ export const createAndroidAppMonitor = ({ const event = createAndroidLogEvent(line, bundleId); if (event) { - if (event.type === 'possible_crash' || event.type === 'app_exited') { + if ( + event.type === 'possible_crash' || + event.type === 'app_exited' + ) { recordCrashArtifact(event.crashDetails); } emit(event); } } } catch (error) { - if (!(error instanceof SubprocessError && error.signalName === 'SIGTERM')) { - androidAppMonitorLogger.debug('Android logcat monitor stopped', error); + if ( + !(error instanceof SubprocessError && error.signalName === 'SIGTERM') + ) { + androidAppMonitorLogger.debug( + 'Android logcat monitor stopped', + error + ); } } })(); diff --git a/packages/platform-android/src/environment.ts b/packages/platform-android/src/environment.ts new file mode 100644 index 00000000..5f60b363 --- /dev/null +++ b/packages/platform-android/src/environment.ts @@ -0,0 +1,47 @@ +import os from 'node:os'; +import path from 'node:path'; +import type { SpawnOptions } from '@react-native-harness/tools'; + +const CMDLINE_TOOLS_PATH_SEGMENTS = ['cmdline-tools', 'latest']; + +const getAndroidSdkRoot = (env: NodeJS.ProcessEnv): string | null => { + return env.ANDROID_HOME ?? env.ANDROID_SDK_ROOT ?? null; +}; + +export const getAndroidProcessEnv = ( + env: NodeJS.ProcessEnv = process.env +): NodeJS.ProcessEnv => { + const sdkRoot = getAndroidSdkRoot(env); + + if (!sdkRoot) { + return env; + } + + const platformToolsPath = path.join(sdkRoot, 'platform-tools'); + const emulatorPath = path.join(sdkRoot, 'emulator'); + const cmdlineToolsPath = path.join(sdkRoot, ...CMDLINE_TOOLS_PATH_SEGMENTS); + const cmdlineToolsBinPath = path.join(cmdlineToolsPath, 'bin'); + const currentPath = env.PATH ?? ''; + const pathEntries = [ + platformToolsPath, + emulatorPath, + cmdlineToolsPath, + cmdlineToolsBinPath, + currentPath, + ].filter((entry) => entry !== ''); + + return { + ...env, + ANDROID_HOME: sdkRoot, + ANDROID_SDK_ROOT: sdkRoot, + ANDROID_AVD_HOME: path.join(os.homedir(), '.android', 'avd'), + PATH: pathEntries.join(path.delimiter), + }; +}; + +export const withAndroidProcessEnv = ( + options?: SpawnOptions +): SpawnOptions => ({ + ...options, + env: getAndroidProcessEnv(options?.env), +}); diff --git a/packages/platform-android/src/shared-prefs.ts b/packages/platform-android/src/shared-prefs.ts index 9d983fad..3c3a21f0 100644 --- a/packages/platform-android/src/shared-prefs.ts +++ b/packages/platform-android/src/shared-prefs.ts @@ -1,4 +1,5 @@ import { spawn, SubprocessError } from '@react-native-harness/tools'; +import { withAndroidProcessEnv } from './environment.js'; const DEBUG_HTTP_HOST_BLOCK_START = ''; @@ -113,12 +114,16 @@ const readSharedPrefsFile = async ( bundleId: string ): Promise => { try { - const { stdout } = await spawn('adb', [ - '-s', - adbId, - 'shell', - `run-as ${bundleId} cat ${getSharedPrefsPath(bundleId)}`, - ]); + const { stdout } = await spawn( + 'adb', + [ + '-s', + adbId, + 'shell', + `run-as ${bundleId} cat ${getSharedPrefsPath(bundleId)}`, + ], + withAndroidProcessEnv() + ); return stdout; } catch (error) { if (error instanceof SubprocessError && error.exitCode === 1) { @@ -144,7 +149,7 @@ const writeSharedPrefsFile = async ( bundleId )}'`, ], - { stdin: { string: `${content.trim()}\n` } } + withAndroidProcessEnv({ stdin: { string: `${content.trim()}\n` } }) ); }; From 5e6a20045c8c6d569db8dab77a3ca2dd83187ba4 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 31 Mar 2026 13:56:58 +0200 Subject: [PATCH 05/26] refactor: initialize Android env during runner setup --- packages/platform-android/src/adb.ts | 139 +++++++++--------- packages/platform-android/src/app-monitor.ts | 2 - packages/platform-android/src/environment.ts | 10 +- packages/platform-android/src/runner.ts | 3 + packages/platform-android/src/shared-prefs.ts | 19 +-- 5 files changed, 80 insertions(+), 93 deletions(-) diff --git a/packages/platform-android/src/adb.ts b/packages/platform-android/src/adb.ts index 7f5db0dc..fdeb55a2 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -1,6 +1,5 @@ import { type AndroidAppLaunchOptions } from '@react-native-harness/platforms'; import { spawn, SubprocessError } from '@react-native-harness/tools'; -import { withAndroidProcessEnv } from './environment.js'; const wait = async (ms: number): Promise => { await new Promise((resolve) => { @@ -66,11 +65,15 @@ export const isAppInstalled = async ( adbId: string, bundleId: string ): Promise => { - const { stdout } = await spawn( - 'adb', - ['-s', adbId, 'shell', 'pm', 'list', 'packages', bundleId], - withAndroidProcessEnv() - ); + const { stdout } = await spawn('adb', [ + '-s', + adbId, + 'shell', + 'pm', + 'list', + 'packages', + bundleId, + ]); return stdout.trim() !== ''; }; @@ -79,22 +82,20 @@ export const reversePort = async ( port: number, hostPort: number = port ): Promise => { - await spawn( - 'adb', - ['-s', adbId, 'reverse', `tcp:${port}`, `tcp:${hostPort}`], - withAndroidProcessEnv() - ); + await spawn('adb', [ + '-s', + adbId, + 'reverse', + `tcp:${port}`, + `tcp:${hostPort}`, + ]); }; export const stopApp = async ( adbId: string, bundleId: string ): Promise => { - await spawn( - 'adb', - ['-s', adbId, 'shell', 'am', 'force-stop', bundleId], - withAndroidProcessEnv() - ); + await spawn('adb', ['-s', adbId, 'shell', 'am', 'force-stop', bundleId]); }; export const startApp = async ( @@ -103,15 +104,15 @@ export const startApp = async ( activityName: string, options?: AndroidAppLaunchOptions ): Promise => { - await spawn( - 'adb', - ['-s', adbId, ...getStartAppArgs(bundleId, activityName, options)], - withAndroidProcessEnv() - ); + await spawn('adb', [ + '-s', + adbId, + ...getStartAppArgs(bundleId, activityName, options), + ]); }; export const getDeviceIds = async (): Promise => { - const { stdout } = await spawn('adb', ['devices'], withAndroidProcessEnv()); + const { stdout } = await spawn('adb', ['devices']); return stdout .split('\n') .slice(1) // Skip header @@ -122,11 +123,7 @@ export const getDeviceIds = async (): Promise => { export const getEmulatorName = async ( adbId: string ): Promise => { - const { stdout } = await spawn( - 'adb', - ['-s', adbId, 'emu', 'avd', 'name'], - withAndroidProcessEnv() - ); + const { stdout } = await spawn('adb', ['-s', adbId, 'emu', 'avd', 'name']); return stdout.split('\n')[0].trim() || null; }; @@ -134,11 +131,13 @@ export const getShellProperty = async ( adbId: string, property: string ): Promise => { - const { stdout } = await spawn( - 'adb', - ['-s', adbId, 'shell', 'getprop', property], - withAndroidProcessEnv() - ); + const { stdout } = await spawn('adb', [ + '-s', + adbId, + 'shell', + 'getprop', + property, + ]); return stdout.trim() || null; }; @@ -161,7 +160,7 @@ export const isBootCompleted = async (adbId: string): Promise => { }; export const stopEmulator = async (adbId: string): Promise => { - await spawn('adb', ['-s', adbId, 'emu', 'kill'], withAndroidProcessEnv()); + await spawn('adb', ['-s', adbId, 'emu', 'kill']); }; export const installApp = async ( @@ -267,11 +266,13 @@ export const isAppRunning = async ( bundleId: string ): Promise => { try { - const { stdout } = await spawn( - 'adb', - ['-s', adbId, 'shell', 'pidof', bundleId], - withAndroidProcessEnv() - ); + const { stdout } = await spawn('adb', [ + '-s', + adbId, + 'shell', + 'pidof', + bundleId, + ]); return stdout.trim() !== ''; } catch (error) { if (error instanceof SubprocessError && error.exitCode === 1) { @@ -286,11 +287,15 @@ export const getAppUid = async ( adbId: string, bundleId: string ): Promise => { - const { stdout } = await spawn( - 'adb', - ['-s', adbId, 'shell', 'pm', 'list', 'packages', '-U'], - withAndroidProcessEnv() - ); + const { stdout } = await spawn('adb', [ + '-s', + adbId, + 'shell', + 'pm', + 'list', + 'packages', + '-U', + ]); const line = stdout .split('\n') .find((entry) => entry.includes(`package:${bundleId}`)); @@ -307,39 +312,33 @@ export const setHideErrorDialogs = async ( adbId: string, hide: boolean ): Promise => { - await spawn( - 'adb', - [ - '-s', - adbId, - 'shell', - 'settings', - 'put', - 'global', - 'hide_error_dialogs', - hide ? '1' : '0', - ], - withAndroidProcessEnv() - ); + await spawn('adb', [ + '-s', + adbId, + 'shell', + 'settings', + 'put', + 'global', + 'hide_error_dialogs', + hide ? '1' : '0', + ]); }; export const getLogcatTimestamp = async (adbId: string): Promise => { - const { stdout } = await spawn( - 'adb', - ['-s', adbId, 'shell', 'date', "+'%m-%d %H:%M:%S.000'"], - withAndroidProcessEnv() - ); + const { stdout } = await spawn('adb', [ + '-s', + adbId, + 'shell', + 'date', + "+'%m-%d %H:%M:%S.000'", + ]); return stdout.trim().replace(/^'+|'+$/g, ''); }; export const getAvds = async (): Promise => { try { - const { stdout } = await spawn( - 'emulator', - ['-list-avds'], - withAndroidProcessEnv() - ); + const { stdout } = await spawn('emulator', ['-list-avds']); return stdout .split('\n') .map((line) => line.trim()) @@ -356,11 +355,7 @@ export type AdbDevice = { }; export const getConnectedDevices = async (): Promise => { - const { stdout } = await spawn( - 'adb', - ['devices', '-l'], - withAndroidProcessEnv() - ); + const { stdout } = await spawn('adb', ['devices', '-l']); const lines = stdout.split('\n').slice(1); const devices: AdbDevice[] = []; diff --git a/packages/platform-android/src/app-monitor.ts b/packages/platform-android/src/app-monitor.ts index 91478eae..5781ccba 100644 --- a/packages/platform-android/src/app-monitor.ts +++ b/packages/platform-android/src/app-monitor.ts @@ -16,7 +16,6 @@ import { } from '@react-native-harness/tools'; import * as adb from './adb.js'; import { androidCrashParser } from './crash-parser.js'; -import { withAndroidProcessEnv } from './environment.js'; const androidAppMonitorLogger = logger.child('android-app-monitor'); @@ -452,7 +451,6 @@ export const createAndroidAppMonitor = ({ { stdout: 'pipe', stderr: 'pipe', - ...withAndroidProcessEnv(), } ); diff --git a/packages/platform-android/src/environment.ts b/packages/platform-android/src/environment.ts index 5f60b363..c3fb9198 100644 --- a/packages/platform-android/src/environment.ts +++ b/packages/platform-android/src/environment.ts @@ -1,6 +1,5 @@ import os from 'node:os'; import path from 'node:path'; -import type { SpawnOptions } from '@react-native-harness/tools'; const CMDLINE_TOOLS_PATH_SEGMENTS = ['cmdline-tools', 'latest']; @@ -39,9 +38,6 @@ export const getAndroidProcessEnv = ( }; }; -export const withAndroidProcessEnv = ( - options?: SpawnOptions -): SpawnOptions => ({ - ...options, - env: getAndroidProcessEnv(options?.env), -}); +export const initializeAndroidProcessEnv = (): void => { + Object.assign(process.env, getAndroidProcessEnv()); +}; diff --git a/packages/platform-android/src/runner.ts b/packages/platform-android/src/runner.ts index fc7a445c..f62ccd63 100644 --- a/packages/platform-android/src/runner.ts +++ b/packages/platform-android/src/runner.ts @@ -9,6 +9,7 @@ import { getAndroidEmulatorPlatformInstance, getAndroidPhysicalDevicePlatformInstance, } from './instance.js'; +import { initializeAndroidProcessEnv } from './environment.js'; const getAndroidRunner = async ( config: AndroidPlatformConfig, @@ -16,6 +17,8 @@ const getAndroidRunner = async ( ): Promise => { const parsedConfig = AndroidPlatformConfigSchema.parse(config); + initializeAndroidProcessEnv(); + if (isAndroidDeviceEmulator(parsedConfig.device)) { return getAndroidEmulatorPlatformInstance(parsedConfig, harnessConfig); } diff --git a/packages/platform-android/src/shared-prefs.ts b/packages/platform-android/src/shared-prefs.ts index 3c3a21f0..9d983fad 100644 --- a/packages/platform-android/src/shared-prefs.ts +++ b/packages/platform-android/src/shared-prefs.ts @@ -1,5 +1,4 @@ import { spawn, SubprocessError } from '@react-native-harness/tools'; -import { withAndroidProcessEnv } from './environment.js'; const DEBUG_HTTP_HOST_BLOCK_START = ''; @@ -114,16 +113,12 @@ const readSharedPrefsFile = async ( bundleId: string ): Promise => { try { - const { stdout } = await spawn( - 'adb', - [ - '-s', - adbId, - 'shell', - `run-as ${bundleId} cat ${getSharedPrefsPath(bundleId)}`, - ], - withAndroidProcessEnv() - ); + const { stdout } = await spawn('adb', [ + '-s', + adbId, + 'shell', + `run-as ${bundleId} cat ${getSharedPrefsPath(bundleId)}`, + ]); return stdout; } catch (error) { if (error instanceof SubprocessError && error.exitCode === 1) { @@ -149,7 +144,7 @@ const writeSharedPrefsFile = async ( bundleId )}'`, ], - withAndroidProcessEnv({ stdin: { string: `${content.trim()}\n` } }) + { stdin: { string: `${content.trim()}\n` } } ); }; From 4f862ef649cecdef7854afeae3166ac5dd934e2d Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 31 Mar 2026 14:04:15 +0200 Subject: [PATCH 06/26] fix: handle non-booted iOS simulator states --- .../src/__tests__/instance.test.ts | 137 +++++++++++++++--- packages/platform-ios/src/instance.ts | 7 +- packages/platform-ios/src/xcrun/simctl.ts | 13 +- 3 files changed, 130 insertions(+), 27 deletions(-) diff --git a/packages/platform-ios/src/__tests__/instance.test.ts b/packages/platform-ios/src/__tests__/instance.test.ts index c3e1f6bd..d0cb24ab 100644 --- a/packages/platform-ios/src/__tests__/instance.test.ts +++ b/packages/platform-ios/src/__tests__/instance.test.ts @@ -11,6 +11,9 @@ import * as simctl from '../xcrun/simctl.js'; import * as devicectl from '../xcrun/devicectl.js'; import * as libimobiledevice from '../libimobiledevice.js'; import { HarnessAppPathError } from '../errors.js'; +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; const harnessConfig = { metroPort: DEFAULT_METRO_PORT, @@ -201,8 +204,95 @@ describe('iOS platform instance dependency validation', () => { expect(shutdownSimulator).toHaveBeenCalledWith('sim-udid'); }); + it('waits for a simulator that is already booting', async () => { + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booting'); + const bootSimulator = vi + .spyOn(simctl, 'bootSimulator') + .mockResolvedValue(undefined); + const waitForBoot = vi + .spyOn(simctl, 'waitForBoot') + .mockResolvedValue(undefined); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( + undefined + ); + vi.spyOn(simctl, 'stopApp').mockResolvedValue(undefined); + vi.spyOn(simctl, 'clearHarnessJsLocationOverride').mockResolvedValue( + undefined + ); + const shutdownSimulator = vi + .spyOn(simctl, 'shutdownSimulator') + .mockResolvedValue(undefined); + + const instance = await getAppleSimulatorPlatformInstance( + { + name: 'ios', + device: { + type: 'simulator', + name: 'iPhone 16 Pro', + systemVersion: '18.0', + }, + bundleId: 'com.harnessplayground', + }, + harnessConfig + ); + + expect(bootSimulator).not.toHaveBeenCalled(); + expect(waitForBoot).toHaveBeenCalledWith('sim-udid'); + + await instance.dispose(); + + expect(shutdownSimulator).not.toHaveBeenCalled(); + }); + + it('boots and waits for other non-booted simulator states', async () => { + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Creating'); + const bootSimulator = vi + .spyOn(simctl, 'bootSimulator') + .mockResolvedValue(undefined); + const waitForBoot = vi + .spyOn(simctl, 'waitForBoot') + .mockResolvedValue(undefined); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( + undefined + ); + vi.spyOn(simctl, 'stopApp').mockResolvedValue(undefined); + vi.spyOn(simctl, 'clearHarnessJsLocationOverride').mockResolvedValue( + undefined + ); + const shutdownSimulator = vi + .spyOn(simctl, 'shutdownSimulator') + .mockResolvedValue(undefined); + + const instance = await getAppleSimulatorPlatformInstance( + { + name: 'ios', + device: { + type: 'simulator', + name: 'iPhone 16 Pro', + systemVersion: '18.0', + }, + bundleId: 'com.harnessplayground', + }, + harnessConfig + ); + + expect(bootSimulator).toHaveBeenCalledWith('sim-udid'); + expect(waitForBoot).toHaveBeenCalledWith('sim-udid'); + + await instance.dispose(); + + expect(shutdownSimulator).toHaveBeenCalledWith('sim-udid'); + }); + it('installs the app from HARNESS_APP_PATH when missing', async () => { - vi.stubEnv('HARNESS_APP_PATH', '/tmp/HarnessPlayground.app'); + const appDir = mkdtempSync(join(tmpdir(), 'rn-harness-ios-app-')); + const bundlePath = join(appDir, 'HarnessPlayground.app'); + mkdirSync(bundlePath); + vi.stubEnv('HARNESS_APP_PATH', bundlePath); vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(false); @@ -212,30 +302,27 @@ describe('iOS platform instance dependency validation', () => { vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( undefined ); - const existsSync = vi - .spyOn(await import('node:fs'), 'existsSync') - .mockReturnValue(true); - await expect( - getAppleSimulatorPlatformInstance( - { - name: 'ios', - device: { - type: 'simulator', - name: 'iPhone 16 Pro', - systemVersion: '18.0', + try { + await expect( + getAppleSimulatorPlatformInstance( + { + name: 'ios', + device: { + type: 'simulator', + name: 'iPhone 16 Pro', + systemVersion: '18.0', + }, + bundleId: 'com.harnessplayground', }, - bundleId: 'com.harnessplayground', - }, - harnessConfig - ) - ).resolves.toBeDefined(); + harnessConfig + ) + ).resolves.toBeDefined(); - expect(existsSync).toHaveBeenCalledWith('/tmp/HarnessPlayground.app'); - expect(installApp).toHaveBeenCalledWith( - 'sim-udid', - '/tmp/HarnessPlayground.app' - ); + expect(installApp).toHaveBeenCalledWith('sim-udid', bundlePath); + } finally { + rmSync(appDir, { force: true, recursive: true }); + } }); it('throws a HarnessAppPathError when HARNESS_APP_PATH is missing', async () => { @@ -260,11 +347,13 @@ describe('iOS platform instance dependency validation', () => { }); it('throws a HarnessAppPathError when HARNESS_APP_PATH points to a missing app', async () => { - vi.stubEnv('HARNESS_APP_PATH', '/tmp/missing.app'); + vi.stubEnv( + 'HARNESS_APP_PATH', + join(tmpdir(), 'rn-harness-ios-missing-app', 'Missing.app') + ); vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(false); - vi.spyOn(await import('node:fs'), 'existsSync').mockReturnValue(false); await expect( getAppleSimulatorPlatformInstance( diff --git a/packages/platform-ios/src/instance.ts b/packages/platform-ios/src/instance.ts index 543c843c..ec318bba 100644 --- a/packages/platform-ios/src/instance.ts +++ b/packages/platform-ios/src/instance.ts @@ -56,12 +56,15 @@ export const getAppleSimulatorPlatformInstance = async ( const simulatorStatus = await simctl.getSimulatorStatus(udid); let startedByHarness = false; - if (simulatorStatus === 'Shutdown') { + if ( + !simctl.isBootedSimulatorStatus(simulatorStatus) && + !simctl.isBootingSimulatorStatus(simulatorStatus) + ) { await simctl.bootSimulator(udid); startedByHarness = true; } - if (simulatorStatus === 'Shutdown' || simulatorStatus === 'Booting') { + if (!simctl.isBootedSimulatorStatus(simulatorStatus)) { await simctl.waitForBoot(udid); } diff --git a/packages/platform-ios/src/xcrun/simctl.ts b/packages/platform-ios/src/xcrun/simctl.ts index 6619aefb..f1665606 100644 --- a/packages/platform-ios/src/xcrun/simctl.ts +++ b/packages/platform-ios/src/xcrun/simctl.ts @@ -209,7 +209,18 @@ export const isAppInstalled = async ( return appInfo !== null; }; -export type AppleSimulatorState = 'Booted' | 'Booting' | 'Shutdown'; +export type AppleSimulatorState = + | 'Booted' + | 'Booting' + | 'Shutdown' + | (string & {}); + +export const isBootedSimulatorStatus = (status: AppleSimulatorState): boolean => + status === 'Booted'; + +export const isBootingSimulatorStatus = ( + status: AppleSimulatorState +): boolean => status === 'Booting'; export type AppleSimulatorInfo = { name: string; From 827ef0a46419deb8d89b2b3b6575e2a38073d656 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 31 Mar 2026 14:49:02 +0200 Subject: [PATCH 07/26] fix: resolve Android SDK tool paths from SDK root --- packages/platform-android/src/adb.ts | 64 ++++++++++++++------ packages/platform-android/src/environment.ts | 38 +++++++++++- 2 files changed, 82 insertions(+), 20 deletions(-) diff --git a/packages/platform-android/src/adb.ts b/packages/platform-android/src/adb.ts index fdeb55a2..1c5eb26c 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -1,5 +1,11 @@ import { type AndroidAppLaunchOptions } from '@react-native-harness/platforms'; import { spawn, SubprocessError } from '@react-native-harness/tools'; +import { + getAdbBinaryPath, + getAvdManagerBinaryPath, + getEmulatorBinaryPath, + getSdkManagerBinaryPath, +} from './environment.js'; const wait = async (ms: number): Promise => { await new Promise((resolve) => { @@ -11,6 +17,11 @@ const getSystemImagePackage = (apiLevel: number): string => { return `system-images;android-${apiLevel};default;x86_64`; }; +const getAvdConfigPath = (name: string): string => + `${ + process.env.ANDROID_AVD_HOME ?? `${process.env.HOME}/.android/avd` + }/${name}.avd/config.ini`; + export type CreateAvdOptions = { name: string; apiLevel: number; @@ -65,7 +76,7 @@ export const isAppInstalled = async ( adbId: string, bundleId: string ): Promise => { - const { stdout } = await spawn('adb', [ + const { stdout } = await spawn(getAdbBinaryPath(), [ '-s', adbId, 'shell', @@ -82,7 +93,7 @@ export const reversePort = async ( port: number, hostPort: number = port ): Promise => { - await spawn('adb', [ + await spawn(getAdbBinaryPath(), [ '-s', adbId, 'reverse', @@ -95,7 +106,14 @@ export const stopApp = async ( adbId: string, bundleId: string ): Promise => { - await spawn('adb', ['-s', adbId, 'shell', 'am', 'force-stop', bundleId]); + await spawn(getAdbBinaryPath(), [ + '-s', + adbId, + 'shell', + 'am', + 'force-stop', + bundleId, + ]); }; export const startApp = async ( @@ -104,7 +122,7 @@ export const startApp = async ( activityName: string, options?: AndroidAppLaunchOptions ): Promise => { - await spawn('adb', [ + await spawn(getAdbBinaryPath(), [ '-s', adbId, ...getStartAppArgs(bundleId, activityName, options), @@ -112,7 +130,7 @@ export const startApp = async ( }; export const getDeviceIds = async (): Promise => { - const { stdout } = await spawn('adb', ['devices']); + const { stdout } = await spawn(getAdbBinaryPath(), ['devices']); return stdout .split('\n') .slice(1) // Skip header @@ -123,7 +141,13 @@ export const getDeviceIds = async (): Promise => { export const getEmulatorName = async ( adbId: string ): Promise => { - const { stdout } = await spawn('adb', ['-s', adbId, 'emu', 'avd', 'name']); + const { stdout } = await spawn(getAdbBinaryPath(), [ + '-s', + adbId, + 'emu', + 'avd', + 'name', + ]); return stdout.split('\n')[0].trim() || null; }; @@ -131,7 +155,7 @@ export const getShellProperty = async ( adbId: string, property: string ): Promise => { - const { stdout } = await spawn('adb', [ + const { stdout } = await spawn(getAdbBinaryPath(), [ '-s', adbId, 'shell', @@ -160,14 +184,14 @@ export const isBootCompleted = async (adbId: string): Promise => { }; export const stopEmulator = async (adbId: string): Promise => { - await spawn('adb', ['-s', adbId, 'emu', 'kill']); + await spawn(getAdbBinaryPath(), ['-s', adbId, 'emu', 'kill']); }; export const installApp = async ( adbId: string, appPath: string ): Promise => { - await spawn('adb', ['-s', adbId, 'install', '-r', appPath]); + await spawn(getAdbBinaryPath(), ['-s', adbId, 'install', '-r', appPath]); }; export const hasAvd = async (name: string): Promise => { @@ -184,20 +208,22 @@ export const createAvd = async ({ }: CreateAvdOptions): Promise => { const systemImagePackage = getSystemImagePackage(apiLevel); - await spawn('sdkmanager', [systemImagePackage]); + await spawn(getSdkManagerBinaryPath(), [systemImagePackage]); await spawn('bash', [ '-lc', - `printf 'no\n' | avdmanager create avd --force --name "${name}" --package "${systemImagePackage}" --device "${profile}"`, + `printf 'no\n' | "${getAvdManagerBinaryPath()}" create avd --force --name "${name}" --package "${systemImagePackage}" --device "${profile}"`, ]); await spawn('bash', [ '-lc', - `printf '%s\n%s\n' 'disk.dataPartition.size=${diskSize}' 'vm.heapSize=${heapSize}' >> "$HOME/.android/avd/${name}.avd/config.ini"`, + `printf '%s\n%s\n' 'disk.dataPartition.size=${diskSize}' 'vm.heapSize=${heapSize}' >> "${getAvdConfigPath( + name + )}"`, ]); }; export const startEmulator = async (name: string): Promise => { void spawn( - 'emulator', + getEmulatorBinaryPath(), [ `@${name}`, '-no-snapshot-save', @@ -266,7 +292,7 @@ export const isAppRunning = async ( bundleId: string ): Promise => { try { - const { stdout } = await spawn('adb', [ + const { stdout } = await spawn(getAdbBinaryPath(), [ '-s', adbId, 'shell', @@ -287,7 +313,7 @@ export const getAppUid = async ( adbId: string, bundleId: string ): Promise => { - const { stdout } = await spawn('adb', [ + const { stdout } = await spawn(getAdbBinaryPath(), [ '-s', adbId, 'shell', @@ -312,7 +338,7 @@ export const setHideErrorDialogs = async ( adbId: string, hide: boolean ): Promise => { - await spawn('adb', [ + await spawn(getAdbBinaryPath(), [ '-s', adbId, 'shell', @@ -325,7 +351,7 @@ export const setHideErrorDialogs = async ( }; export const getLogcatTimestamp = async (adbId: string): Promise => { - const { stdout } = await spawn('adb', [ + const { stdout } = await spawn(getAdbBinaryPath(), [ '-s', adbId, 'shell', @@ -338,7 +364,7 @@ export const getLogcatTimestamp = async (adbId: string): Promise => { export const getAvds = async (): Promise => { try { - const { stdout } = await spawn('emulator', ['-list-avds']); + const { stdout } = await spawn(getEmulatorBinaryPath(), ['-list-avds']); return stdout .split('\n') .map((line) => line.trim()) @@ -355,7 +381,7 @@ export type AdbDevice = { }; export const getConnectedDevices = async (): Promise => { - const { stdout } = await spawn('adb', ['devices', '-l']); + const { stdout } = await spawn(getAdbBinaryPath(), ['devices', '-l']); const lines = stdout.split('\n').slice(1); const devices: AdbDevice[] = []; diff --git a/packages/platform-android/src/environment.ts b/packages/platform-android/src/environment.ts index c3fb9198..363d1fbb 100644 --- a/packages/platform-android/src/environment.ts +++ b/packages/platform-android/src/environment.ts @@ -3,10 +3,24 @@ import path from 'node:path'; const CMDLINE_TOOLS_PATH_SEGMENTS = ['cmdline-tools', 'latest']; -const getAndroidSdkRoot = (env: NodeJS.ProcessEnv): string | null => { +export const getAndroidSdkRoot = ( + env: NodeJS.ProcessEnv = process.env +): string | null => { return env.ANDROID_HOME ?? env.ANDROID_SDK_ROOT ?? null; }; +const getRequiredAndroidSdkRoot = (): string => { + const sdkRoot = getAndroidSdkRoot(); + + if (!sdkRoot) { + throw new Error( + 'Android SDK root is not configured. Set ANDROID_HOME or ANDROID_SDK_ROOT.' + ); + } + + return sdkRoot; +}; + export const getAndroidProcessEnv = ( env: NodeJS.ProcessEnv = process.env ): NodeJS.ProcessEnv => { @@ -41,3 +55,25 @@ export const getAndroidProcessEnv = ( export const initializeAndroidProcessEnv = (): void => { Object.assign(process.env, getAndroidProcessEnv()); }; + +export const getAdbBinaryPath = (): string => + path.join(getRequiredAndroidSdkRoot(), 'platform-tools', 'adb'); + +export const getEmulatorBinaryPath = (): string => + path.join(getRequiredAndroidSdkRoot(), 'emulator', 'emulator'); + +export const getSdkManagerBinaryPath = (): string => + path.join( + getRequiredAndroidSdkRoot(), + ...CMDLINE_TOOLS_PATH_SEGMENTS, + 'bin', + 'sdkmanager' + ); + +export const getAvdManagerBinaryPath = (): string => + path.join( + getRequiredAndroidSdkRoot(), + ...CMDLINE_TOOLS_PATH_SEGMENTS, + 'bin', + 'avdmanager' + ); From 64e7277b599c4b1b7da5ac9704ab270d28f50f04 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 31 Mar 2026 14:49:15 +0200 Subject: [PATCH 08/26] chore: enable debug logging for iOS e2e runs --- .github/workflows/e2e-tests.yml | 2 ++ packages/platform-ios/src/instance.ts | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 3e8bc298..c1dafb40 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -110,6 +110,7 @@ jobs: timeout-minutes: 30 if: ${{ (github.event_name == 'pull_request' && github.event.pull_request.base.ref == 'main') || (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios')) }} env: + HARNESS_DEBUG: true DEBUG: 'Metro:*' steps: - name: Checkout code @@ -349,6 +350,7 @@ jobs: timeout-minutes: 30 if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios') }} env: + HARNESS_DEBUG: true DEBUG: 'Metro:*' steps: - name: Checkout code diff --git a/packages/platform-ios/src/instance.ts b/packages/platform-ios/src/instance.ts index ec318bba..7f64623f 100644 --- a/packages/platform-ios/src/instance.ts +++ b/packages/platform-ios/src/instance.ts @@ -22,8 +22,11 @@ import { } from './app-monitor.js'; import { assertLibimobiledeviceInstalled } from './libimobiledevice.js'; import { HarnessAppPathError } from './errors.js'; +import { logger } from '@react-native-harness/tools'; import fs from 'node:fs'; +const iosInstanceLogger = logger.child('ios-instance'); + const getHarnessAppPath = (): string => { const appPath = process.env.HARNESS_APP_PATH; @@ -56,15 +59,30 @@ export const getAppleSimulatorPlatformInstance = async ( const simulatorStatus = await simctl.getSimulatorStatus(udid); let startedByHarness = false; + iosInstanceLogger.debug( + 'resolved iOS simulator %s with status %s', + udid, + simulatorStatus + ); + if ( !simctl.isBootedSimulatorStatus(simulatorStatus) && !simctl.isBootingSimulatorStatus(simulatorStatus) ) { + iosInstanceLogger.debug( + 'booting iOS simulator %s from status %s', + udid, + simulatorStatus + ); await simctl.bootSimulator(udid); startedByHarness = true; } if (!simctl.isBootedSimulatorStatus(simulatorStatus)) { + iosInstanceLogger.debug( + 'waiting for iOS simulator %s to finish booting', + udid + ); await simctl.waitForBoot(udid); } From bfee9a8afb4e3b2c790bd0bbcc3c6210a87c3231 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 31 Mar 2026 15:02:08 +0200 Subject: [PATCH 09/26] chore: add debug logs for Android startup --- packages/platform-android/src/instance.ts | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/platform-android/src/instance.ts b/packages/platform-android/src/instance.ts index 72664b45..38ffa167 100644 --- a/packages/platform-android/src/instance.ts +++ b/packages/platform-android/src/instance.ts @@ -5,6 +5,7 @@ import { HarnessPlatformRunner, } from '@react-native-harness/platforms'; import type { Config as HarnessConfig } from '@react-native-harness/config'; +import { logger } from '@react-native-harness/tools'; import { AndroidPlatformConfig, assertAndroidDeviceEmulator, @@ -21,6 +22,8 @@ import { createAndroidAppMonitor } from './app-monitor.js'; import { HarnessAppPathError, HarnessEmulatorConfigError } from './errors.js'; import fs from 'node:fs'; +const androidInstanceLogger = logger.child('android-instance'); + const getHarnessAppPath = (): string => { const appPath = process.env.HARNESS_APP_PATH; @@ -61,6 +64,12 @@ export const getAndroidEmulatorPlatformInstance = async ( let adbId = await getAdbId(config.device); let startedByHarness = false; + androidInstanceLogger.debug( + 'resolved Android emulator %s with adb id %s', + config.device.name, + adbId ?? 'not-found' + ); + if (!adbId) { const avdConfig = config.device.avd; @@ -69,6 +78,10 @@ export const getAndroidEmulatorPlatformInstance = async ( } if (!(await adb.hasAvd(config.device.name))) { + androidInstanceLogger.debug( + 'creating Android AVD %s before startup', + config.device.name + ); await adb.createAvd({ name: config.device.name, apiLevel: avdConfig.apiLevel, @@ -78,15 +91,29 @@ export const getAndroidEmulatorPlatformInstance = async ( }); } + androidInstanceLogger.debug( + 'starting Android emulator %s', + config.device.name + ); await adb.startEmulator(config.device.name); adbId = await adb.waitForEmulator(config.device.name); startedByHarness = true; + + androidInstanceLogger.debug( + 'Android emulator %s connected as %s', + config.device.name, + adbId + ); } if (!adbId) { throw new DeviceNotFoundError(getDeviceName(config.device)); } + androidInstanceLogger.debug( + 'waiting for Android emulator %s to finish booting', + adbId + ); await adb.waitForBoot(adbId); const isInstalled = await adb.isAppInstalled(adbId, config.bundleId); From 36727790f64998b460b2954153af8bddf4db2b60 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 31 Mar 2026 15:15:56 +0200 Subject: [PATCH 10/26] fix: install Android emulator package when missing --- packages/platform-android/src/adb.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/platform-android/src/adb.ts b/packages/platform-android/src/adb.ts index 1c5eb26c..941df119 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -1,5 +1,6 @@ import { type AndroidAppLaunchOptions } from '@react-native-harness/platforms'; import { spawn, SubprocessError } from '@react-native-harness/tools'; +import { access } from 'node:fs/promises'; import { getAdbBinaryPath, getAvdManagerBinaryPath, @@ -22,6 +23,19 @@ const getAvdConfigPath = (name: string): string => process.env.ANDROID_AVD_HOME ?? `${process.env.HOME}/.android/avd` }/${name}.avd/config.ini`; +const ensureEmulatorInstalled = async (): Promise => { + const emulatorBinaryPath = getEmulatorBinaryPath(); + + try { + await access(emulatorBinaryPath); + return emulatorBinaryPath; + } catch { + await spawn(getSdkManagerBinaryPath(), ['emulator']); + await access(emulatorBinaryPath); + return emulatorBinaryPath; + } +}; + export type CreateAvdOptions = { name: string; apiLevel: number; @@ -222,8 +236,10 @@ export const createAvd = async ({ }; export const startEmulator = async (name: string): Promise => { + const emulatorBinaryPath = await ensureEmulatorInstalled(); + void spawn( - getEmulatorBinaryPath(), + emulatorBinaryPath, [ `@${name}`, '-no-snapshot-save', @@ -364,7 +380,8 @@ export const getLogcatTimestamp = async (adbId: string): Promise => { export const getAvds = async (): Promise => { try { - const { stdout } = await spawn(getEmulatorBinaryPath(), ['-list-avds']); + const emulatorBinaryPath = await ensureEmulatorInstalled(); + const { stdout } = await spawn(emulatorBinaryPath, ['-list-avds']); return stdout .split('\n') .map((line) => line.trim()) From 8760e4e69308da89e07ddda5d69e402deacbb898 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 1 Apr 2026 08:19:23 +0200 Subject: [PATCH 11/26] fix: add platform startup timeout --- actions/shared/index.cjs | 2643 ++++++++++------- apps/playground/rn-harness.config.mjs | 1 + packages/config/src/types.ts | 15 +- packages/jest/src/__tests__/errors.test.ts | 24 +- packages/jest/src/__tests__/harness.test.ts | 47 +- packages/jest/src/errors.ts | 14 +- packages/jest/src/harness.ts | 77 +- packages/platform-android/src/targets.ts | 16 +- .../docs/getting-started/configuration.mdx | 72 +- 9 files changed, 1702 insertions(+), 1207 deletions(-) diff --git a/actions/shared/index.cjs b/actions/shared/index.cjs index 98717f57..90f0c572 100644 --- a/actions/shared/index.cjs +++ b/actions/shared/index.cjs @@ -1,48 +1,73 @@ -"use strict"; +'use strict'; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; -var __commonJS = (cb, mod) => function __require() { - return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; -}; +var __commonJS = (cb, mod) => + function __require() { + return ( + mod || + (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), + mod.exports + ); + }; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { - if (from && typeof from === "object" || typeof from === "function") { + if ((from && typeof from === 'object') || typeof from === 'function') { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + __defProp(to, key, { + get: () => from[key], + enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable, + }); } return to; }; -var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( - // If the importer is in node compatibility mode or this is not an ESM - // file that has been converted to a CommonJS file using a Babel- - // compatible transform (i.e. "__esModule" has not been set), then set - // "default" to the CommonJS "module.exports" for node compatibility. - isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, - mod -)); +var __toESM = (mod, isNodeMode, target) => ( + (target = mod != null ? __create(__getProtoOf(mod)) : {}), + __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule + ? __defProp(target, 'default', { value: mod, enumerable: true }) + : target, + mod + ) +); // ../../node_modules/picocolors/picocolors.js var require_picocolors = __commonJS({ - "../../node_modules/picocolors/picocolors.js"(exports2, module2) { - "use strict"; + '../../node_modules/picocolors/picocolors.js'(exports2, module2) { + 'use strict'; var p = process || {}; var argv = p.argv || []; var env = p.env || {}; - var isColorSupported = !(!!env.NO_COLOR || argv.includes("--no-color")) && (!!env.FORCE_COLOR || argv.includes("--color") || p.platform === "win32" || (p.stdout || {}).isTTY && env.TERM !== "dumb" || !!env.CI); - var formatter = (open, close, replace = open) => (input) => { - let string = "" + input, index = string.indexOf(close, open.length); - return ~index ? open + replaceClose(string, close, replace, index) + close : open + string + close; - }; + var isColorSupported = + !(!!env.NO_COLOR || argv.includes('--no-color')) && + (!!env.FORCE_COLOR || + argv.includes('--color') || + p.platform === 'win32' || + ((p.stdout || {}).isTTY && env.TERM !== 'dumb') || + !!env.CI); + var formatter = + (open, close, replace = open) => + (input) => { + let string = '' + input, + index = string.indexOf(close, open.length); + return ~index + ? open + replaceClose(string, close, replace, index) + close + : open + string + close; + }; var replaceClose = (string, close, replace, index) => { - let result = "", cursor = 0; + let result = '', + cursor = 0; do { result += string.substring(cursor, index) + replace; cursor = index + close.length; @@ -54,68 +79,68 @@ var require_picocolors = __commonJS({ let f = enabled ? formatter : () => String; return { isColorSupported: enabled, - reset: f("\x1B[0m", "\x1B[0m"), - bold: f("\x1B[1m", "\x1B[22m", "\x1B[22m\x1B[1m"), - dim: f("\x1B[2m", "\x1B[22m", "\x1B[22m\x1B[2m"), - italic: f("\x1B[3m", "\x1B[23m"), - underline: f("\x1B[4m", "\x1B[24m"), - inverse: f("\x1B[7m", "\x1B[27m"), - hidden: f("\x1B[8m", "\x1B[28m"), - strikethrough: f("\x1B[9m", "\x1B[29m"), - black: f("\x1B[30m", "\x1B[39m"), - red: f("\x1B[31m", "\x1B[39m"), - green: f("\x1B[32m", "\x1B[39m"), - yellow: f("\x1B[33m", "\x1B[39m"), - blue: f("\x1B[34m", "\x1B[39m"), - magenta: f("\x1B[35m", "\x1B[39m"), - cyan: f("\x1B[36m", "\x1B[39m"), - white: f("\x1B[37m", "\x1B[39m"), - gray: f("\x1B[90m", "\x1B[39m"), - bgBlack: f("\x1B[40m", "\x1B[49m"), - bgRed: f("\x1B[41m", "\x1B[49m"), - bgGreen: f("\x1B[42m", "\x1B[49m"), - bgYellow: f("\x1B[43m", "\x1B[49m"), - bgBlue: f("\x1B[44m", "\x1B[49m"), - bgMagenta: f("\x1B[45m", "\x1B[49m"), - bgCyan: f("\x1B[46m", "\x1B[49m"), - bgWhite: f("\x1B[47m", "\x1B[49m"), - blackBright: f("\x1B[90m", "\x1B[39m"), - redBright: f("\x1B[91m", "\x1B[39m"), - greenBright: f("\x1B[92m", "\x1B[39m"), - yellowBright: f("\x1B[93m", "\x1B[39m"), - blueBright: f("\x1B[94m", "\x1B[39m"), - magentaBright: f("\x1B[95m", "\x1B[39m"), - cyanBright: f("\x1B[96m", "\x1B[39m"), - whiteBright: f("\x1B[97m", "\x1B[39m"), - bgBlackBright: f("\x1B[100m", "\x1B[49m"), - bgRedBright: f("\x1B[101m", "\x1B[49m"), - bgGreenBright: f("\x1B[102m", "\x1B[49m"), - bgYellowBright: f("\x1B[103m", "\x1B[49m"), - bgBlueBright: f("\x1B[104m", "\x1B[49m"), - bgMagentaBright: f("\x1B[105m", "\x1B[49m"), - bgCyanBright: f("\x1B[106m", "\x1B[49m"), - bgWhiteBright: f("\x1B[107m", "\x1B[49m") + reset: f('\x1B[0m', '\x1B[0m'), + bold: f('\x1B[1m', '\x1B[22m', '\x1B[22m\x1B[1m'), + dim: f('\x1B[2m', '\x1B[22m', '\x1B[22m\x1B[2m'), + italic: f('\x1B[3m', '\x1B[23m'), + underline: f('\x1B[4m', '\x1B[24m'), + inverse: f('\x1B[7m', '\x1B[27m'), + hidden: f('\x1B[8m', '\x1B[28m'), + strikethrough: f('\x1B[9m', '\x1B[29m'), + black: f('\x1B[30m', '\x1B[39m'), + red: f('\x1B[31m', '\x1B[39m'), + green: f('\x1B[32m', '\x1B[39m'), + yellow: f('\x1B[33m', '\x1B[39m'), + blue: f('\x1B[34m', '\x1B[39m'), + magenta: f('\x1B[35m', '\x1B[39m'), + cyan: f('\x1B[36m', '\x1B[39m'), + white: f('\x1B[37m', '\x1B[39m'), + gray: f('\x1B[90m', '\x1B[39m'), + bgBlack: f('\x1B[40m', '\x1B[49m'), + bgRed: f('\x1B[41m', '\x1B[49m'), + bgGreen: f('\x1B[42m', '\x1B[49m'), + bgYellow: f('\x1B[43m', '\x1B[49m'), + bgBlue: f('\x1B[44m', '\x1B[49m'), + bgMagenta: f('\x1B[45m', '\x1B[49m'), + bgCyan: f('\x1B[46m', '\x1B[49m'), + bgWhite: f('\x1B[47m', '\x1B[49m'), + blackBright: f('\x1B[90m', '\x1B[39m'), + redBright: f('\x1B[91m', '\x1B[39m'), + greenBright: f('\x1B[92m', '\x1B[39m'), + yellowBright: f('\x1B[93m', '\x1B[39m'), + blueBright: f('\x1B[94m', '\x1B[39m'), + magentaBright: f('\x1B[95m', '\x1B[39m'), + cyanBright: f('\x1B[96m', '\x1B[39m'), + whiteBright: f('\x1B[97m', '\x1B[39m'), + bgBlackBright: f('\x1B[100m', '\x1B[49m'), + bgRedBright: f('\x1B[101m', '\x1B[49m'), + bgGreenBright: f('\x1B[102m', '\x1B[49m'), + bgYellowBright: f('\x1B[103m', '\x1B[49m'), + bgBlueBright: f('\x1B[104m', '\x1B[49m'), + bgMagentaBright: f('\x1B[105m', '\x1B[49m'), + bgCyanBright: f('\x1B[106m', '\x1B[49m'), + bgWhiteBright: f('\x1B[107m', '\x1B[49m'), }; }; module2.exports = createColors(); module2.exports.createColors = createColors; - } + }, }); // ../../node_modules/sisteransi/src/index.js var require_src = __commonJS({ - "../../node_modules/sisteransi/src/index.js"(exports2, module2) { - "use strict"; - var ESC = "\x1B"; + '../../node_modules/sisteransi/src/index.js'(exports2, module2) { + 'use strict'; + var ESC = '\x1B'; var CSI = `${ESC}[`; - var beep = "\x07"; + var beep = '\x07'; var cursor = { to(x2, y) { if (!y) return `${CSI}${x2 + 1}G`; return `${CSI}${y + 1};${x2 + 1}H`; }, move(x2, y) { - let ret = ""; + let ret = ''; if (x2 < 0) ret += `${CSI}${-x2}D`; else if (x2 > 0) ret += `${CSI}${x2}C`; if (y < 0) ret += `${CSI}${-y}A`; @@ -132,11 +157,11 @@ var require_src = __commonJS({ hide: `${CSI}?25l`, show: `${CSI}?25h`, save: `${ESC}7`, - restore: `${ESC}8` + restore: `${ESC}8`, }; var scroll = { up: (count = 1) => `${CSI}S`.repeat(count), - down: (count = 1) => `${CSI}T`.repeat(count) + down: (count = 1) => `${CSI}T`.repeat(count), }; var erase = { screen: `${CSI}2J`, @@ -146,16 +171,15 @@ var require_src = __commonJS({ lineEnd: `${CSI}K`, lineStart: `${CSI}1K`, lines(count) { - let clear = ""; + let clear = ''; for (let i = 0; i < count; i++) - clear += this.line + (i < count - 1 ? cursor.up() : ""); - if (count) - clear += cursor.left; + clear += this.line + (i < count - 1 ? cursor.up() : ''); + if (count) clear += cursor.left; return clear; - } + }, }; module2.exports = { cursor, scroll, erase, beep }; - } + }, }); // ../../node_modules/zod/dist/esm/v3/external.js @@ -267,16 +291,14 @@ __export(external_exports, { union: () => unionType, unknown: () => unknownType, util: () => util, - void: () => voidType + void: () => voidType, }); // ../../node_modules/zod/dist/esm/v3/helpers/util.js var util; -(function(util3) { - util3.assertEqual = (_2) => { - }; - function assertIs(_arg) { - } +(function (util3) { + util3.assertEqual = (_2) => {}; + function assertIs(_arg) {} util3.assertIs = assertIs; function assertNever(_x) { throw new Error(); @@ -290,7 +312,9 @@ var util; return obj; }; util3.getValidEnumValues = (obj) => { - const validKeys = util3.objectKeys(obj).filter((k3) => typeof obj[obj[k3]] !== "number"); + const validKeys = util3 + .objectKeys(obj) + .filter((k3) => typeof obj[obj[k3]] !== 'number'); const filtered = {}; for (const k3 of validKeys) { filtered[k3] = obj[k3]; @@ -298,104 +322,119 @@ var util; return util3.objectValues(filtered); }; util3.objectValues = (obj) => { - return util3.objectKeys(obj).map(function(e) { + return util3.objectKeys(obj).map(function (e) { return obj[e]; }); }; - util3.objectKeys = typeof Object.keys === "function" ? (obj) => Object.keys(obj) : (object) => { - const keys = []; - for (const key in object) { - if (Object.prototype.hasOwnProperty.call(object, key)) { - keys.push(key); - } - } - return keys; - }; + util3.objectKeys = + typeof Object.keys === 'function' + ? (obj) => Object.keys(obj) + : (object) => { + const keys = []; + for (const key in object) { + if (Object.prototype.hasOwnProperty.call(object, key)) { + keys.push(key); + } + } + return keys; + }; util3.find = (arr, checker) => { for (const item of arr) { - if (checker(item)) - return item; + if (checker(item)) return item; } return void 0; }; - util3.isInteger = typeof Number.isInteger === "function" ? (val) => Number.isInteger(val) : (val) => typeof val === "number" && Number.isFinite(val) && Math.floor(val) === val; - function joinValues(array, separator = " | ") { - return array.map((val) => typeof val === "string" ? `'${val}'` : val).join(separator); + util3.isInteger = + typeof Number.isInteger === 'function' + ? (val) => Number.isInteger(val) + : (val) => + typeof val === 'number' && + Number.isFinite(val) && + Math.floor(val) === val; + function joinValues(array, separator = ' | ') { + return array + .map((val) => (typeof val === 'string' ? `'${val}'` : val)) + .join(separator); } util3.joinValues = joinValues; util3.jsonStringifyReplacer = (_2, value) => { - if (typeof value === "bigint") { + if (typeof value === 'bigint') { return value.toString(); } return value; }; })(util || (util = {})); var objectUtil; -(function(objectUtil2) { +(function (objectUtil2) { objectUtil2.mergeShapes = (first, second) => { return { ...first, - ...second + ...second, // second overwrites first }; }; })(objectUtil || (objectUtil = {})); var ZodParsedType = util.arrayToEnum([ - "string", - "nan", - "number", - "integer", - "float", - "boolean", - "date", - "bigint", - "symbol", - "function", - "undefined", - "null", - "array", - "object", - "unknown", - "promise", - "void", - "never", - "map", - "set" + 'string', + 'nan', + 'number', + 'integer', + 'float', + 'boolean', + 'date', + 'bigint', + 'symbol', + 'function', + 'undefined', + 'null', + 'array', + 'object', + 'unknown', + 'promise', + 'void', + 'never', + 'map', + 'set', ]); var getParsedType = (data) => { const t2 = typeof data; switch (t2) { - case "undefined": + case 'undefined': return ZodParsedType.undefined; - case "string": + case 'string': return ZodParsedType.string; - case "number": + case 'number': return Number.isNaN(data) ? ZodParsedType.nan : ZodParsedType.number; - case "boolean": + case 'boolean': return ZodParsedType.boolean; - case "function": + case 'function': return ZodParsedType.function; - case "bigint": + case 'bigint': return ZodParsedType.bigint; - case "symbol": + case 'symbol': return ZodParsedType.symbol; - case "object": + case 'object': if (Array.isArray(data)) { return ZodParsedType.array; } if (data === null) { return ZodParsedType.null; } - if (data.then && typeof data.then === "function" && data.catch && typeof data.catch === "function") { + if ( + data.then && + typeof data.then === 'function' && + data.catch && + typeof data.catch === 'function' + ) { return ZodParsedType.promise; } - if (typeof Map !== "undefined" && data instanceof Map) { + if (typeof Map !== 'undefined' && data instanceof Map) { return ZodParsedType.map; } - if (typeof Set !== "undefined" && data instanceof Set) { + if (typeof Set !== 'undefined' && data instanceof Set) { return ZodParsedType.set; } - if (typeof Date !== "undefined" && data instanceof Date) { + if (typeof Date !== 'undefined' && data instanceof Date) { return ZodParsedType.date; } return ZodParsedType.object; @@ -406,26 +445,26 @@ var getParsedType = (data) => { // ../../node_modules/zod/dist/esm/v3/ZodError.js var ZodIssueCode = util.arrayToEnum([ - "invalid_type", - "invalid_literal", - "custom", - "invalid_union", - "invalid_union_discriminator", - "invalid_enum_value", - "unrecognized_keys", - "invalid_arguments", - "invalid_return_type", - "invalid_date", - "invalid_string", - "too_small", - "too_big", - "invalid_intersection_types", - "not_multiple_of", - "not_finite" + 'invalid_type', + 'invalid_literal', + 'custom', + 'invalid_union', + 'invalid_union_discriminator', + 'invalid_enum_value', + 'unrecognized_keys', + 'invalid_arguments', + 'invalid_return_type', + 'invalid_date', + 'invalid_string', + 'too_small', + 'too_big', + 'invalid_intersection_types', + 'not_multiple_of', + 'not_finite', ]); var quotelessJson = (obj) => { const json = JSON.stringify(obj, null, 2); - return json.replace(/"([^"]+)":/g, "$1:"); + return json.replace(/"([^"]+)":/g, '$1:'); }; var ZodError = class _ZodError extends Error { get errors() { @@ -446,21 +485,23 @@ var ZodError = class _ZodError extends Error { } else { this.__proto__ = actualProto; } - this.name = "ZodError"; + this.name = 'ZodError'; this.issues = issues; } format(_mapper) { - const mapper = _mapper || function(issue) { - return issue.message; - }; + const mapper = + _mapper || + function (issue) { + return issue.message; + }; const fieldErrors = { _errors: [] }; const processError = (error) => { for (const issue of error.issues) { - if (issue.code === "invalid_union") { + if (issue.code === 'invalid_union') { issue.unionErrors.map(processError); - } else if (issue.code === "invalid_return_type") { + } else if (issue.code === 'invalid_return_type') { processError(issue.returnTypeError); - } else if (issue.code === "invalid_arguments") { + } else if (issue.code === 'invalid_arguments') { processError(issue.argumentsError); } else if (issue.path.length === 0) { fieldErrors._errors.push(mapper(issue)); @@ -527,25 +568,35 @@ var errorMap = (issue, _ctx) => { switch (issue.code) { case ZodIssueCode.invalid_type: if (issue.received === ZodParsedType.undefined) { - message = "Required"; + message = 'Required'; } else { message = `Expected ${issue.expected}, received ${issue.received}`; } break; case ZodIssueCode.invalid_literal: - message = `Invalid literal value, expected ${JSON.stringify(issue.expected, util.jsonStringifyReplacer)}`; + message = `Invalid literal value, expected ${JSON.stringify( + issue.expected, + util.jsonStringifyReplacer + )}`; break; case ZodIssueCode.unrecognized_keys: - message = `Unrecognized key(s) in object: ${util.joinValues(issue.keys, ", ")}`; + message = `Unrecognized key(s) in object: ${util.joinValues( + issue.keys, + ', ' + )}`; break; case ZodIssueCode.invalid_union: message = `Invalid input`; break; case ZodIssueCode.invalid_union_discriminator: - message = `Invalid discriminator value. Expected ${util.joinValues(issue.options)}`; + message = `Invalid discriminator value. Expected ${util.joinValues( + issue.options + )}`; break; case ZodIssueCode.invalid_enum_value: - message = `Invalid enum value. Expected ${util.joinValues(issue.options)}, received '${issue.received}'`; + message = `Invalid enum value. Expected ${util.joinValues( + issue.options + )}, received '${issue.received}'`; break; case ZodIssueCode.invalid_arguments: message = `Invalid function arguments`; @@ -557,50 +608,86 @@ var errorMap = (issue, _ctx) => { message = `Invalid date`; break; case ZodIssueCode.invalid_string: - if (typeof issue.validation === "object") { - if ("includes" in issue.validation) { + if (typeof issue.validation === 'object') { + if ('includes' in issue.validation) { message = `Invalid input: must include "${issue.validation.includes}"`; - if (typeof issue.validation.position === "number") { + if (typeof issue.validation.position === 'number') { message = `${message} at one or more positions greater than or equal to ${issue.validation.position}`; } - } else if ("startsWith" in issue.validation) { + } else if ('startsWith' in issue.validation) { message = `Invalid input: must start with "${issue.validation.startsWith}"`; - } else if ("endsWith" in issue.validation) { + } else if ('endsWith' in issue.validation) { message = `Invalid input: must end with "${issue.validation.endsWith}"`; } else { util.assertNever(issue.validation); } - } else if (issue.validation !== "regex") { + } else if (issue.validation !== 'regex') { message = `Invalid ${issue.validation}`; } else { - message = "Invalid"; + message = 'Invalid'; } break; case ZodIssueCode.too_small: - if (issue.type === "array") - message = `Array must contain ${issue.exact ? "exactly" : issue.inclusive ? `at least` : `more than`} ${issue.minimum} element(s)`; - else if (issue.type === "string") - message = `String must contain ${issue.exact ? "exactly" : issue.inclusive ? `at least` : `over`} ${issue.minimum} character(s)`; - else if (issue.type === "number") - message = `Number must be ${issue.exact ? `exactly equal to ` : issue.inclusive ? `greater than or equal to ` : `greater than `}${issue.minimum}`; - else if (issue.type === "date") - message = `Date must be ${issue.exact ? `exactly equal to ` : issue.inclusive ? `greater than or equal to ` : `greater than `}${new Date(Number(issue.minimum))}`; - else - message = "Invalid input"; + if (issue.type === 'array') + message = `Array must contain ${ + issue.exact ? 'exactly' : issue.inclusive ? `at least` : `more than` + } ${issue.minimum} element(s)`; + else if (issue.type === 'string') + message = `String must contain ${ + issue.exact ? 'exactly' : issue.inclusive ? `at least` : `over` + } ${issue.minimum} character(s)`; + else if (issue.type === 'number') + message = `Number must be ${ + issue.exact + ? `exactly equal to ` + : issue.inclusive + ? `greater than or equal to ` + : `greater than ` + }${issue.minimum}`; + else if (issue.type === 'date') + message = `Date must be ${ + issue.exact + ? `exactly equal to ` + : issue.inclusive + ? `greater than or equal to ` + : `greater than ` + }${new Date(Number(issue.minimum))}`; + else message = 'Invalid input'; break; case ZodIssueCode.too_big: - if (issue.type === "array") - message = `Array must contain ${issue.exact ? `exactly` : issue.inclusive ? `at most` : `less than`} ${issue.maximum} element(s)`; - else if (issue.type === "string") - message = `String must contain ${issue.exact ? `exactly` : issue.inclusive ? `at most` : `under`} ${issue.maximum} character(s)`; - else if (issue.type === "number") - message = `Number must be ${issue.exact ? `exactly` : issue.inclusive ? `less than or equal to` : `less than`} ${issue.maximum}`; - else if (issue.type === "bigint") - message = `BigInt must be ${issue.exact ? `exactly` : issue.inclusive ? `less than or equal to` : `less than`} ${issue.maximum}`; - else if (issue.type === "date") - message = `Date must be ${issue.exact ? `exactly` : issue.inclusive ? `smaller than or equal to` : `smaller than`} ${new Date(Number(issue.maximum))}`; - else - message = "Invalid input"; + if (issue.type === 'array') + message = `Array must contain ${ + issue.exact ? `exactly` : issue.inclusive ? `at most` : `less than` + } ${issue.maximum} element(s)`; + else if (issue.type === 'string') + message = `String must contain ${ + issue.exact ? `exactly` : issue.inclusive ? `at most` : `under` + } ${issue.maximum} character(s)`; + else if (issue.type === 'number') + message = `Number must be ${ + issue.exact + ? `exactly` + : issue.inclusive + ? `less than or equal to` + : `less than` + } ${issue.maximum}`; + else if (issue.type === 'bigint') + message = `BigInt must be ${ + issue.exact + ? `exactly` + : issue.inclusive + ? `less than or equal to` + : `less than` + } ${issue.maximum}`; + else if (issue.type === 'date') + message = `Date must be ${ + issue.exact + ? `exactly` + : issue.inclusive + ? `smaller than or equal to` + : `smaller than` + } ${new Date(Number(issue.maximum))}`; + else message = 'Invalid input'; break; case ZodIssueCode.custom: message = `Invalid input`; @@ -612,7 +699,7 @@ var errorMap = (issue, _ctx) => { message = `Number must be a multiple of ${issue.multipleOf}`; break; case ZodIssueCode.not_finite: - message = "Number must be finite"; + message = 'Number must be finite'; break; default: message = _ctx.defaultError; @@ -634,27 +721,30 @@ function getErrorMap() { // ../../node_modules/zod/dist/esm/v3/helpers/parseUtil.js var makeIssue = (params) => { const { data, path: path6, errorMaps, issueData } = params; - const fullPath = [...path6, ...issueData.path || []]; + const fullPath = [...path6, ...(issueData.path || [])]; const fullIssue = { ...issueData, - path: fullPath + path: fullPath, }; if (issueData.message !== void 0) { return { ...issueData, path: fullPath, - message: issueData.message + message: issueData.message, }; } - let errorMessage = ""; - const maps = errorMaps.filter((m) => !!m).slice().reverse(); + let errorMessage = ''; + const maps = errorMaps + .filter((m) => !!m) + .slice() + .reverse(); for (const map of maps) { errorMessage = map(fullIssue, { data, defaultError: errorMessage }).message; } return { ...issueData, path: fullPath, - message: errorMessage + message: errorMessage, }; }; var EMPTY_PATH = []; @@ -671,31 +761,27 @@ function addIssueToContext(ctx, issueData) { // then schema-bound map if available overrideMap, // then global override map - overrideMap === en_default ? void 0 : en_default + overrideMap === en_default ? void 0 : en_default, // then global default map - ].filter((x2) => !!x2) + ].filter((x2) => !!x2), }); ctx.common.issues.push(issue); } var ParseStatus = class _ParseStatus { constructor() { - this.value = "valid"; + this.value = 'valid'; } dirty() { - if (this.value === "valid") - this.value = "dirty"; + if (this.value === 'valid') this.value = 'dirty'; } abort() { - if (this.value !== "aborted") - this.value = "aborted"; + if (this.value !== 'aborted') this.value = 'aborted'; } static mergeArray(status, results) { const arrayValue = []; for (const s of results) { - if (s.status === "aborted") - return INVALID; - if (s.status === "dirty") - status.dirty(); + if (s.status === 'aborted') return INVALID; + if (s.status === 'dirty') status.dirty(); arrayValue.push(s.value); } return { status: status.value, value: arrayValue }; @@ -707,7 +793,7 @@ var ParseStatus = class _ParseStatus { const value = await pair.value; syncPairs.push({ key, - value + value, }); } return _ParseStatus.mergeObjectSync(status, syncPairs); @@ -716,15 +802,14 @@ var ParseStatus = class _ParseStatus { const finalObject = {}; for (const pair of pairs) { const { key, value } = pair; - if (key.status === "aborted") - return INVALID; - if (value.status === "aborted") - return INVALID; - if (key.status === "dirty") - status.dirty(); - if (value.status === "dirty") - status.dirty(); - if (key.value !== "__proto__" && (typeof value.value !== "undefined" || pair.alwaysSet)) { + if (key.status === 'aborted') return INVALID; + if (value.status === 'aborted') return INVALID; + if (key.status === 'dirty') status.dirty(); + if (value.status === 'dirty') status.dirty(); + if ( + key.value !== '__proto__' && + (typeof value.value !== 'undefined' || pair.alwaysSet) + ) { finalObject[key.value] = value.value; } } @@ -732,20 +817,22 @@ var ParseStatus = class _ParseStatus { } }; var INVALID = Object.freeze({ - status: "aborted" + status: 'aborted', }); -var DIRTY = (value) => ({ status: "dirty", value }); -var OK = (value) => ({ status: "valid", value }); -var isAborted = (x2) => x2.status === "aborted"; -var isDirty = (x2) => x2.status === "dirty"; -var isValid = (x2) => x2.status === "valid"; -var isAsync = (x2) => typeof Promise !== "undefined" && x2 instanceof Promise; +var DIRTY = (value) => ({ status: 'dirty', value }); +var OK = (value) => ({ status: 'valid', value }); +var isAborted = (x2) => x2.status === 'aborted'; +var isDirty = (x2) => x2.status === 'dirty'; +var isValid = (x2) => x2.status === 'valid'; +var isAsync = (x2) => typeof Promise !== 'undefined' && x2 instanceof Promise; // ../../node_modules/zod/dist/esm/v3/helpers/errorUtil.js var errorUtil; -(function(errorUtil2) { - errorUtil2.errToObj = (message) => typeof message === "string" ? { message } : message || {}; - errorUtil2.toString = (message) => typeof message === "string" ? message : message?.message; +(function (errorUtil2) { + errorUtil2.errToObj = (message) => + typeof message === 'string' ? { message } : message || {}; + errorUtil2.toString = (message) => + typeof message === 'string' ? message : message?.message; })(errorUtil || (errorUtil = {})); // ../../node_modules/zod/dist/esm/v3/types.js @@ -773,39 +860,42 @@ var handleResult = (ctx, result) => { return { success: true, data: result.value }; } else { if (!ctx.common.issues.length) { - throw new Error("Validation failed but no issues detected."); + throw new Error('Validation failed but no issues detected.'); } return { success: false, get error() { - if (this._error) - return this._error; + if (this._error) return this._error; const error = new ZodError(ctx.common.issues); this._error = error; return this._error; - } + }, }; } }; function processCreateParams(params) { - if (!params) - return {}; - const { errorMap: errorMap2, invalid_type_error, required_error, description } = params; + if (!params) return {}; + const { + errorMap: errorMap2, + invalid_type_error, + required_error, + description, + } = params; if (errorMap2 && (invalid_type_error || required_error)) { - throw new Error(`Can't use "invalid_type_error" or "required_error" in conjunction with custom error map.`); + throw new Error( + `Can't use "invalid_type_error" or "required_error" in conjunction with custom error map.` + ); } - if (errorMap2) - return { errorMap: errorMap2, description }; + if (errorMap2) return { errorMap: errorMap2, description }; const customMap = (iss, ctx) => { const { message } = params; - if (iss.code === "invalid_enum_value") { + if (iss.code === 'invalid_enum_value') { return { message: message ?? ctx.defaultError }; } - if (typeof ctx.data === "undefined") { + if (typeof ctx.data === 'undefined') { return { message: message ?? required_error ?? ctx.defaultError }; } - if (iss.code !== "invalid_type") - return { message: ctx.defaultError }; + if (iss.code !== 'invalid_type') return { message: ctx.defaultError }; return { message: message ?? invalid_type_error ?? ctx.defaultError }; }; return { errorMap: customMap, description }; @@ -818,14 +908,16 @@ var ZodType = class { return getParsedType(input.data); } _getOrReturnCtx(input, ctx) { - return ctx || { - common: input.parent.common, - data: input.data, - parsedType: getParsedType(input.data), - schemaErrorMap: this._def.errorMap, - path: input.path, - parent: input.parent - }; + return ( + ctx || { + common: input.parent.common, + data: input.data, + parsedType: getParsedType(input.data), + schemaErrorMap: this._def.errorMap, + path: input.path, + parent: input.parent, + } + ); } _processInputParams(input) { return { @@ -836,14 +928,14 @@ var ZodType = class { parsedType: getParsedType(input.data), schemaErrorMap: this._def.errorMap, path: input.path, - parent: input.parent - } + parent: input.parent, + }, }; } _parseSync(input) { const result = this._parse(input); if (isAsync(result)) { - throw new Error("Synchronous parse encountered promise."); + throw new Error('Synchronous parse encountered promise.'); } return result; } @@ -853,8 +945,7 @@ var ZodType = class { } parse(data, params) { const result = this.safeParse(data, params); - if (result.success) - return result.data; + if (result.success) return result.data; throw result.error; } safeParse(data, params) { @@ -862,57 +953,62 @@ var ZodType = class { common: { issues: [], async: params?.async ?? false, - contextualErrorMap: params?.errorMap + contextualErrorMap: params?.errorMap, }, path: params?.path || [], schemaErrorMap: this._def.errorMap, parent: null, data, - parsedType: getParsedType(data) + parsedType: getParsedType(data), }; const result = this._parseSync({ data, path: ctx.path, parent: ctx }); return handleResult(ctx, result); } - "~validate"(data) { + '~validate'(data) { const ctx = { common: { issues: [], - async: !!this["~standard"].async + async: !!this['~standard'].async, }, path: [], schemaErrorMap: this._def.errorMap, parent: null, data, - parsedType: getParsedType(data) + parsedType: getParsedType(data), }; - if (!this["~standard"].async) { + if (!this['~standard'].async) { try { const result = this._parseSync({ data, path: [], parent: ctx }); - return isValid(result) ? { - value: result.value - } : { - issues: ctx.common.issues - }; + return isValid(result) + ? { + value: result.value, + } + : { + issues: ctx.common.issues, + }; } catch (err) { - if (err?.message?.toLowerCase()?.includes("encountered")) { - this["~standard"].async = true; + if (err?.message?.toLowerCase()?.includes('encountered')) { + this['~standard'].async = true; } ctx.common = { issues: [], - async: true + async: true, }; } } - return this._parseAsync({ data, path: [], parent: ctx }).then((result) => isValid(result) ? { - value: result.value - } : { - issues: ctx.common.issues - }); + return this._parseAsync({ data, path: [], parent: ctx }).then((result) => + isValid(result) + ? { + value: result.value, + } + : { + issues: ctx.common.issues, + } + ); } async parseAsync(data, params) { const result = await this.safeParseAsync(data, params); - if (result.success) - return result.data; + if (result.success) return result.data; throw result.error; } async safeParseAsync(data, params) { @@ -920,23 +1016,25 @@ var ZodType = class { common: { issues: [], contextualErrorMap: params?.errorMap, - async: true + async: true, }, path: params?.path || [], schemaErrorMap: this._def.errorMap, parent: null, data, - parsedType: getParsedType(data) + parsedType: getParsedType(data), }; const maybeAsyncResult = this._parse({ data, path: ctx.path, parent: ctx }); - const result = await (isAsync(maybeAsyncResult) ? maybeAsyncResult : Promise.resolve(maybeAsyncResult)); + const result = await (isAsync(maybeAsyncResult) + ? maybeAsyncResult + : Promise.resolve(maybeAsyncResult)); return handleResult(ctx, result); } refine(check, message) { const getIssueProperties = (val) => { - if (typeof message === "string" || typeof message === "undefined") { + if (typeof message === 'string' || typeof message === 'undefined') { return { message }; - } else if (typeof message === "function") { + } else if (typeof message === 'function') { return message(val); } else { return message; @@ -944,11 +1042,12 @@ var ZodType = class { }; return this._refinement((val, ctx) => { const result = check(val); - const setError = () => ctx.addIssue({ - code: ZodIssueCode.custom, - ...getIssueProperties(val) - }); - if (typeof Promise !== "undefined" && result instanceof Promise) { + const setError = () => + ctx.addIssue({ + code: ZodIssueCode.custom, + ...getIssueProperties(val), + }); + if (typeof Promise !== 'undefined' && result instanceof Promise) { return result.then((data) => { if (!data) { setError(); @@ -969,7 +1068,11 @@ var ZodType = class { refinement(check, refinementData) { return this._refinement((val, ctx) => { if (!check(val)) { - ctx.addIssue(typeof refinementData === "function" ? refinementData(val, ctx) : refinementData); + ctx.addIssue( + typeof refinementData === 'function' + ? refinementData(val, ctx) + : refinementData + ); return false; } else { return true; @@ -980,7 +1083,7 @@ var ZodType = class { return new ZodEffects({ schema: this, typeName: ZodFirstPartyTypeKind.ZodEffects, - effect: { type: "refinement", refinement } + effect: { type: 'refinement', refinement }, }); } superRefine(refinement) { @@ -1013,10 +1116,10 @@ var ZodType = class { this.readonly = this.readonly.bind(this); this.isNullable = this.isNullable.bind(this); this.isOptional = this.isOptional.bind(this); - this["~standard"] = { + this['~standard'] = { version: 1, - vendor: "zod", - validate: (data) => this["~validate"](data) + vendor: 'zod', + validate: (data) => this['~validate'](data), }; } optional() { @@ -1045,39 +1148,39 @@ var ZodType = class { ...processCreateParams(this._def), schema: this, typeName: ZodFirstPartyTypeKind.ZodEffects, - effect: { type: "transform", transform } + effect: { type: 'transform', transform }, }); } default(def) { - const defaultValueFunc = typeof def === "function" ? def : () => def; + const defaultValueFunc = typeof def === 'function' ? def : () => def; return new ZodDefault({ ...processCreateParams(this._def), innerType: this, defaultValue: defaultValueFunc, - typeName: ZodFirstPartyTypeKind.ZodDefault + typeName: ZodFirstPartyTypeKind.ZodDefault, }); } brand() { return new ZodBranded({ typeName: ZodFirstPartyTypeKind.ZodBranded, type: this, - ...processCreateParams(this._def) + ...processCreateParams(this._def), }); } catch(def) { - const catchValueFunc = typeof def === "function" ? def : () => def; + const catchValueFunc = typeof def === 'function' ? def : () => def; return new ZodCatch({ ...processCreateParams(this._def), innerType: this, catchValue: catchValueFunc, - typeName: ZodFirstPartyTypeKind.ZodCatch + typeName: ZodFirstPartyTypeKind.ZodCatch, }); } describe(description) { const This = this.constructor; return new This({ ...this._def, - description + description, }); } pipe(target) { @@ -1096,19 +1199,28 @@ var ZodType = class { var cuidRegex = /^c[^\s-]{8,}$/i; var cuid2Regex = /^[0-9a-z]+$/; var ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/i; -var uuidRegex = /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/i; +var uuidRegex = + /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/i; var nanoidRegex = /^[a-z0-9_-]{21}$/i; var jwtRegex = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/; -var durationRegex = /^[-+]?P(?!$)(?:(?:[-+]?\d+Y)|(?:[-+]?\d+[.,]\d+Y$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:(?:[-+]?\d+W)|(?:[-+]?\d+[.,]\d+W$))?(?:(?:[-+]?\d+D)|(?:[-+]?\d+[.,]\d+D$))?(?:T(?=[\d+-])(?:(?:[-+]?\d+H)|(?:[-+]?\d+[.,]\d+H$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:[-+]?\d+(?:[.,]\d+)?S)?)??$/; -var emailRegex = /^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i; +var durationRegex = + /^[-+]?P(?!$)(?:(?:[-+]?\d+Y)|(?:[-+]?\d+[.,]\d+Y$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:(?:[-+]?\d+W)|(?:[-+]?\d+[.,]\d+W$))?(?:(?:[-+]?\d+D)|(?:[-+]?\d+[.,]\d+D$))?(?:T(?=[\d+-])(?:(?:[-+]?\d+H)|(?:[-+]?\d+[.,]\d+H$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:[-+]?\d+(?:[.,]\d+)?S)?)??$/; +var emailRegex = + /^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i; var _emojiRegex = `^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$`; var emojiRegex; -var ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/; -var ipv4CidrRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/(3[0-2]|[12]?[0-9])$/; -var ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/; -var ipv6CidrRegex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/; -var base64Regex = /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/; -var base64urlRegex = /^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$/; +var ipv4Regex = + /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/; +var ipv4CidrRegex = + /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/(3[0-2]|[12]?[0-9])$/; +var ipv6Regex = + /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/; +var ipv6CidrRegex = + /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/; +var base64Regex = + /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/; +var base64urlRegex = + /^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$/; var dateRegexSource = `((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))`; var dateRegex = new RegExp(`^${dateRegexSource}$`); function timeRegexSource(args) { @@ -1118,7 +1230,7 @@ function timeRegexSource(args) { } else if (args.precision == null) { secondsRegexSource = `${secondsRegexSource}(\\.\\d+)?`; } - const secondsQuantifier = args.precision ? "+" : "?"; + const secondsQuantifier = args.precision ? '+' : '?'; return `([01]\\d|2[0-3]):[0-5]\\d(:${secondsRegexSource})${secondsQuantifier}`; } function timeRegex(args) { @@ -1128,45 +1240,42 @@ function datetimeRegex(args) { let regex = `${dateRegexSource}T${timeRegexSource(args)}`; const opts = []; opts.push(args.local ? `Z?` : `Z`); - if (args.offset) - opts.push(`([+-]\\d{2}:?\\d{2})`); - regex = `${regex}(${opts.join("|")})`; + if (args.offset) opts.push(`([+-]\\d{2}:?\\d{2})`); + regex = `${regex}(${opts.join('|')})`; return new RegExp(`^${regex}$`); } function isValidIP(ip, version) { - if ((version === "v4" || !version) && ipv4Regex.test(ip)) { + if ((version === 'v4' || !version) && ipv4Regex.test(ip)) { return true; } - if ((version === "v6" || !version) && ipv6Regex.test(ip)) { + if ((version === 'v6' || !version) && ipv6Regex.test(ip)) { return true; } return false; } function isValidJWT(jwt, alg) { - if (!jwtRegex.test(jwt)) - return false; + if (!jwtRegex.test(jwt)) return false; try { - const [header] = jwt.split("."); - const base64 = header.replace(/-/g, "+").replace(/_/g, "/").padEnd(header.length + (4 - header.length % 4) % 4, "="); + const [header] = jwt.split('.'); + const base64 = header + .replace(/-/g, '+') + .replace(/_/g, '/') + .padEnd(header.length + ((4 - (header.length % 4)) % 4), '='); const decoded = JSON.parse(atob(base64)); - if (typeof decoded !== "object" || decoded === null) - return false; - if ("typ" in decoded && decoded?.typ !== "JWT") - return false; - if (!decoded.alg) - return false; - if (alg && decoded.alg !== alg) - return false; + if (typeof decoded !== 'object' || decoded === null) return false; + if ('typ' in decoded && decoded?.typ !== 'JWT') return false; + if (!decoded.alg) return false; + if (alg && decoded.alg !== alg) return false; return true; } catch { return false; } } function isValidCidr(ip, version) { - if ((version === "v4" || !version) && ipv4CidrRegex.test(ip)) { + if ((version === 'v4' || !version) && ipv4CidrRegex.test(ip)) { return true; } - if ((version === "v6" || !version) && ipv6CidrRegex.test(ip)) { + if ((version === 'v6' || !version) && ipv6CidrRegex.test(ip)) { return true; } return false; @@ -1182,40 +1291,40 @@ var ZodString = class _ZodString extends ZodType { addIssueToContext(ctx2, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.string, - received: ctx2.parsedType + received: ctx2.parsedType, }); return INVALID; } const status = new ParseStatus(); let ctx = void 0; for (const check of this._def.checks) { - if (check.kind === "min") { + if (check.kind === 'min') { if (input.data.length < check.value) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.too_small, minimum: check.value, - type: "string", + type: 'string', inclusive: true, exact: false, - message: check.message + message: check.message, }); status.dirty(); } - } else if (check.kind === "max") { + } else if (check.kind === 'max') { if (input.data.length > check.value) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.too_big, maximum: check.value, - type: "string", + type: 'string', inclusive: true, exact: false, - message: check.message + message: check.message, }); status.dirty(); } - } else if (check.kind === "length") { + } else if (check.kind === 'length') { const tooBig = input.data.length > check.value; const tooSmall = input.data.length < check.value; if (tooBig || tooSmall) { @@ -1224,246 +1333,246 @@ var ZodString = class _ZodString extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.too_big, maximum: check.value, - type: "string", + type: 'string', inclusive: true, exact: true, - message: check.message + message: check.message, }); } else if (tooSmall) { addIssueToContext(ctx, { code: ZodIssueCode.too_small, minimum: check.value, - type: "string", + type: 'string', inclusive: true, exact: true, - message: check.message + message: check.message, }); } status.dirty(); } - } else if (check.kind === "email") { + } else if (check.kind === 'email') { if (!emailRegex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: "email", + validation: 'email', code: ZodIssueCode.invalid_string, - message: check.message + message: check.message, }); status.dirty(); } - } else if (check.kind === "emoji") { + } else if (check.kind === 'emoji') { if (!emojiRegex) { - emojiRegex = new RegExp(_emojiRegex, "u"); + emojiRegex = new RegExp(_emojiRegex, 'u'); } if (!emojiRegex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: "emoji", + validation: 'emoji', code: ZodIssueCode.invalid_string, - message: check.message + message: check.message, }); status.dirty(); } - } else if (check.kind === "uuid") { + } else if (check.kind === 'uuid') { if (!uuidRegex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: "uuid", + validation: 'uuid', code: ZodIssueCode.invalid_string, - message: check.message + message: check.message, }); status.dirty(); } - } else if (check.kind === "nanoid") { + } else if (check.kind === 'nanoid') { if (!nanoidRegex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: "nanoid", + validation: 'nanoid', code: ZodIssueCode.invalid_string, - message: check.message + message: check.message, }); status.dirty(); } - } else if (check.kind === "cuid") { + } else if (check.kind === 'cuid') { if (!cuidRegex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: "cuid", + validation: 'cuid', code: ZodIssueCode.invalid_string, - message: check.message + message: check.message, }); status.dirty(); } - } else if (check.kind === "cuid2") { + } else if (check.kind === 'cuid2') { if (!cuid2Regex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: "cuid2", + validation: 'cuid2', code: ZodIssueCode.invalid_string, - message: check.message + message: check.message, }); status.dirty(); } - } else if (check.kind === "ulid") { + } else if (check.kind === 'ulid') { if (!ulidRegex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: "ulid", + validation: 'ulid', code: ZodIssueCode.invalid_string, - message: check.message + message: check.message, }); status.dirty(); } - } else if (check.kind === "url") { + } else if (check.kind === 'url') { try { new URL(input.data); } catch { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: "url", + validation: 'url', code: ZodIssueCode.invalid_string, - message: check.message + message: check.message, }); status.dirty(); } - } else if (check.kind === "regex") { + } else if (check.kind === 'regex') { check.regex.lastIndex = 0; const testResult = check.regex.test(input.data); if (!testResult) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: "regex", + validation: 'regex', code: ZodIssueCode.invalid_string, - message: check.message + message: check.message, }); status.dirty(); } - } else if (check.kind === "trim") { + } else if (check.kind === 'trim') { input.data = input.data.trim(); - } else if (check.kind === "includes") { + } else if (check.kind === 'includes') { if (!input.data.includes(check.value, check.position)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.invalid_string, validation: { includes: check.value, position: check.position }, - message: check.message + message: check.message, }); status.dirty(); } - } else if (check.kind === "toLowerCase") { + } else if (check.kind === 'toLowerCase') { input.data = input.data.toLowerCase(); - } else if (check.kind === "toUpperCase") { + } else if (check.kind === 'toUpperCase') { input.data = input.data.toUpperCase(); - } else if (check.kind === "startsWith") { + } else if (check.kind === 'startsWith') { if (!input.data.startsWith(check.value)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.invalid_string, validation: { startsWith: check.value }, - message: check.message + message: check.message, }); status.dirty(); } - } else if (check.kind === "endsWith") { + } else if (check.kind === 'endsWith') { if (!input.data.endsWith(check.value)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.invalid_string, validation: { endsWith: check.value }, - message: check.message + message: check.message, }); status.dirty(); } - } else if (check.kind === "datetime") { + } else if (check.kind === 'datetime') { const regex = datetimeRegex(check); if (!regex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.invalid_string, - validation: "datetime", - message: check.message + validation: 'datetime', + message: check.message, }); status.dirty(); } - } else if (check.kind === "date") { + } else if (check.kind === 'date') { const regex = dateRegex; if (!regex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.invalid_string, - validation: "date", - message: check.message + validation: 'date', + message: check.message, }); status.dirty(); } - } else if (check.kind === "time") { + } else if (check.kind === 'time') { const regex = timeRegex(check); if (!regex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.invalid_string, - validation: "time", - message: check.message + validation: 'time', + message: check.message, }); status.dirty(); } - } else if (check.kind === "duration") { + } else if (check.kind === 'duration') { if (!durationRegex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: "duration", + validation: 'duration', code: ZodIssueCode.invalid_string, - message: check.message + message: check.message, }); status.dirty(); } - } else if (check.kind === "ip") { + } else if (check.kind === 'ip') { if (!isValidIP(input.data, check.version)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: "ip", + validation: 'ip', code: ZodIssueCode.invalid_string, - message: check.message + message: check.message, }); status.dirty(); } - } else if (check.kind === "jwt") { + } else if (check.kind === 'jwt') { if (!isValidJWT(input.data, check.alg)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: "jwt", + validation: 'jwt', code: ZodIssueCode.invalid_string, - message: check.message + message: check.message, }); status.dirty(); } - } else if (check.kind === "cidr") { + } else if (check.kind === 'cidr') { if (!isValidCidr(input.data, check.version)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: "cidr", + validation: 'cidr', code: ZodIssueCode.invalid_string, - message: check.message + message: check.message, }); status.dirty(); } - } else if (check.kind === "base64") { + } else if (check.kind === 'base64') { if (!base64Regex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: "base64", + validation: 'base64', code: ZodIssueCode.invalid_string, - message: check.message + message: check.message, }); status.dirty(); } - } else if (check.kind === "base64url") { + } else if (check.kind === 'base64url') { if (!base64urlRegex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: "base64url", + validation: 'base64url', code: ZodIssueCode.invalid_string, - message: check.message + message: check.message, }); status.dirty(); } @@ -1477,143 +1586,145 @@ var ZodString = class _ZodString extends ZodType { return this.refinement((data) => regex.test(data), { validation, code: ZodIssueCode.invalid_string, - ...errorUtil.errToObj(message) + ...errorUtil.errToObj(message), }); } _addCheck(check) { return new _ZodString({ ...this._def, - checks: [...this._def.checks, check] + checks: [...this._def.checks, check], }); } email(message) { - return this._addCheck({ kind: "email", ...errorUtil.errToObj(message) }); + return this._addCheck({ kind: 'email', ...errorUtil.errToObj(message) }); } url(message) { - return this._addCheck({ kind: "url", ...errorUtil.errToObj(message) }); + return this._addCheck({ kind: 'url', ...errorUtil.errToObj(message) }); } emoji(message) { - return this._addCheck({ kind: "emoji", ...errorUtil.errToObj(message) }); + return this._addCheck({ kind: 'emoji', ...errorUtil.errToObj(message) }); } uuid(message) { - return this._addCheck({ kind: "uuid", ...errorUtil.errToObj(message) }); + return this._addCheck({ kind: 'uuid', ...errorUtil.errToObj(message) }); } nanoid(message) { - return this._addCheck({ kind: "nanoid", ...errorUtil.errToObj(message) }); + return this._addCheck({ kind: 'nanoid', ...errorUtil.errToObj(message) }); } cuid(message) { - return this._addCheck({ kind: "cuid", ...errorUtil.errToObj(message) }); + return this._addCheck({ kind: 'cuid', ...errorUtil.errToObj(message) }); } cuid2(message) { - return this._addCheck({ kind: "cuid2", ...errorUtil.errToObj(message) }); + return this._addCheck({ kind: 'cuid2', ...errorUtil.errToObj(message) }); } ulid(message) { - return this._addCheck({ kind: "ulid", ...errorUtil.errToObj(message) }); + return this._addCheck({ kind: 'ulid', ...errorUtil.errToObj(message) }); } base64(message) { - return this._addCheck({ kind: "base64", ...errorUtil.errToObj(message) }); + return this._addCheck({ kind: 'base64', ...errorUtil.errToObj(message) }); } base64url(message) { return this._addCheck({ - kind: "base64url", - ...errorUtil.errToObj(message) + kind: 'base64url', + ...errorUtil.errToObj(message), }); } jwt(options) { - return this._addCheck({ kind: "jwt", ...errorUtil.errToObj(options) }); + return this._addCheck({ kind: 'jwt', ...errorUtil.errToObj(options) }); } ip(options) { - return this._addCheck({ kind: "ip", ...errorUtil.errToObj(options) }); + return this._addCheck({ kind: 'ip', ...errorUtil.errToObj(options) }); } cidr(options) { - return this._addCheck({ kind: "cidr", ...errorUtil.errToObj(options) }); + return this._addCheck({ kind: 'cidr', ...errorUtil.errToObj(options) }); } datetime(options) { - if (typeof options === "string") { + if (typeof options === 'string') { return this._addCheck({ - kind: "datetime", + kind: 'datetime', precision: null, offset: false, local: false, - message: options + message: options, }); } return this._addCheck({ - kind: "datetime", - precision: typeof options?.precision === "undefined" ? null : options?.precision, + kind: 'datetime', + precision: + typeof options?.precision === 'undefined' ? null : options?.precision, offset: options?.offset ?? false, local: options?.local ?? false, - ...errorUtil.errToObj(options?.message) + ...errorUtil.errToObj(options?.message), }); } date(message) { - return this._addCheck({ kind: "date", message }); + return this._addCheck({ kind: 'date', message }); } time(options) { - if (typeof options === "string") { + if (typeof options === 'string') { return this._addCheck({ - kind: "time", + kind: 'time', precision: null, - message: options + message: options, }); } return this._addCheck({ - kind: "time", - precision: typeof options?.precision === "undefined" ? null : options?.precision, - ...errorUtil.errToObj(options?.message) + kind: 'time', + precision: + typeof options?.precision === 'undefined' ? null : options?.precision, + ...errorUtil.errToObj(options?.message), }); } duration(message) { - return this._addCheck({ kind: "duration", ...errorUtil.errToObj(message) }); + return this._addCheck({ kind: 'duration', ...errorUtil.errToObj(message) }); } regex(regex, message) { return this._addCheck({ - kind: "regex", + kind: 'regex', regex, - ...errorUtil.errToObj(message) + ...errorUtil.errToObj(message), }); } includes(value, options) { return this._addCheck({ - kind: "includes", + kind: 'includes', value, position: options?.position, - ...errorUtil.errToObj(options?.message) + ...errorUtil.errToObj(options?.message), }); } startsWith(value, message) { return this._addCheck({ - kind: "startsWith", + kind: 'startsWith', value, - ...errorUtil.errToObj(message) + ...errorUtil.errToObj(message), }); } endsWith(value, message) { return this._addCheck({ - kind: "endsWith", + kind: 'endsWith', value, - ...errorUtil.errToObj(message) + ...errorUtil.errToObj(message), }); } min(minLength, message) { return this._addCheck({ - kind: "min", + kind: 'min', value: minLength, - ...errorUtil.errToObj(message) + ...errorUtil.errToObj(message), }); } max(maxLength, message) { return this._addCheck({ - kind: "max", + kind: 'max', value: maxLength, - ...errorUtil.errToObj(message) + ...errorUtil.errToObj(message), }); } length(len, message) { return this._addCheck({ - kind: "length", + kind: 'length', value: len, - ...errorUtil.errToObj(message) + ...errorUtil.errToObj(message), }); } /** @@ -1625,75 +1736,74 @@ var ZodString = class _ZodString extends ZodType { trim() { return new _ZodString({ ...this._def, - checks: [...this._def.checks, { kind: "trim" }] + checks: [...this._def.checks, { kind: 'trim' }], }); } toLowerCase() { return new _ZodString({ ...this._def, - checks: [...this._def.checks, { kind: "toLowerCase" }] + checks: [...this._def.checks, { kind: 'toLowerCase' }], }); } toUpperCase() { return new _ZodString({ ...this._def, - checks: [...this._def.checks, { kind: "toUpperCase" }] + checks: [...this._def.checks, { kind: 'toUpperCase' }], }); } get isDatetime() { - return !!this._def.checks.find((ch) => ch.kind === "datetime"); + return !!this._def.checks.find((ch) => ch.kind === 'datetime'); } get isDate() { - return !!this._def.checks.find((ch) => ch.kind === "date"); + return !!this._def.checks.find((ch) => ch.kind === 'date'); } get isTime() { - return !!this._def.checks.find((ch) => ch.kind === "time"); + return !!this._def.checks.find((ch) => ch.kind === 'time'); } get isDuration() { - return !!this._def.checks.find((ch) => ch.kind === "duration"); + return !!this._def.checks.find((ch) => ch.kind === 'duration'); } get isEmail() { - return !!this._def.checks.find((ch) => ch.kind === "email"); + return !!this._def.checks.find((ch) => ch.kind === 'email'); } get isURL() { - return !!this._def.checks.find((ch) => ch.kind === "url"); + return !!this._def.checks.find((ch) => ch.kind === 'url'); } get isEmoji() { - return !!this._def.checks.find((ch) => ch.kind === "emoji"); + return !!this._def.checks.find((ch) => ch.kind === 'emoji'); } get isUUID() { - return !!this._def.checks.find((ch) => ch.kind === "uuid"); + return !!this._def.checks.find((ch) => ch.kind === 'uuid'); } get isNANOID() { - return !!this._def.checks.find((ch) => ch.kind === "nanoid"); + return !!this._def.checks.find((ch) => ch.kind === 'nanoid'); } get isCUID() { - return !!this._def.checks.find((ch) => ch.kind === "cuid"); + return !!this._def.checks.find((ch) => ch.kind === 'cuid'); } get isCUID2() { - return !!this._def.checks.find((ch) => ch.kind === "cuid2"); + return !!this._def.checks.find((ch) => ch.kind === 'cuid2'); } get isULID() { - return !!this._def.checks.find((ch) => ch.kind === "ulid"); + return !!this._def.checks.find((ch) => ch.kind === 'ulid'); } get isIP() { - return !!this._def.checks.find((ch) => ch.kind === "ip"); + return !!this._def.checks.find((ch) => ch.kind === 'ip'); } get isCIDR() { - return !!this._def.checks.find((ch) => ch.kind === "cidr"); + return !!this._def.checks.find((ch) => ch.kind === 'cidr'); } get isBase64() { - return !!this._def.checks.find((ch) => ch.kind === "base64"); + return !!this._def.checks.find((ch) => ch.kind === 'base64'); } get isBase64url() { - return !!this._def.checks.find((ch) => ch.kind === "base64url"); + return !!this._def.checks.find((ch) => ch.kind === 'base64url'); } get minLength() { let min = null; for (const ch of this._def.checks) { - if (ch.kind === "min") { - if (min === null || ch.value > min) - min = ch.value; + if (ch.kind === 'min') { + if (min === null || ch.value > min) min = ch.value; } } return min; @@ -1701,9 +1811,8 @@ var ZodString = class _ZodString extends ZodType { get maxLength() { let max = null; for (const ch of this._def.checks) { - if (ch.kind === "max") { - if (max === null || ch.value < max) - max = ch.value; + if (ch.kind === 'max') { + if (max === null || ch.value < max) max = ch.value; } } return max; @@ -1714,16 +1823,16 @@ ZodString.create = (params) => { checks: [], typeName: ZodFirstPartyTypeKind.ZodString, coerce: params?.coerce ?? false, - ...processCreateParams(params) + ...processCreateParams(params), }); }; function floatSafeRemainder(val, step) { - const valDecCount = (val.toString().split(".")[1] || "").length; - const stepDecCount = (step.toString().split(".")[1] || "").length; + const valDecCount = (val.toString().split('.')[1] || '').length; + const stepDecCount = (step.toString().split('.')[1] || '').length; const decCount = valDecCount > stepDecCount ? valDecCount : stepDecCount; - const valInt = Number.parseInt(val.toFixed(decCount).replace(".", "")); - const stepInt = Number.parseInt(step.toFixed(decCount).replace(".", "")); - return valInt % stepInt / 10 ** decCount; + const valInt = Number.parseInt(val.toFixed(decCount).replace('.', '')); + const stepInt = Number.parseInt(step.toFixed(decCount).replace('.', '')); + return (valInt % stepInt) / 10 ** decCount; } var ZodNumber = class _ZodNumber extends ZodType { constructor() { @@ -1742,68 +1851,72 @@ var ZodNumber = class _ZodNumber extends ZodType { addIssueToContext(ctx2, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.number, - received: ctx2.parsedType + received: ctx2.parsedType, }); return INVALID; } let ctx = void 0; const status = new ParseStatus(); for (const check of this._def.checks) { - if (check.kind === "int") { + if (check.kind === 'int') { if (!util.isInteger(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, - expected: "integer", - received: "float", - message: check.message + expected: 'integer', + received: 'float', + message: check.message, }); status.dirty(); } - } else if (check.kind === "min") { - const tooSmall = check.inclusive ? input.data < check.value : input.data <= check.value; + } else if (check.kind === 'min') { + const tooSmall = check.inclusive + ? input.data < check.value + : input.data <= check.value; if (tooSmall) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.too_small, minimum: check.value, - type: "number", + type: 'number', inclusive: check.inclusive, exact: false, - message: check.message + message: check.message, }); status.dirty(); } - } else if (check.kind === "max") { - const tooBig = check.inclusive ? input.data > check.value : input.data >= check.value; + } else if (check.kind === 'max') { + const tooBig = check.inclusive + ? input.data > check.value + : input.data >= check.value; if (tooBig) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.too_big, maximum: check.value, - type: "number", + type: 'number', inclusive: check.inclusive, exact: false, - message: check.message + message: check.message, }); status.dirty(); } - } else if (check.kind === "multipleOf") { + } else if (check.kind === 'multipleOf') { if (floatSafeRemainder(input.data, check.value) !== 0) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.not_multiple_of, multipleOf: check.value, - message: check.message + message: check.message, }); status.dirty(); } - } else if (check.kind === "finite") { + } else if (check.kind === 'finite') { if (!Number.isFinite(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.not_finite, - message: check.message + message: check.message, }); status.dirty(); } @@ -1814,16 +1927,16 @@ var ZodNumber = class _ZodNumber extends ZodType { return { status: status.value, value: input.data }; } gte(value, message) { - return this.setLimit("min", value, true, errorUtil.toString(message)); + return this.setLimit('min', value, true, errorUtil.toString(message)); } gt(value, message) { - return this.setLimit("min", value, false, errorUtil.toString(message)); + return this.setLimit('min', value, false, errorUtil.toString(message)); } lte(value, message) { - return this.setLimit("max", value, true, errorUtil.toString(message)); + return this.setLimit('max', value, true, errorUtil.toString(message)); } lt(value, message) { - return this.setLimit("max", value, false, errorUtil.toString(message)); + return this.setLimit('max', value, false, errorUtil.toString(message)); } setLimit(kind, value, inclusive, message) { return new _ZodNumber({ @@ -1834,87 +1947,86 @@ var ZodNumber = class _ZodNumber extends ZodType { kind, value, inclusive, - message: errorUtil.toString(message) - } - ] + message: errorUtil.toString(message), + }, + ], }); } _addCheck(check) { return new _ZodNumber({ ...this._def, - checks: [...this._def.checks, check] + checks: [...this._def.checks, check], }); } int(message) { return this._addCheck({ - kind: "int", - message: errorUtil.toString(message) + kind: 'int', + message: errorUtil.toString(message), }); } positive(message) { return this._addCheck({ - kind: "min", + kind: 'min', value: 0, inclusive: false, - message: errorUtil.toString(message) + message: errorUtil.toString(message), }); } negative(message) { return this._addCheck({ - kind: "max", + kind: 'max', value: 0, inclusive: false, - message: errorUtil.toString(message) + message: errorUtil.toString(message), }); } nonpositive(message) { return this._addCheck({ - kind: "max", + kind: 'max', value: 0, inclusive: true, - message: errorUtil.toString(message) + message: errorUtil.toString(message), }); } nonnegative(message) { return this._addCheck({ - kind: "min", + kind: 'min', value: 0, inclusive: true, - message: errorUtil.toString(message) + message: errorUtil.toString(message), }); } multipleOf(value, message) { return this._addCheck({ - kind: "multipleOf", + kind: 'multipleOf', value, - message: errorUtil.toString(message) + message: errorUtil.toString(message), }); } finite(message) { return this._addCheck({ - kind: "finite", - message: errorUtil.toString(message) + kind: 'finite', + message: errorUtil.toString(message), }); } safe(message) { return this._addCheck({ - kind: "min", + kind: 'min', inclusive: true, value: Number.MIN_SAFE_INTEGER, - message: errorUtil.toString(message) + message: errorUtil.toString(message), })._addCheck({ - kind: "max", + kind: 'max', inclusive: true, value: Number.MAX_SAFE_INTEGER, - message: errorUtil.toString(message) + message: errorUtil.toString(message), }); } get minValue() { let min = null; for (const ch of this._def.checks) { - if (ch.kind === "min") { - if (min === null || ch.value > min) - min = ch.value; + if (ch.kind === 'min') { + if (min === null || ch.value > min) min = ch.value; } } return min; @@ -1922,28 +2034,33 @@ var ZodNumber = class _ZodNumber extends ZodType { get maxValue() { let max = null; for (const ch of this._def.checks) { - if (ch.kind === "max") { - if (max === null || ch.value < max) - max = ch.value; + if (ch.kind === 'max') { + if (max === null || ch.value < max) max = ch.value; } } return max; } get isInt() { - return !!this._def.checks.find((ch) => ch.kind === "int" || ch.kind === "multipleOf" && util.isInteger(ch.value)); + return !!this._def.checks.find( + (ch) => + ch.kind === 'int' || + (ch.kind === 'multipleOf' && util.isInteger(ch.value)) + ); } get isFinite() { let max = null; let min = null; for (const ch of this._def.checks) { - if (ch.kind === "finite" || ch.kind === "int" || ch.kind === "multipleOf") { + if ( + ch.kind === 'finite' || + ch.kind === 'int' || + ch.kind === 'multipleOf' + ) { return true; - } else if (ch.kind === "min") { - if (min === null || ch.value > min) - min = ch.value; - } else if (ch.kind === "max") { - if (max === null || ch.value < max) - max = ch.value; + } else if (ch.kind === 'min') { + if (min === null || ch.value > min) min = ch.value; + } else if (ch.kind === 'max') { + if (max === null || ch.value < max) max = ch.value; } } return Number.isFinite(min) && Number.isFinite(max); @@ -1954,7 +2071,7 @@ ZodNumber.create = (params) => { checks: [], typeName: ZodFirstPartyTypeKind.ZodNumber, coerce: params?.coerce || false, - ...processCreateParams(params) + ...processCreateParams(params), }); }; var ZodBigInt = class _ZodBigInt extends ZodType { @@ -1978,39 +2095,43 @@ var ZodBigInt = class _ZodBigInt extends ZodType { let ctx = void 0; const status = new ParseStatus(); for (const check of this._def.checks) { - if (check.kind === "min") { - const tooSmall = check.inclusive ? input.data < check.value : input.data <= check.value; + if (check.kind === 'min') { + const tooSmall = check.inclusive + ? input.data < check.value + : input.data <= check.value; if (tooSmall) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.too_small, - type: "bigint", + type: 'bigint', minimum: check.value, inclusive: check.inclusive, - message: check.message + message: check.message, }); status.dirty(); } - } else if (check.kind === "max") { - const tooBig = check.inclusive ? input.data > check.value : input.data >= check.value; + } else if (check.kind === 'max') { + const tooBig = check.inclusive + ? input.data > check.value + : input.data >= check.value; if (tooBig) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.too_big, - type: "bigint", + type: 'bigint', maximum: check.value, inclusive: check.inclusive, - message: check.message + message: check.message, }); status.dirty(); } - } else if (check.kind === "multipleOf") { + } else if (check.kind === 'multipleOf') { if (input.data % check.value !== BigInt(0)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.not_multiple_of, multipleOf: check.value, - message: check.message + message: check.message, }); status.dirty(); } @@ -2025,21 +2146,21 @@ var ZodBigInt = class _ZodBigInt extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.bigint, - received: ctx.parsedType + received: ctx.parsedType, }); return INVALID; } gte(value, message) { - return this.setLimit("min", value, true, errorUtil.toString(message)); + return this.setLimit('min', value, true, errorUtil.toString(message)); } gt(value, message) { - return this.setLimit("min", value, false, errorUtil.toString(message)); + return this.setLimit('min', value, false, errorUtil.toString(message)); } lte(value, message) { - return this.setLimit("max", value, true, errorUtil.toString(message)); + return this.setLimit('max', value, true, errorUtil.toString(message)); } lt(value, message) { - return this.setLimit("max", value, false, errorUtil.toString(message)); + return this.setLimit('max', value, false, errorUtil.toString(message)); } setLimit(kind, value, inclusive, message) { return new _ZodBigInt({ @@ -2050,62 +2171,61 @@ var ZodBigInt = class _ZodBigInt extends ZodType { kind, value, inclusive, - message: errorUtil.toString(message) - } - ] + message: errorUtil.toString(message), + }, + ], }); } _addCheck(check) { return new _ZodBigInt({ ...this._def, - checks: [...this._def.checks, check] + checks: [...this._def.checks, check], }); } positive(message) { return this._addCheck({ - kind: "min", + kind: 'min', value: BigInt(0), inclusive: false, - message: errorUtil.toString(message) + message: errorUtil.toString(message), }); } negative(message) { return this._addCheck({ - kind: "max", + kind: 'max', value: BigInt(0), inclusive: false, - message: errorUtil.toString(message) + message: errorUtil.toString(message), }); } nonpositive(message) { return this._addCheck({ - kind: "max", + kind: 'max', value: BigInt(0), inclusive: true, - message: errorUtil.toString(message) + message: errorUtil.toString(message), }); } nonnegative(message) { return this._addCheck({ - kind: "min", + kind: 'min', value: BigInt(0), inclusive: true, - message: errorUtil.toString(message) + message: errorUtil.toString(message), }); } multipleOf(value, message) { return this._addCheck({ - kind: "multipleOf", + kind: 'multipleOf', value, - message: errorUtil.toString(message) + message: errorUtil.toString(message), }); } get minValue() { let min = null; for (const ch of this._def.checks) { - if (ch.kind === "min") { - if (min === null || ch.value > min) - min = ch.value; + if (ch.kind === 'min') { + if (min === null || ch.value > min) min = ch.value; } } return min; @@ -2113,9 +2233,8 @@ var ZodBigInt = class _ZodBigInt extends ZodType { get maxValue() { let max = null; for (const ch of this._def.checks) { - if (ch.kind === "max") { - if (max === null || ch.value < max) - max = ch.value; + if (ch.kind === 'max') { + if (max === null || ch.value < max) max = ch.value; } } return max; @@ -2126,7 +2245,7 @@ ZodBigInt.create = (params) => { checks: [], typeName: ZodFirstPartyTypeKind.ZodBigInt, coerce: params?.coerce ?? false, - ...processCreateParams(params) + ...processCreateParams(params), }); }; var ZodBoolean = class extends ZodType { @@ -2140,7 +2259,7 @@ var ZodBoolean = class extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.boolean, - received: ctx.parsedType + received: ctx.parsedType, }); return INVALID; } @@ -2151,7 +2270,7 @@ ZodBoolean.create = (params) => { return new ZodBoolean({ typeName: ZodFirstPartyTypeKind.ZodBoolean, coerce: params?.coerce || false, - ...processCreateParams(params) + ...processCreateParams(params), }); }; var ZodDate = class _ZodDate extends ZodType { @@ -2165,21 +2284,21 @@ var ZodDate = class _ZodDate extends ZodType { addIssueToContext(ctx2, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.date, - received: ctx2.parsedType + received: ctx2.parsedType, }); return INVALID; } if (Number.isNaN(input.data.getTime())) { const ctx2 = this._getOrReturnCtx(input); addIssueToContext(ctx2, { - code: ZodIssueCode.invalid_date + code: ZodIssueCode.invalid_date, }); return INVALID; } const status = new ParseStatus(); let ctx = void 0; for (const check of this._def.checks) { - if (check.kind === "min") { + if (check.kind === 'min') { if (input.data.getTime() < check.value) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { @@ -2188,11 +2307,11 @@ var ZodDate = class _ZodDate extends ZodType { inclusive: true, exact: false, minimum: check.value, - type: "date" + type: 'date', }); status.dirty(); } - } else if (check.kind === "max") { + } else if (check.kind === 'max') { if (input.data.getTime() > check.value) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { @@ -2201,7 +2320,7 @@ var ZodDate = class _ZodDate extends ZodType { inclusive: true, exact: false, maximum: check.value, - type: "date" + type: 'date', }); status.dirty(); } @@ -2211,35 +2330,34 @@ var ZodDate = class _ZodDate extends ZodType { } return { status: status.value, - value: new Date(input.data.getTime()) + value: new Date(input.data.getTime()), }; } _addCheck(check) { return new _ZodDate({ ...this._def, - checks: [...this._def.checks, check] + checks: [...this._def.checks, check], }); } min(minDate, message) { return this._addCheck({ - kind: "min", + kind: 'min', value: minDate.getTime(), - message: errorUtil.toString(message) + message: errorUtil.toString(message), }); } max(maxDate, message) { return this._addCheck({ - kind: "max", + kind: 'max', value: maxDate.getTime(), - message: errorUtil.toString(message) + message: errorUtil.toString(message), }); } get minDate() { let min = null; for (const ch of this._def.checks) { - if (ch.kind === "min") { - if (min === null || ch.value > min) - min = ch.value; + if (ch.kind === 'min') { + if (min === null || ch.value > min) min = ch.value; } } return min != null ? new Date(min) : null; @@ -2247,9 +2365,8 @@ var ZodDate = class _ZodDate extends ZodType { get maxDate() { let max = null; for (const ch of this._def.checks) { - if (ch.kind === "max") { - if (max === null || ch.value < max) - max = ch.value; + if (ch.kind === 'max') { + if (max === null || ch.value < max) max = ch.value; } } return max != null ? new Date(max) : null; @@ -2260,7 +2377,7 @@ ZodDate.create = (params) => { checks: [], coerce: params?.coerce || false, typeName: ZodFirstPartyTypeKind.ZodDate, - ...processCreateParams(params) + ...processCreateParams(params), }); }; var ZodSymbol = class extends ZodType { @@ -2271,7 +2388,7 @@ var ZodSymbol = class extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.symbol, - received: ctx.parsedType + received: ctx.parsedType, }); return INVALID; } @@ -2281,7 +2398,7 @@ var ZodSymbol = class extends ZodType { ZodSymbol.create = (params) => { return new ZodSymbol({ typeName: ZodFirstPartyTypeKind.ZodSymbol, - ...processCreateParams(params) + ...processCreateParams(params), }); }; var ZodUndefined = class extends ZodType { @@ -2292,7 +2409,7 @@ var ZodUndefined = class extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.undefined, - received: ctx.parsedType + received: ctx.parsedType, }); return INVALID; } @@ -2302,7 +2419,7 @@ var ZodUndefined = class extends ZodType { ZodUndefined.create = (params) => { return new ZodUndefined({ typeName: ZodFirstPartyTypeKind.ZodUndefined, - ...processCreateParams(params) + ...processCreateParams(params), }); }; var ZodNull = class extends ZodType { @@ -2313,7 +2430,7 @@ var ZodNull = class extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.null, - received: ctx.parsedType + received: ctx.parsedType, }); return INVALID; } @@ -2323,7 +2440,7 @@ var ZodNull = class extends ZodType { ZodNull.create = (params) => { return new ZodNull({ typeName: ZodFirstPartyTypeKind.ZodNull, - ...processCreateParams(params) + ...processCreateParams(params), }); }; var ZodAny = class extends ZodType { @@ -2338,7 +2455,7 @@ var ZodAny = class extends ZodType { ZodAny.create = (params) => { return new ZodAny({ typeName: ZodFirstPartyTypeKind.ZodAny, - ...processCreateParams(params) + ...processCreateParams(params), }); }; var ZodUnknown = class extends ZodType { @@ -2353,7 +2470,7 @@ var ZodUnknown = class extends ZodType { ZodUnknown.create = (params) => { return new ZodUnknown({ typeName: ZodFirstPartyTypeKind.ZodUnknown, - ...processCreateParams(params) + ...processCreateParams(params), }); }; var ZodNever = class extends ZodType { @@ -2362,7 +2479,7 @@ var ZodNever = class extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.never, - received: ctx.parsedType + received: ctx.parsedType, }); return INVALID; } @@ -2370,7 +2487,7 @@ var ZodNever = class extends ZodType { ZodNever.create = (params) => { return new ZodNever({ typeName: ZodFirstPartyTypeKind.ZodNever, - ...processCreateParams(params) + ...processCreateParams(params), }); }; var ZodVoid = class extends ZodType { @@ -2381,7 +2498,7 @@ var ZodVoid = class extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.void, - received: ctx.parsedType + received: ctx.parsedType, }); return INVALID; } @@ -2391,7 +2508,7 @@ var ZodVoid = class extends ZodType { ZodVoid.create = (params) => { return new ZodVoid({ typeName: ZodFirstPartyTypeKind.ZodVoid, - ...processCreateParams(params) + ...processCreateParams(params), }); }; var ZodArray = class _ZodArray extends ZodType { @@ -2402,7 +2519,7 @@ var ZodArray = class _ZodArray extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.array, - received: ctx.parsedType + received: ctx.parsedType, }); return INVALID; } @@ -2414,10 +2531,10 @@ var ZodArray = class _ZodArray extends ZodType { code: tooBig ? ZodIssueCode.too_big : ZodIssueCode.too_small, minimum: tooSmall ? def.exactLength.value : void 0, maximum: tooBig ? def.exactLength.value : void 0, - type: "array", + type: 'array', inclusive: true, exact: true, - message: def.exactLength.message + message: def.exactLength.message, }); status.dirty(); } @@ -2427,10 +2544,10 @@ var ZodArray = class _ZodArray extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.too_small, minimum: def.minLength.value, - type: "array", + type: 'array', inclusive: true, exact: false, - message: def.minLength.message + message: def.minLength.message, }); status.dirty(); } @@ -2440,23 +2557,29 @@ var ZodArray = class _ZodArray extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.too_big, maximum: def.maxLength.value, - type: "array", + type: 'array', inclusive: true, exact: false, - message: def.maxLength.message + message: def.maxLength.message, }); status.dirty(); } } if (ctx.common.async) { - return Promise.all([...ctx.data].map((item, i) => { - return def.type._parseAsync(new ParseInputLazyPath(ctx, item, ctx.path, i)); - })).then((result2) => { + return Promise.all( + [...ctx.data].map((item, i) => { + return def.type._parseAsync( + new ParseInputLazyPath(ctx, item, ctx.path, i) + ); + }) + ).then((result2) => { return ParseStatus.mergeArray(status, result2); }); } const result = [...ctx.data].map((item, i) => { - return def.type._parseSync(new ParseInputLazyPath(ctx, item, ctx.path, i)); + return def.type._parseSync( + new ParseInputLazyPath(ctx, item, ctx.path, i) + ); }); return ParseStatus.mergeArray(status, result); } @@ -2466,19 +2589,19 @@ var ZodArray = class _ZodArray extends ZodType { min(minLength, message) { return new _ZodArray({ ...this._def, - minLength: { value: minLength, message: errorUtil.toString(message) } + minLength: { value: minLength, message: errorUtil.toString(message) }, }); } max(maxLength, message) { return new _ZodArray({ ...this._def, - maxLength: { value: maxLength, message: errorUtil.toString(message) } + maxLength: { value: maxLength, message: errorUtil.toString(message) }, }); } length(len, message) { return new _ZodArray({ ...this._def, - exactLength: { value: len, message: errorUtil.toString(message) } + exactLength: { value: len, message: errorUtil.toString(message) }, }); } nonempty(message) { @@ -2492,7 +2615,7 @@ ZodArray.create = (schema, params) => { maxLength: null, exactLength: null, typeName: ZodFirstPartyTypeKind.ZodArray, - ...processCreateParams(params) + ...processCreateParams(params), }); }; function deepPartialify(schema) { @@ -2504,12 +2627,12 @@ function deepPartialify(schema) { } return new ZodObject({ ...schema._def, - shape: () => newShape + shape: () => newShape, }); } else if (schema instanceof ZodArray) { return new ZodArray({ ...schema._def, - type: deepPartialify(schema.element) + type: deepPartialify(schema.element), }); } else if (schema instanceof ZodOptional) { return ZodOptional.create(deepPartialify(schema.unwrap())); @@ -2529,8 +2652,7 @@ var ZodObject = class _ZodObject extends ZodType { this.augment = this.extend; } _getCached() { - if (this._cached !== null) - return this._cached; + if (this._cached !== null) return this._cached; const shape = this._def.shape(); const keys = util.objectKeys(shape); this._cached = { shape, keys }; @@ -2543,14 +2665,19 @@ var ZodObject = class _ZodObject extends ZodType { addIssueToContext(ctx2, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.object, - received: ctx2.parsedType + received: ctx2.parsedType, }); return INVALID; } const { status, ctx } = this._processInputParams(input); const { shape, keys: shapeKeys } = this._getCached(); const extraKeys = []; - if (!(this._def.catchall instanceof ZodNever && this._def.unknownKeys === "strip")) { + if ( + !( + this._def.catchall instanceof ZodNever && + this._def.unknownKeys === 'strip' + ) + ) { for (const key in ctx.data) { if (!shapeKeys.includes(key)) { extraKeys.push(key); @@ -2562,29 +2689,31 @@ var ZodObject = class _ZodObject extends ZodType { const keyValidator = shape[key]; const value = ctx.data[key]; pairs.push({ - key: { status: "valid", value: key }, - value: keyValidator._parse(new ParseInputLazyPath(ctx, value, ctx.path, key)), - alwaysSet: key in ctx.data + key: { status: 'valid', value: key }, + value: keyValidator._parse( + new ParseInputLazyPath(ctx, value, ctx.path, key) + ), + alwaysSet: key in ctx.data, }); } if (this._def.catchall instanceof ZodNever) { const unknownKeys = this._def.unknownKeys; - if (unknownKeys === "passthrough") { + if (unknownKeys === 'passthrough') { for (const key of extraKeys) { pairs.push({ - key: { status: "valid", value: key }, - value: { status: "valid", value: ctx.data[key] } + key: { status: 'valid', value: key }, + value: { status: 'valid', value: ctx.data[key] }, }); } - } else if (unknownKeys === "strict") { + } else if (unknownKeys === 'strict') { if (extraKeys.length > 0) { addIssueToContext(ctx, { code: ZodIssueCode.unrecognized_keys, - keys: extraKeys + keys: extraKeys, }); status.dirty(); } - } else if (unknownKeys === "strip") { + } else if (unknownKeys === 'strip') { } else { throw new Error(`Internal ZodObject error: invalid unknownKeys value.`); } @@ -2593,31 +2722,33 @@ var ZodObject = class _ZodObject extends ZodType { for (const key of extraKeys) { const value = ctx.data[key]; pairs.push({ - key: { status: "valid", value: key }, + key: { status: 'valid', value: key }, value: catchall._parse( new ParseInputLazyPath(ctx, value, ctx.path, key) //, ctx.child(key), value, getParsedType(value) ), - alwaysSet: key in ctx.data + alwaysSet: key in ctx.data, }); } } if (ctx.common.async) { - return Promise.resolve().then(async () => { - const syncPairs = []; - for (const pair of pairs) { - const key = await pair.key; - const value = await pair.value; - syncPairs.push({ - key, - value, - alwaysSet: pair.alwaysSet - }); - } - return syncPairs; - }).then((syncPairs) => { - return ParseStatus.mergeObjectSync(status, syncPairs); - }); + return Promise.resolve() + .then(async () => { + const syncPairs = []; + for (const pair of pairs) { + const key = await pair.key; + const value = await pair.value; + syncPairs.push({ + key, + value, + alwaysSet: pair.alwaysSet, + }); + } + return syncPairs; + }) + .then((syncPairs) => { + return ParseStatus.mergeObjectSync(status, syncPairs); + }); } else { return ParseStatus.mergeObjectSync(status, pairs); } @@ -2629,31 +2760,34 @@ var ZodObject = class _ZodObject extends ZodType { errorUtil.errToObj; return new _ZodObject({ ...this._def, - unknownKeys: "strict", - ...message !== void 0 ? { - errorMap: (issue, ctx) => { - const defaultError = this._def.errorMap?.(issue, ctx).message ?? ctx.defaultError; - if (issue.code === "unrecognized_keys") - return { - message: errorUtil.errToObj(message).message ?? defaultError - }; - return { - message: defaultError - }; - } - } : {} + unknownKeys: 'strict', + ...(message !== void 0 + ? { + errorMap: (issue, ctx) => { + const defaultError = + this._def.errorMap?.(issue, ctx).message ?? ctx.defaultError; + if (issue.code === 'unrecognized_keys') + return { + message: errorUtil.errToObj(message).message ?? defaultError, + }; + return { + message: defaultError, + }; + }, + } + : {}), }); } strip() { return new _ZodObject({ ...this._def, - unknownKeys: "strip" + unknownKeys: 'strip', }); } passthrough() { return new _ZodObject({ ...this._def, - unknownKeys: "passthrough" + unknownKeys: 'passthrough', }); } // const AugmentFactory = @@ -2678,8 +2812,8 @@ var ZodObject = class _ZodObject extends ZodType { ...this._def, shape: () => ({ ...this._def.shape(), - ...augmentation - }) + ...augmentation, + }), }); } /** @@ -2693,9 +2827,9 @@ var ZodObject = class _ZodObject extends ZodType { catchall: merging._def.catchall, shape: () => ({ ...this._def.shape(), - ...merging._def.shape() + ...merging._def.shape(), }), - typeName: ZodFirstPartyTypeKind.ZodObject + typeName: ZodFirstPartyTypeKind.ZodObject, }); return merged; } @@ -2761,7 +2895,7 @@ var ZodObject = class _ZodObject extends ZodType { catchall(index) { return new _ZodObject({ ...this._def, - catchall: index + catchall: index, }); } pick(mask) { @@ -2773,7 +2907,7 @@ var ZodObject = class _ZodObject extends ZodType { } return new _ZodObject({ ...this._def, - shape: () => shape + shape: () => shape, }); } omit(mask) { @@ -2785,7 +2919,7 @@ var ZodObject = class _ZodObject extends ZodType { } return new _ZodObject({ ...this._def, - shape: () => shape + shape: () => shape, }); } /** @@ -2806,7 +2940,7 @@ var ZodObject = class _ZodObject extends ZodType { } return new _ZodObject({ ...this._def, - shape: () => newShape + shape: () => newShape, }); } required(mask) { @@ -2825,7 +2959,7 @@ var ZodObject = class _ZodObject extends ZodType { } return new _ZodObject({ ...this._def, - shape: () => newShape + shape: () => newShape, }); } keyof() { @@ -2835,28 +2969,28 @@ var ZodObject = class _ZodObject extends ZodType { ZodObject.create = (shape, params) => { return new ZodObject({ shape: () => shape, - unknownKeys: "strip", + unknownKeys: 'strip', catchall: ZodNever.create(), typeName: ZodFirstPartyTypeKind.ZodObject, - ...processCreateParams(params) + ...processCreateParams(params), }); }; ZodObject.strictCreate = (shape, params) => { return new ZodObject({ shape: () => shape, - unknownKeys: "strict", + unknownKeys: 'strict', catchall: ZodNever.create(), typeName: ZodFirstPartyTypeKind.ZodObject, - ...processCreateParams(params) + ...processCreateParams(params), }); }; ZodObject.lazycreate = (shape, params) => { return new ZodObject({ shape, - unknownKeys: "strip", + unknownKeys: 'strip', catchall: ZodNever.create(), typeName: ZodFirstPartyTypeKind.ZodObject, - ...processCreateParams(params) + ...processCreateParams(params), }); }; var ZodUnion = class extends ZodType { @@ -2865,42 +2999,46 @@ var ZodUnion = class extends ZodType { const options = this._def.options; function handleResults(results) { for (const result of results) { - if (result.result.status === "valid") { + if (result.result.status === 'valid') { return result.result; } } for (const result of results) { - if (result.result.status === "dirty") { + if (result.result.status === 'dirty') { ctx.common.issues.push(...result.ctx.common.issues); return result.result; } } - const unionErrors = results.map((result) => new ZodError(result.ctx.common.issues)); + const unionErrors = results.map( + (result) => new ZodError(result.ctx.common.issues) + ); addIssueToContext(ctx, { code: ZodIssueCode.invalid_union, - unionErrors + unionErrors, }); return INVALID; } if (ctx.common.async) { - return Promise.all(options.map(async (option) => { - const childCtx = { - ...ctx, - common: { - ...ctx.common, - issues: [] - }, - parent: null - }; - return { - result: await option._parseAsync({ - data: ctx.data, - path: ctx.path, - parent: childCtx - }), - ctx: childCtx - }; - })).then(handleResults); + return Promise.all( + options.map(async (option) => { + const childCtx = { + ...ctx, + common: { + ...ctx.common, + issues: [], + }, + parent: null, + }; + return { + result: await option._parseAsync({ + data: ctx.data, + path: ctx.path, + parent: childCtx, + }), + ctx: childCtx, + }; + }) + ).then(handleResults); } else { let dirty = void 0; const issues = []; @@ -2909,18 +3047,18 @@ var ZodUnion = class extends ZodType { ...ctx, common: { ...ctx.common, - issues: [] + issues: [], }, - parent: null + parent: null, }; const result = option._parseSync({ data: ctx.data, path: ctx.path, - parent: childCtx + parent: childCtx, }); - if (result.status === "valid") { + if (result.status === 'valid') { return result; - } else if (result.status === "dirty" && !dirty) { + } else if (result.status === 'dirty' && !dirty) { dirty = { result, ctx: childCtx }; } if (childCtx.common.issues.length) { @@ -2934,7 +3072,7 @@ var ZodUnion = class extends ZodType { const unionErrors = issues.map((issues2) => new ZodError(issues2)); addIssueToContext(ctx, { code: ZodIssueCode.invalid_union, - unionErrors + unionErrors, }); return INVALID; } @@ -2947,7 +3085,7 @@ ZodUnion.create = (types, params) => { return new ZodUnion({ options: types, typeName: ZodFirstPartyTypeKind.ZodUnion, - ...processCreateParams(params) + ...processCreateParams(params), }); }; var getDiscriminator = (type) => { @@ -2988,7 +3126,7 @@ var ZodDiscriminatedUnion = class _ZodDiscriminatedUnion extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.object, - received: ctx.parsedType + received: ctx.parsedType, }); return INVALID; } @@ -2999,7 +3137,7 @@ var ZodDiscriminatedUnion = class _ZodDiscriminatedUnion extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_union_discriminator, options: Array.from(this.optionsMap.keys()), - path: [discriminator] + path: [discriminator], }); return INVALID; } @@ -3007,13 +3145,13 @@ var ZodDiscriminatedUnion = class _ZodDiscriminatedUnion extends ZodType { return option._parseAsync({ data: ctx.data, path: ctx.path, - parent: ctx + parent: ctx, }); } else { return option._parseSync({ data: ctx.data, path: ctx.path, - parent: ctx + parent: ctx, }); } } @@ -3039,11 +3177,17 @@ var ZodDiscriminatedUnion = class _ZodDiscriminatedUnion extends ZodType { for (const type of options) { const discriminatorValues = getDiscriminator(type.shape[discriminator]); if (!discriminatorValues.length) { - throw new Error(`A discriminator value for key \`${discriminator}\` could not be extracted from all schema options`); + throw new Error( + `A discriminator value for key \`${discriminator}\` could not be extracted from all schema options` + ); } for (const value of discriminatorValues) { if (optionsMap.has(value)) { - throw new Error(`Discriminator property ${String(discriminator)} has duplicate value ${String(value)}`); + throw new Error( + `Discriminator property ${String( + discriminator + )} has duplicate value ${String(value)}` + ); } optionsMap.set(value, type); } @@ -3053,7 +3197,7 @@ var ZodDiscriminatedUnion = class _ZodDiscriminatedUnion extends ZodType { discriminator, options, optionsMap, - ...processCreateParams(params) + ...processCreateParams(params), }); } }; @@ -3064,7 +3208,9 @@ function mergeValues(a, b) { return { valid: true, data: a }; } else if (aType === ZodParsedType.object && bType === ZodParsedType.object) { const bKeys = util.objectKeys(b); - const sharedKeys = util.objectKeys(a).filter((key) => bKeys.indexOf(key) !== -1); + const sharedKeys = util + .objectKeys(a) + .filter((key) => bKeys.indexOf(key) !== -1); const newObj = { ...a, ...b }; for (const key of sharedKeys) { const sharedValue = mergeValues(a[key], b[key]); @@ -3089,7 +3235,11 @@ function mergeValues(a, b) { newArray.push(sharedValue.data); } return { valid: true, data: newArray }; - } else if (aType === ZodParsedType.date && bType === ZodParsedType.date && +a === +b) { + } else if ( + aType === ZodParsedType.date && + bType === ZodParsedType.date && + +a === +b + ) { return { valid: true, data: a }; } else { return { valid: false }; @@ -3105,7 +3255,7 @@ var ZodIntersection = class extends ZodType { const merged = mergeValues(parsedLeft.value, parsedRight.value); if (!merged.valid) { addIssueToContext(ctx, { - code: ZodIssueCode.invalid_intersection_types + code: ZodIssueCode.invalid_intersection_types, }); return INVALID; } @@ -3119,24 +3269,27 @@ var ZodIntersection = class extends ZodType { this._def.left._parseAsync({ data: ctx.data, path: ctx.path, - parent: ctx + parent: ctx, }), this._def.right._parseAsync({ data: ctx.data, path: ctx.path, - parent: ctx - }) + parent: ctx, + }), ]).then(([left, right]) => handleParsed(left, right)); } else { - return handleParsed(this._def.left._parseSync({ - data: ctx.data, - path: ctx.path, - parent: ctx - }), this._def.right._parseSync({ - data: ctx.data, - path: ctx.path, - parent: ctx - })); + return handleParsed( + this._def.left._parseSync({ + data: ctx.data, + path: ctx.path, + parent: ctx, + }), + this._def.right._parseSync({ + data: ctx.data, + path: ctx.path, + parent: ctx, + }) + ); } } }; @@ -3145,7 +3298,7 @@ ZodIntersection.create = (left, right, params) => { left, right, typeName: ZodFirstPartyTypeKind.ZodIntersection, - ...processCreateParams(params) + ...processCreateParams(params), }); }; var ZodTuple = class _ZodTuple extends ZodType { @@ -3155,7 +3308,7 @@ var ZodTuple = class _ZodTuple extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.array, - received: ctx.parsedType + received: ctx.parsedType, }); return INVALID; } @@ -3165,7 +3318,7 @@ var ZodTuple = class _ZodTuple extends ZodType { minimum: this._def.items.length, inclusive: true, exact: false, - type: "array" + type: 'array', }); return INVALID; } @@ -3176,16 +3329,19 @@ var ZodTuple = class _ZodTuple extends ZodType { maximum: this._def.items.length, inclusive: true, exact: false, - type: "array" + type: 'array', }); status.dirty(); } - const items = [...ctx.data].map((item, itemIndex) => { - const schema = this._def.items[itemIndex] || this._def.rest; - if (!schema) - return null; - return schema._parse(new ParseInputLazyPath(ctx, item, ctx.path, itemIndex)); - }).filter((x2) => !!x2); + const items = [...ctx.data] + .map((item, itemIndex) => { + const schema = this._def.items[itemIndex] || this._def.rest; + if (!schema) return null; + return schema._parse( + new ParseInputLazyPath(ctx, item, ctx.path, itemIndex) + ); + }) + .filter((x2) => !!x2); if (ctx.common.async) { return Promise.all(items).then((results) => { return ParseStatus.mergeArray(status, results); @@ -3200,19 +3356,19 @@ var ZodTuple = class _ZodTuple extends ZodType { rest(rest) { return new _ZodTuple({ ...this._def, - rest + rest, }); } }; ZodTuple.create = (schemas, params) => { if (!Array.isArray(schemas)) { - throw new Error("You must pass an array of schemas to z.tuple([ ... ])"); + throw new Error('You must pass an array of schemas to z.tuple([ ... ])'); } return new ZodTuple({ items: schemas, typeName: ZodFirstPartyTypeKind.ZodTuple, rest: null, - ...processCreateParams(params) + ...processCreateParams(params), }); }; var ZodRecord = class _ZodRecord extends ZodType { @@ -3228,7 +3384,7 @@ var ZodRecord = class _ZodRecord extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.object, - received: ctx.parsedType + received: ctx.parsedType, }); return INVALID; } @@ -3238,8 +3394,10 @@ var ZodRecord = class _ZodRecord extends ZodType { for (const key in ctx.data) { pairs.push({ key: keyType._parse(new ParseInputLazyPath(ctx, key, ctx.path, key)), - value: valueType._parse(new ParseInputLazyPath(ctx, ctx.data[key], ctx.path, key)), - alwaysSet: key in ctx.data + value: valueType._parse( + new ParseInputLazyPath(ctx, ctx.data[key], ctx.path, key) + ), + alwaysSet: key in ctx.data, }); } if (ctx.common.async) { @@ -3257,14 +3415,14 @@ var ZodRecord = class _ZodRecord extends ZodType { keyType: first, valueType: second, typeName: ZodFirstPartyTypeKind.ZodRecord, - ...processCreateParams(third) + ...processCreateParams(third), }); } return new _ZodRecord({ keyType: ZodString.create(), valueType: first, typeName: ZodFirstPartyTypeKind.ZodRecord, - ...processCreateParams(second) + ...processCreateParams(second), }); } }; @@ -3281,7 +3439,7 @@ var ZodMap = class extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.map, - received: ctx.parsedType + received: ctx.parsedType, }); return INVALID; } @@ -3289,8 +3447,12 @@ var ZodMap = class extends ZodType { const valueType = this._def.valueType; const pairs = [...ctx.data.entries()].map(([key, value], index) => { return { - key: keyType._parse(new ParseInputLazyPath(ctx, key, ctx.path, [index, "key"])), - value: valueType._parse(new ParseInputLazyPath(ctx, value, ctx.path, [index, "value"])) + key: keyType._parse( + new ParseInputLazyPath(ctx, key, ctx.path, [index, 'key']) + ), + value: valueType._parse( + new ParseInputLazyPath(ctx, value, ctx.path, [index, 'value']) + ), }; }); if (ctx.common.async) { @@ -3299,10 +3461,10 @@ var ZodMap = class extends ZodType { for (const pair of pairs) { const key = await pair.key; const value = await pair.value; - if (key.status === "aborted" || value.status === "aborted") { + if (key.status === 'aborted' || value.status === 'aborted') { return INVALID; } - if (key.status === "dirty" || value.status === "dirty") { + if (key.status === 'dirty' || value.status === 'dirty') { status.dirty(); } finalMap.set(key.value, value.value); @@ -3314,10 +3476,10 @@ var ZodMap = class extends ZodType { for (const pair of pairs) { const key = pair.key; const value = pair.value; - if (key.status === "aborted" || value.status === "aborted") { + if (key.status === 'aborted' || value.status === 'aborted') { return INVALID; } - if (key.status === "dirty" || value.status === "dirty") { + if (key.status === 'dirty' || value.status === 'dirty') { status.dirty(); } finalMap.set(key.value, value.value); @@ -3331,7 +3493,7 @@ ZodMap.create = (keyType, valueType, params) => { valueType, keyType, typeName: ZodFirstPartyTypeKind.ZodMap, - ...processCreateParams(params) + ...processCreateParams(params), }); }; var ZodSet = class _ZodSet extends ZodType { @@ -3341,7 +3503,7 @@ var ZodSet = class _ZodSet extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.set, - received: ctx.parsedType + received: ctx.parsedType, }); return INVALID; } @@ -3351,10 +3513,10 @@ var ZodSet = class _ZodSet extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.too_small, minimum: def.minSize.value, - type: "set", + type: 'set', inclusive: true, exact: false, - message: def.minSize.message + message: def.minSize.message, }); status.dirty(); } @@ -3364,10 +3526,10 @@ var ZodSet = class _ZodSet extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.too_big, maximum: def.maxSize.value, - type: "set", + type: 'set', inclusive: true, exact: false, - message: def.maxSize.message + message: def.maxSize.message, }); status.dirty(); } @@ -3376,15 +3538,15 @@ var ZodSet = class _ZodSet extends ZodType { function finalizeSet(elements2) { const parsedSet = /* @__PURE__ */ new Set(); for (const element of elements2) { - if (element.status === "aborted") - return INVALID; - if (element.status === "dirty") - status.dirty(); + if (element.status === 'aborted') return INVALID; + if (element.status === 'dirty') status.dirty(); parsedSet.add(element.value); } return { status: status.value, value: parsedSet }; } - const elements = [...ctx.data.values()].map((item, i) => valueType._parse(new ParseInputLazyPath(ctx, item, ctx.path, i))); + const elements = [...ctx.data.values()].map((item, i) => + valueType._parse(new ParseInputLazyPath(ctx, item, ctx.path, i)) + ); if (ctx.common.async) { return Promise.all(elements).then((elements2) => finalizeSet(elements2)); } else { @@ -3394,13 +3556,13 @@ var ZodSet = class _ZodSet extends ZodType { min(minSize, message) { return new _ZodSet({ ...this._def, - minSize: { value: minSize, message: errorUtil.toString(message) } + minSize: { value: minSize, message: errorUtil.toString(message) }, }); } max(maxSize, message) { return new _ZodSet({ ...this._def, - maxSize: { value: maxSize, message: errorUtil.toString(message) } + maxSize: { value: maxSize, message: errorUtil.toString(message) }, }); } size(size, message) { @@ -3416,7 +3578,7 @@ ZodSet.create = (valueType, params) => { minSize: null, maxSize: null, typeName: ZodFirstPartyTypeKind.ZodSet, - ...processCreateParams(params) + ...processCreateParams(params), }); }; var ZodFunction = class _ZodFunction extends ZodType { @@ -3430,7 +3592,7 @@ var ZodFunction = class _ZodFunction extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.function, - received: ctx.parsedType + received: ctx.parsedType, }); return INVALID; } @@ -3438,44 +3600,58 @@ var ZodFunction = class _ZodFunction extends ZodType { return makeIssue({ data: args, path: ctx.path, - errorMaps: [ctx.common.contextualErrorMap, ctx.schemaErrorMap, getErrorMap(), en_default].filter((x2) => !!x2), + errorMaps: [ + ctx.common.contextualErrorMap, + ctx.schemaErrorMap, + getErrorMap(), + en_default, + ].filter((x2) => !!x2), issueData: { code: ZodIssueCode.invalid_arguments, - argumentsError: error - } + argumentsError: error, + }, }); } function makeReturnsIssue(returns, error) { return makeIssue({ data: returns, path: ctx.path, - errorMaps: [ctx.common.contextualErrorMap, ctx.schemaErrorMap, getErrorMap(), en_default].filter((x2) => !!x2), + errorMaps: [ + ctx.common.contextualErrorMap, + ctx.schemaErrorMap, + getErrorMap(), + en_default, + ].filter((x2) => !!x2), issueData: { code: ZodIssueCode.invalid_return_type, - returnTypeError: error - } + returnTypeError: error, + }, }); } const params = { errorMap: ctx.common.contextualErrorMap }; const fn = ctx.data; if (this._def.returns instanceof ZodPromise) { const me2 = this; - return OK(async function(...args) { + return OK(async function (...args) { const error = new ZodError([]); - const parsedArgs = await me2._def.args.parseAsync(args, params).catch((e) => { - error.addIssue(makeArgsIssue(args, e)); - throw error; - }); + const parsedArgs = await me2._def.args + .parseAsync(args, params) + .catch((e) => { + error.addIssue(makeArgsIssue(args, e)); + throw error; + }); const result = await Reflect.apply(fn, this, parsedArgs); - const parsedReturns = await me2._def.returns._def.type.parseAsync(result, params).catch((e) => { - error.addIssue(makeReturnsIssue(result, e)); - throw error; - }); + const parsedReturns = await me2._def.returns._def.type + .parseAsync(result, params) + .catch((e) => { + error.addIssue(makeReturnsIssue(result, e)); + throw error; + }); return parsedReturns; }); } else { const me2 = this; - return OK(function(...args) { + return OK(function (...args) { const parsedArgs = me2._def.args.safeParse(args, params); if (!parsedArgs.success) { throw new ZodError([makeArgsIssue(args, parsedArgs.error)]); @@ -3498,13 +3674,13 @@ var ZodFunction = class _ZodFunction extends ZodType { args(...items) { return new _ZodFunction({ ...this._def, - args: ZodTuple.create(items).rest(ZodUnknown.create()) + args: ZodTuple.create(items).rest(ZodUnknown.create()), }); } returns(returnType) { return new _ZodFunction({ ...this._def, - returns: returnType + returns: returnType, }); } implement(func) { @@ -3520,7 +3696,7 @@ var ZodFunction = class _ZodFunction extends ZodType { args: args ? args : ZodTuple.create([]).rest(ZodUnknown.create()), returns: returns || ZodUnknown.create(), typeName: ZodFirstPartyTypeKind.ZodFunction, - ...processCreateParams(params) + ...processCreateParams(params), }); } }; @@ -3538,7 +3714,7 @@ ZodLazy.create = (getter, params) => { return new ZodLazy({ getter, typeName: ZodFirstPartyTypeKind.ZodLazy, - ...processCreateParams(params) + ...processCreateParams(params), }); }; var ZodLiteral = class extends ZodType { @@ -3548,11 +3724,11 @@ var ZodLiteral = class extends ZodType { addIssueToContext(ctx, { received: ctx.data, code: ZodIssueCode.invalid_literal, - expected: this._def.value + expected: this._def.value, }); return INVALID; } - return { status: "valid", value: input.data }; + return { status: 'valid', value: input.data }; } get value() { return this._def.value; @@ -3562,25 +3738,25 @@ ZodLiteral.create = (value, params) => { return new ZodLiteral({ value, typeName: ZodFirstPartyTypeKind.ZodLiteral, - ...processCreateParams(params) + ...processCreateParams(params), }); }; function createZodEnum(values, params) { return new ZodEnum({ values, typeName: ZodFirstPartyTypeKind.ZodEnum, - ...processCreateParams(params) + ...processCreateParams(params), }); } var ZodEnum = class _ZodEnum extends ZodType { _parse(input) { - if (typeof input.data !== "string") { + if (typeof input.data !== 'string') { const ctx = this._getOrReturnCtx(input); const expectedValues = this._def.values; addIssueToContext(ctx, { expected: util.joinValues(expectedValues), received: ctx.parsedType, - code: ZodIssueCode.invalid_type + code: ZodIssueCode.invalid_type, }); return INVALID; } @@ -3593,7 +3769,7 @@ var ZodEnum = class _ZodEnum extends ZodType { addIssueToContext(ctx, { received: ctx.data, code: ZodIssueCode.invalid_enum_value, - options: expectedValues + options: expectedValues, }); return INVALID; } @@ -3626,14 +3802,17 @@ var ZodEnum = class _ZodEnum extends ZodType { extract(values, newDef = this._def) { return _ZodEnum.create(values, { ...this._def, - ...newDef + ...newDef, }); } exclude(values, newDef = this._def) { - return _ZodEnum.create(this.options.filter((opt) => !values.includes(opt)), { - ...this._def, - ...newDef - }); + return _ZodEnum.create( + this.options.filter((opt) => !values.includes(opt)), + { + ...this._def, + ...newDef, + } + ); } }; ZodEnum.create = createZodEnum; @@ -3641,12 +3820,15 @@ var ZodNativeEnum = class extends ZodType { _parse(input) { const nativeEnumValues = util.getValidEnumValues(this._def.values); const ctx = this._getOrReturnCtx(input); - if (ctx.parsedType !== ZodParsedType.string && ctx.parsedType !== ZodParsedType.number) { + if ( + ctx.parsedType !== ZodParsedType.string && + ctx.parsedType !== ZodParsedType.number + ) { const expectedValues = util.objectValues(nativeEnumValues); addIssueToContext(ctx, { expected: util.joinValues(expectedValues), received: ctx.parsedType, - code: ZodIssueCode.invalid_type + code: ZodIssueCode.invalid_type, }); return INVALID; } @@ -3658,7 +3840,7 @@ var ZodNativeEnum = class extends ZodType { addIssueToContext(ctx, { received: ctx.data, code: ZodIssueCode.invalid_enum_value, - options: expectedValues + options: expectedValues, }); return INVALID; } @@ -3672,7 +3854,7 @@ ZodNativeEnum.create = (values, params) => { return new ZodNativeEnum({ values, typeName: ZodFirstPartyTypeKind.ZodNativeEnum, - ...processCreateParams(params) + ...processCreateParams(params), }); }; var ZodPromise = class extends ZodType { @@ -3681,28 +3863,36 @@ var ZodPromise = class extends ZodType { } _parse(input) { const { ctx } = this._processInputParams(input); - if (ctx.parsedType !== ZodParsedType.promise && ctx.common.async === false) { + if ( + ctx.parsedType !== ZodParsedType.promise && + ctx.common.async === false + ) { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.promise, - received: ctx.parsedType + received: ctx.parsedType, }); return INVALID; } - const promisified = ctx.parsedType === ZodParsedType.promise ? ctx.data : Promise.resolve(ctx.data); - return OK(promisified.then((data) => { - return this._def.type.parseAsync(data, { - path: ctx.path, - errorMap: ctx.common.contextualErrorMap - }); - })); + const promisified = + ctx.parsedType === ZodParsedType.promise + ? ctx.data + : Promise.resolve(ctx.data); + return OK( + promisified.then((data) => { + return this._def.type.parseAsync(data, { + path: ctx.path, + errorMap: ctx.common.contextualErrorMap, + }); + }) + ); } }; ZodPromise.create = (schema, params) => { return new ZodPromise({ type: schema, typeName: ZodFirstPartyTypeKind.ZodPromise, - ...processCreateParams(params) + ...processCreateParams(params), }); }; var ZodEffects = class extends ZodType { @@ -3710,7 +3900,9 @@ var ZodEffects = class extends ZodType { return this._def.schema; } sourceType() { - return this._def.schema._def.typeName === ZodFirstPartyTypeKind.ZodEffects ? this._def.schema.sourceType() : this._def.schema; + return this._def.schema._def.typeName === ZodFirstPartyTypeKind.ZodEffects + ? this._def.schema.sourceType() + : this._def.schema; } _parse(input) { const { status, ctx } = this._processInputParams(input); @@ -3726,53 +3918,47 @@ var ZodEffects = class extends ZodType { }, get path() { return ctx.path; - } + }, }; checkCtx.addIssue = checkCtx.addIssue.bind(checkCtx); - if (effect.type === "preprocess") { + if (effect.type === 'preprocess') { const processed = effect.transform(ctx.data, checkCtx); if (ctx.common.async) { return Promise.resolve(processed).then(async (processed2) => { - if (status.value === "aborted") - return INVALID; + if (status.value === 'aborted') return INVALID; const result = await this._def.schema._parseAsync({ data: processed2, path: ctx.path, - parent: ctx + parent: ctx, }); - if (result.status === "aborted") - return INVALID; - if (result.status === "dirty") - return DIRTY(result.value); - if (status.value === "dirty") - return DIRTY(result.value); + if (result.status === 'aborted') return INVALID; + if (result.status === 'dirty') return DIRTY(result.value); + if (status.value === 'dirty') return DIRTY(result.value); return result; }); } else { - if (status.value === "aborted") - return INVALID; + if (status.value === 'aborted') return INVALID; const result = this._def.schema._parseSync({ data: processed, path: ctx.path, - parent: ctx + parent: ctx, }); - if (result.status === "aborted") - return INVALID; - if (result.status === "dirty") - return DIRTY(result.value); - if (status.value === "dirty") - return DIRTY(result.value); + if (result.status === 'aborted') return INVALID; + if (result.status === 'dirty') return DIRTY(result.value); + if (status.value === 'dirty') return DIRTY(result.value); return result; } } - if (effect.type === "refinement") { + if (effect.type === 'refinement') { const executeRefinement = (acc) => { const result = effect.refinement(acc, checkCtx); if (ctx.common.async) { return Promise.resolve(result); } if (result instanceof Promise) { - throw new Error("Async refinement encountered during synchronous parse operation. Use .parseAsync instead."); + throw new Error( + 'Async refinement encountered during synchronous parse operation. Use .parseAsync instead.' + ); } return acc; }; @@ -3780,49 +3966,51 @@ var ZodEffects = class extends ZodType { const inner = this._def.schema._parseSync({ data: ctx.data, path: ctx.path, - parent: ctx + parent: ctx, }); - if (inner.status === "aborted") - return INVALID; - if (inner.status === "dirty") - status.dirty(); + if (inner.status === 'aborted') return INVALID; + if (inner.status === 'dirty') status.dirty(); executeRefinement(inner.value); return { status: status.value, value: inner.value }; } else { - return this._def.schema._parseAsync({ data: ctx.data, path: ctx.path, parent: ctx }).then((inner) => { - if (inner.status === "aborted") - return INVALID; - if (inner.status === "dirty") - status.dirty(); - return executeRefinement(inner.value).then(() => { - return { status: status.value, value: inner.value }; + return this._def.schema + ._parseAsync({ data: ctx.data, path: ctx.path, parent: ctx }) + .then((inner) => { + if (inner.status === 'aborted') return INVALID; + if (inner.status === 'dirty') status.dirty(); + return executeRefinement(inner.value).then(() => { + return { status: status.value, value: inner.value }; + }); }); - }); } } - if (effect.type === "transform") { + if (effect.type === 'transform') { if (ctx.common.async === false) { const base = this._def.schema._parseSync({ data: ctx.data, path: ctx.path, - parent: ctx + parent: ctx, }); - if (!isValid(base)) - return INVALID; + if (!isValid(base)) return INVALID; const result = effect.transform(base.value, checkCtx); if (result instanceof Promise) { - throw new Error(`Asynchronous transform encountered during synchronous parse operation. Use .parseAsync instead.`); + throw new Error( + `Asynchronous transform encountered during synchronous parse operation. Use .parseAsync instead.` + ); } return { status: status.value, value: result }; } else { - return this._def.schema._parseAsync({ data: ctx.data, path: ctx.path, parent: ctx }).then((base) => { - if (!isValid(base)) - return INVALID; - return Promise.resolve(effect.transform(base.value, checkCtx)).then((result) => ({ - status: status.value, - value: result - })); - }); + return this._def.schema + ._parseAsync({ data: ctx.data, path: ctx.path, parent: ctx }) + .then((base) => { + if (!isValid(base)) return INVALID; + return Promise.resolve(effect.transform(base.value, checkCtx)).then( + (result) => ({ + status: status.value, + value: result, + }) + ); + }); } } util.assertNever(effect); @@ -3833,15 +4021,15 @@ ZodEffects.create = (schema, effect, params) => { schema, typeName: ZodFirstPartyTypeKind.ZodEffects, effect, - ...processCreateParams(params) + ...processCreateParams(params), }); }; ZodEffects.createWithPreprocess = (preprocess, schema, params) => { return new ZodEffects({ schema, - effect: { type: "preprocess", transform: preprocess }, + effect: { type: 'preprocess', transform: preprocess }, typeName: ZodFirstPartyTypeKind.ZodEffects, - ...processCreateParams(params) + ...processCreateParams(params), }); }; var ZodOptional = class extends ZodType { @@ -3860,7 +4048,7 @@ ZodOptional.create = (type, params) => { return new ZodOptional({ innerType: type, typeName: ZodFirstPartyTypeKind.ZodOptional, - ...processCreateParams(params) + ...processCreateParams(params), }); }; var ZodNullable = class extends ZodType { @@ -3879,7 +4067,7 @@ ZodNullable.create = (type, params) => { return new ZodNullable({ innerType: type, typeName: ZodFirstPartyTypeKind.ZodNullable, - ...processCreateParams(params) + ...processCreateParams(params), }); }; var ZodDefault = class extends ZodType { @@ -3892,7 +4080,7 @@ var ZodDefault = class extends ZodType { return this._def.innerType._parse({ data, path: ctx.path, - parent: ctx + parent: ctx, }); } removeDefault() { @@ -3903,8 +4091,11 @@ ZodDefault.create = (type, params) => { return new ZodDefault({ innerType: type, typeName: ZodFirstPartyTypeKind.ZodDefault, - defaultValue: typeof params.default === "function" ? params.default : () => params.default, - ...processCreateParams(params) + defaultValue: + typeof params.default === 'function' + ? params.default + : () => params.default, + ...processCreateParams(params), }); }; var ZodCatch = class extends ZodType { @@ -3914,37 +4105,43 @@ var ZodCatch = class extends ZodType { ...ctx, common: { ...ctx.common, - issues: [] - } + issues: [], + }, }; const result = this._def.innerType._parse({ data: newCtx.data, path: newCtx.path, parent: { - ...newCtx - } + ...newCtx, + }, }); if (isAsync(result)) { return result.then((result2) => { return { - status: "valid", - value: result2.status === "valid" ? result2.value : this._def.catchValue({ - get error() { - return new ZodError(newCtx.common.issues); - }, - input: newCtx.data - }) + status: 'valid', + value: + result2.status === 'valid' + ? result2.value + : this._def.catchValue({ + get error() { + return new ZodError(newCtx.common.issues); + }, + input: newCtx.data, + }), }; }); } else { return { - status: "valid", - value: result.status === "valid" ? result.value : this._def.catchValue({ - get error() { - return new ZodError(newCtx.common.issues); - }, - input: newCtx.data - }) + status: 'valid', + value: + result.status === 'valid' + ? result.value + : this._def.catchValue({ + get error() { + return new ZodError(newCtx.common.issues); + }, + input: newCtx.data, + }), }; } } @@ -3956,8 +4153,9 @@ ZodCatch.create = (type, params) => { return new ZodCatch({ innerType: type, typeName: ZodFirstPartyTypeKind.ZodCatch, - catchValue: typeof params.catch === "function" ? params.catch : () => params.catch, - ...processCreateParams(params) + catchValue: + typeof params.catch === 'function' ? params.catch : () => params.catch, + ...processCreateParams(params), }); }; var ZodNaN = class extends ZodType { @@ -3968,20 +4166,20 @@ var ZodNaN = class extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.nan, - received: ctx.parsedType + received: ctx.parsedType, }); return INVALID; } - return { status: "valid", value: input.data }; + return { status: 'valid', value: input.data }; } }; ZodNaN.create = (params) => { return new ZodNaN({ typeName: ZodFirstPartyTypeKind.ZodNaN, - ...processCreateParams(params) + ...processCreateParams(params), }); }; -var BRAND = /* @__PURE__ */ Symbol("zod_brand"); +var BRAND = /* @__PURE__ */ Symbol('zod_brand'); var ZodBranded = class extends ZodType { _parse(input) { const { ctx } = this._processInputParams(input); @@ -3989,7 +4187,7 @@ var ZodBranded = class extends ZodType { return this._def.type._parse({ data, path: ctx.path, - parent: ctx + parent: ctx, }); } unwrap() { @@ -4004,18 +4202,17 @@ var ZodPipeline = class _ZodPipeline extends ZodType { const inResult = await this._def.in._parseAsync({ data: ctx.data, path: ctx.path, - parent: ctx + parent: ctx, }); - if (inResult.status === "aborted") - return INVALID; - if (inResult.status === "dirty") { + if (inResult.status === 'aborted') return INVALID; + if (inResult.status === 'dirty') { status.dirty(); return DIRTY(inResult.value); } else { return this._def.out._parseAsync({ data: inResult.value, path: ctx.path, - parent: ctx + parent: ctx, }); } }; @@ -4024,21 +4221,20 @@ var ZodPipeline = class _ZodPipeline extends ZodType { const inResult = this._def.in._parseSync({ data: ctx.data, path: ctx.path, - parent: ctx + parent: ctx, }); - if (inResult.status === "aborted") - return INVALID; - if (inResult.status === "dirty") { + if (inResult.status === 'aborted') return INVALID; + if (inResult.status === 'dirty') { status.dirty(); return { - status: "dirty", - value: inResult.value + status: 'dirty', + value: inResult.value, }; } else { return this._def.out._parseSync({ data: inResult.value, path: ctx.path, - parent: ctx + parent: ctx, }); } } @@ -4047,7 +4243,7 @@ var ZodPipeline = class _ZodPipeline extends ZodType { return new _ZodPipeline({ in: a, out: b, - typeName: ZodFirstPartyTypeKind.ZodPipeline + typeName: ZodFirstPartyTypeKind.ZodPipeline, }); } }; @@ -4060,7 +4256,9 @@ var ZodReadonly = class extends ZodType { } return data; }; - return isAsync(result) ? result.then((data) => freeze(data)) : freeze(result); + return isAsync(result) + ? result.then((data) => freeze(data)) + : freeze(result); } unwrap() { return this._def.innerType; @@ -4070,12 +4268,17 @@ ZodReadonly.create = (type, params) => { return new ZodReadonly({ innerType: type, typeName: ZodFirstPartyTypeKind.ZodReadonly, - ...processCreateParams(params) + ...processCreateParams(params), }); }; function cleanParams(params, data) { - const p = typeof params === "function" ? params(data) : typeof params === "string" ? { message: params } : params; - const p2 = typeof p === "string" ? { message: p } : p; + const p = + typeof params === 'function' + ? params(data) + : typeof params === 'string' + ? { message: params } + : params; + const p2 = typeof p === 'string' ? { message: p } : p; return p2; } function custom(check, _params = {}, fatal) { @@ -4087,64 +4290,67 @@ function custom(check, _params = {}, fatal) { if (!r2) { const params = cleanParams(_params, data); const _fatal = params.fatal ?? fatal ?? true; - ctx.addIssue({ code: "custom", ...params, fatal: _fatal }); + ctx.addIssue({ code: 'custom', ...params, fatal: _fatal }); } }); } if (!r) { const params = cleanParams(_params, data); const _fatal = params.fatal ?? fatal ?? true; - ctx.addIssue({ code: "custom", ...params, fatal: _fatal }); + ctx.addIssue({ code: 'custom', ...params, fatal: _fatal }); } return; }); return ZodAny.create(); } var late = { - object: ZodObject.lazycreate + object: ZodObject.lazycreate, }; var ZodFirstPartyTypeKind; -(function(ZodFirstPartyTypeKind2) { - ZodFirstPartyTypeKind2["ZodString"] = "ZodString"; - ZodFirstPartyTypeKind2["ZodNumber"] = "ZodNumber"; - ZodFirstPartyTypeKind2["ZodNaN"] = "ZodNaN"; - ZodFirstPartyTypeKind2["ZodBigInt"] = "ZodBigInt"; - ZodFirstPartyTypeKind2["ZodBoolean"] = "ZodBoolean"; - ZodFirstPartyTypeKind2["ZodDate"] = "ZodDate"; - ZodFirstPartyTypeKind2["ZodSymbol"] = "ZodSymbol"; - ZodFirstPartyTypeKind2["ZodUndefined"] = "ZodUndefined"; - ZodFirstPartyTypeKind2["ZodNull"] = "ZodNull"; - ZodFirstPartyTypeKind2["ZodAny"] = "ZodAny"; - ZodFirstPartyTypeKind2["ZodUnknown"] = "ZodUnknown"; - ZodFirstPartyTypeKind2["ZodNever"] = "ZodNever"; - ZodFirstPartyTypeKind2["ZodVoid"] = "ZodVoid"; - ZodFirstPartyTypeKind2["ZodArray"] = "ZodArray"; - ZodFirstPartyTypeKind2["ZodObject"] = "ZodObject"; - ZodFirstPartyTypeKind2["ZodUnion"] = "ZodUnion"; - ZodFirstPartyTypeKind2["ZodDiscriminatedUnion"] = "ZodDiscriminatedUnion"; - ZodFirstPartyTypeKind2["ZodIntersection"] = "ZodIntersection"; - ZodFirstPartyTypeKind2["ZodTuple"] = "ZodTuple"; - ZodFirstPartyTypeKind2["ZodRecord"] = "ZodRecord"; - ZodFirstPartyTypeKind2["ZodMap"] = "ZodMap"; - ZodFirstPartyTypeKind2["ZodSet"] = "ZodSet"; - ZodFirstPartyTypeKind2["ZodFunction"] = "ZodFunction"; - ZodFirstPartyTypeKind2["ZodLazy"] = "ZodLazy"; - ZodFirstPartyTypeKind2["ZodLiteral"] = "ZodLiteral"; - ZodFirstPartyTypeKind2["ZodEnum"] = "ZodEnum"; - ZodFirstPartyTypeKind2["ZodEffects"] = "ZodEffects"; - ZodFirstPartyTypeKind2["ZodNativeEnum"] = "ZodNativeEnum"; - ZodFirstPartyTypeKind2["ZodOptional"] = "ZodOptional"; - ZodFirstPartyTypeKind2["ZodNullable"] = "ZodNullable"; - ZodFirstPartyTypeKind2["ZodDefault"] = "ZodDefault"; - ZodFirstPartyTypeKind2["ZodCatch"] = "ZodCatch"; - ZodFirstPartyTypeKind2["ZodPromise"] = "ZodPromise"; - ZodFirstPartyTypeKind2["ZodBranded"] = "ZodBranded"; - ZodFirstPartyTypeKind2["ZodPipeline"] = "ZodPipeline"; - ZodFirstPartyTypeKind2["ZodReadonly"] = "ZodReadonly"; +(function (ZodFirstPartyTypeKind2) { + ZodFirstPartyTypeKind2['ZodString'] = 'ZodString'; + ZodFirstPartyTypeKind2['ZodNumber'] = 'ZodNumber'; + ZodFirstPartyTypeKind2['ZodNaN'] = 'ZodNaN'; + ZodFirstPartyTypeKind2['ZodBigInt'] = 'ZodBigInt'; + ZodFirstPartyTypeKind2['ZodBoolean'] = 'ZodBoolean'; + ZodFirstPartyTypeKind2['ZodDate'] = 'ZodDate'; + ZodFirstPartyTypeKind2['ZodSymbol'] = 'ZodSymbol'; + ZodFirstPartyTypeKind2['ZodUndefined'] = 'ZodUndefined'; + ZodFirstPartyTypeKind2['ZodNull'] = 'ZodNull'; + ZodFirstPartyTypeKind2['ZodAny'] = 'ZodAny'; + ZodFirstPartyTypeKind2['ZodUnknown'] = 'ZodUnknown'; + ZodFirstPartyTypeKind2['ZodNever'] = 'ZodNever'; + ZodFirstPartyTypeKind2['ZodVoid'] = 'ZodVoid'; + ZodFirstPartyTypeKind2['ZodArray'] = 'ZodArray'; + ZodFirstPartyTypeKind2['ZodObject'] = 'ZodObject'; + ZodFirstPartyTypeKind2['ZodUnion'] = 'ZodUnion'; + ZodFirstPartyTypeKind2['ZodDiscriminatedUnion'] = 'ZodDiscriminatedUnion'; + ZodFirstPartyTypeKind2['ZodIntersection'] = 'ZodIntersection'; + ZodFirstPartyTypeKind2['ZodTuple'] = 'ZodTuple'; + ZodFirstPartyTypeKind2['ZodRecord'] = 'ZodRecord'; + ZodFirstPartyTypeKind2['ZodMap'] = 'ZodMap'; + ZodFirstPartyTypeKind2['ZodSet'] = 'ZodSet'; + ZodFirstPartyTypeKind2['ZodFunction'] = 'ZodFunction'; + ZodFirstPartyTypeKind2['ZodLazy'] = 'ZodLazy'; + ZodFirstPartyTypeKind2['ZodLiteral'] = 'ZodLiteral'; + ZodFirstPartyTypeKind2['ZodEnum'] = 'ZodEnum'; + ZodFirstPartyTypeKind2['ZodEffects'] = 'ZodEffects'; + ZodFirstPartyTypeKind2['ZodNativeEnum'] = 'ZodNativeEnum'; + ZodFirstPartyTypeKind2['ZodOptional'] = 'ZodOptional'; + ZodFirstPartyTypeKind2['ZodNullable'] = 'ZodNullable'; + ZodFirstPartyTypeKind2['ZodDefault'] = 'ZodDefault'; + ZodFirstPartyTypeKind2['ZodCatch'] = 'ZodCatch'; + ZodFirstPartyTypeKind2['ZodPromise'] = 'ZodPromise'; + ZodFirstPartyTypeKind2['ZodBranded'] = 'ZodBranded'; + ZodFirstPartyTypeKind2['ZodPipeline'] = 'ZodPipeline'; + ZodFirstPartyTypeKind2['ZodReadonly'] = 'ZodReadonly'; })(ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {})); -var instanceOfType = (cls, params = { - message: `Input not instance of ${cls.name}` -}) => custom((data) => data instanceof cls, params); +var instanceOfType = ( + cls, + params = { + message: `Input not instance of ${cls.name}`, + } +) => custom((data) => data instanceof cls, params); var stringType = ZodString.create; var numberType = ZodNumber.create; var nanType = ZodNaN.create; @@ -4183,30 +4389,46 @@ var ostring = () => stringType().optional(); var onumber = () => numberType().optional(); var oboolean = () => booleanType().optional(); var coerce = { - string: ((arg) => ZodString.create({ ...arg, coerce: true })), - number: ((arg) => ZodNumber.create({ ...arg, coerce: true })), - boolean: ((arg) => ZodBoolean.create({ - ...arg, - coerce: true - })), - bigint: ((arg) => ZodBigInt.create({ ...arg, coerce: true })), - date: ((arg) => ZodDate.create({ ...arg, coerce: true })) + string: (arg) => ZodString.create({ ...arg, coerce: true }), + number: (arg) => ZodNumber.create({ ...arg, coerce: true }), + boolean: (arg) => + ZodBoolean.create({ + ...arg, + coerce: true, + }), + bigint: (arg) => ZodBigInt.create({ ...arg, coerce: true }), + date: (arg) => ZodDate.create({ ...arg, coerce: true }), }; var NEVER = INVALID; // ../tools/dist/logger.js -var import_node_util = __toESM(require("util"), 1); +var import_node_util = __toESM(require('util'), 1); var verbose = !!process.env.HARNESS_DEBUG; -var BASE_TAG = "[harness]"; -var getTimestamp = () => (/* @__PURE__ */ new Date()).toISOString(); -var normalizeScope = (scope) => scope.trim().replace(/^\[+|\]+$/g, "").replace(/\]\[/g, "]["); +var BASE_TAG = '[harness]'; +var getTimestamp = () => /* @__PURE__ */ new Date().toISOString(); +var normalizeScope = (scope) => + scope + .trim() + .replace(/^\[+|\]+$/g, '') + .replace(/\]\[/g, ']['); var formatPrefix = (scopes) => { - const suffix = scopes.map((scope) => `[${normalizeScope(scope)}]`).join(""); + const suffix = scopes.map((scope) => `[${normalizeScope(scope)}]`).join(''); return `${BASE_TAG}${suffix}`; }; -var mapLines = (text, prefix) => text.split("\n").map((line) => `${prefix} ${line}`).join("\n"); +var mapLines = (text, prefix) => + text + .split('\n') + .map((line) => `${prefix} ${line}`) + .join('\n'); var writeLog = (level, scopes, messages) => { - const method = level === "warn" ? console.warn : level === "error" ? console.error : level === "debug" ? console.debug : console.info; + const method = + level === 'warn' + ? console.warn + : level === 'error' + ? console.error + : level === 'debug' + ? console.debug + : console.info; const output = import_node_util.default.format(...messages); const prefix = `${getTimestamp()} ${formatPrefix(scopes)}`; method(mapLines(output, prefix)); @@ -4222,128 +4444,163 @@ var createScopedLogger = (scopes = []) => ({ if (!verbose) { return; } - writeLog("debug", scopes, messages); + writeLog('debug', scopes, messages); }, info: (...messages) => { - writeLog("info", scopes, messages); + writeLog('info', scopes, messages); }, warn: (...messages) => { - writeLog("warn", scopes, messages); + writeLog('warn', scopes, messages); }, error: (...messages) => { - writeLog("error", scopes, messages); + writeLog('error', scopes, messages); }, log: (...messages) => { - writeLog("log", scopes, messages); + writeLog('log', scopes, messages); }, success: (...messages) => { - writeLog("success", scopes, messages); + writeLog('success', scopes, messages); }, child: (scope) => createScopedLogger([...scopes, scope]), setVerbose, - isVerbose + isVerbose, }); var logger = createScopedLogger(); // ../../node_modules/@clack/core/dist/index.mjs -var import_node_process = require("process"); -var k = __toESM(require("readline"), 1); -var import_node_readline = __toESM(require("readline"), 1); +var import_node_process = require('process'); +var k = __toESM(require('readline'), 1); +var import_node_readline = __toESM(require('readline'), 1); var import_sisteransi = __toESM(require_src(), 1); -var import_node_tty = require("tty"); -var Ft = { limit: 1 / 0, ellipsis: "" }; -var ft = { limit: 1 / 0, ellipsis: "", ellipsisWidth: 0 }; -var j = "\x07"; -var Q = "["; -var dt = "]"; +var import_node_tty = require('tty'); +var Ft = { limit: 1 / 0, ellipsis: '' }; +var ft = { limit: 1 / 0, ellipsis: '', ellipsisWidth: 0 }; +var j = '\x07'; +var Q = '['; +var dt = ']'; var U = `${dt}8;;`; -var et = new RegExp(`(?:\\${Q}(?\\d+)m|\\${U}(?.*)${j})`, "y"); -var At = ["up", "down", "left", "right", "space", "enter", "cancel"]; -var _ = { actions: new Set(At), aliases: /* @__PURE__ */ new Map([["k", "up"], ["j", "down"], ["h", "left"], ["l", "right"], ["", "cancel"], ["escape", "cancel"]]), messages: { cancel: "Canceled", error: "Something went wrong" }, withGuide: true }; -var bt = globalThis.process.platform.startsWith("win"); +var et = new RegExp(`(?:\\${Q}(?\\d+)m|\\${U}(?.*)${j})`, 'y'); +var At = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel']; +var _ = { + actions: new Set(At), + aliases: /* @__PURE__ */ new Map([ + ['k', 'up'], + ['j', 'down'], + ['h', 'left'], + ['l', 'right'], + ['', 'cancel'], + ['escape', 'cancel'], + ]), + messages: { cancel: 'Canceled', error: 'Something went wrong' }, + withGuide: true, +}; +var bt = globalThis.process.platform.startsWith('win'); // ../../node_modules/@clack/prompts/dist/index.mjs var import_picocolors = __toESM(require_picocolors(), 1); -var import_node_process2 = __toESM(require("process"), 1); -var import_node_fs = require("fs"); -var import_node_path = require("path"); +var import_node_process2 = __toESM(require('process'), 1); +var import_node_fs = require('fs'); +var import_node_path = require('path'); var import_sisteransi2 = __toESM(require_src(), 1); -var import_node_util2 = require("util"); +var import_node_util2 = require('util'); function ht() { - return import_node_process2.default.platform !== "win32" ? import_node_process2.default.env.TERM !== "linux" : !!import_node_process2.default.env.CI || !!import_node_process2.default.env.WT_SESSION || !!import_node_process2.default.env.TERMINUS_SUBLIME || import_node_process2.default.env.ConEmuTask === "{cmd::Cmder}" || import_node_process2.default.env.TERM_PROGRAM === "Terminus-Sublime" || import_node_process2.default.env.TERM_PROGRAM === "vscode" || import_node_process2.default.env.TERM === "xterm-256color" || import_node_process2.default.env.TERM === "alacritty" || import_node_process2.default.env.TERMINAL_EMULATOR === "JetBrains-JediTerm"; + return import_node_process2.default.platform !== 'win32' + ? import_node_process2.default.env.TERM !== 'linux' + : !!import_node_process2.default.env.CI || + !!import_node_process2.default.env.WT_SESSION || + !!import_node_process2.default.env.TERMINUS_SUBLIME || + import_node_process2.default.env.ConEmuTask === '{cmd::Cmder}' || + import_node_process2.default.env.TERM_PROGRAM === 'Terminus-Sublime' || + import_node_process2.default.env.TERM_PROGRAM === 'vscode' || + import_node_process2.default.env.TERM === 'xterm-256color' || + import_node_process2.default.env.TERM === 'alacritty' || + import_node_process2.default.env.TERMINAL_EMULATOR === + 'JetBrains-JediTerm'; } var ee = ht(); -var w = (e, r) => ee ? e : r; -var Me = w("\u25C6", "*"); -var ce = w("\u25A0", "x"); -var de = w("\u25B2", "x"); -var V = w("\u25C7", "o"); -var $e = w("\u250C", "T"); -var h = w("\u2502", "|"); -var x = w("\u2514", "\u2014"); -var Re = w("\u2510", "T"); -var Oe = w("\u2518", "\u2014"); -var Y = w("\u25CF", ">"); -var K = w("\u25CB", " "); -var te = w("\u25FB", "[\u2022]"); -var k2 = w("\u25FC", "[+]"); -var z = w("\u25FB", "[ ]"); -var Pe = w("\u25AA", "\u2022"); -var se = w("\u2500", "-"); -var he = w("\u256E", "+"); -var Ne = w("\u251C", "+"); -var me = w("\u256F", "+"); -var pe = w("\u2570", "+"); -var We = w("\u256D", "+"); -var ge = w("\u25CF", "\u2022"); -var fe = w("\u25C6", "*"); -var Fe = w("\u25B2", "!"); -var ye = w("\u25A0", "x"); -var Ft2 = { limit: 1 / 0, ellipsis: "" }; -var yt2 = { limit: 1 / 0, ellipsis: "", ellipsisWidth: 0 }; -var Ce = "\x07"; -var Ve = "["; -var vt = "]"; +var w = (e, r) => (ee ? e : r); +var Me = w('\u25C6', '*'); +var ce = w('\u25A0', 'x'); +var de = w('\u25B2', 'x'); +var V = w('\u25C7', 'o'); +var $e = w('\u250C', 'T'); +var h = w('\u2502', '|'); +var x = w('\u2514', '\u2014'); +var Re = w('\u2510', 'T'); +var Oe = w('\u2518', '\u2014'); +var Y = w('\u25CF', '>'); +var K = w('\u25CB', ' '); +var te = w('\u25FB', '[\u2022]'); +var k2 = w('\u25FC', '[+]'); +var z = w('\u25FB', '[ ]'); +var Pe = w('\u25AA', '\u2022'); +var se = w('\u2500', '-'); +var he = w('\u256E', '+'); +var Ne = w('\u251C', '+'); +var me = w('\u256F', '+'); +var pe = w('\u2570', '+'); +var We = w('\u256D', '+'); +var ge = w('\u25CF', '\u2022'); +var fe = w('\u25C6', '*'); +var Fe = w('\u25B2', '!'); +var ye = w('\u25A0', 'x'); +var Ft2 = { limit: 1 / 0, ellipsis: '' }; +var yt2 = { limit: 1 / 0, ellipsis: '', ellipsisWidth: 0 }; +var Ce = '\x07'; +var Ve = '['; +var vt = ']'; var we = `${vt}8;;`; -var Ge = new RegExp(`(?:\\${Ve}(?\\d+)m|\\${we}(?.*)${Ce})`, "y"); +var Ge = new RegExp(`(?:\\${Ve}(?\\d+)m|\\${we}(?.*)${Ce})`, 'y'); var Ut = import_picocolors.default.magenta; -var Ye = { light: w("\u2500", "-"), heavy: w("\u2501", "="), block: w("\u2588", "#") }; +var Ye = { + light: w('\u2500', '-'), + heavy: w('\u2501', '='), + block: w('\u2588', '#'), +}; var ze = `${import_picocolors.default.gray(h)} `; // ../tools/dist/spawn.js -var spawnLogger = logger.child("spawn"); +var spawnLogger = logger.child('spawn'); // ../tools/dist/react-native.js -var import_node_module = require("module"); -var import_node_path2 = __toESM(require("path"), 1); -var import_node_fs2 = __toESM(require("fs"), 1); +var import_node_module = require('module'); +var import_node_path2 = __toESM(require('path'), 1); +var import_node_fs2 = __toESM(require('fs'), 1); // ../tools/dist/error.js -var HarnessError = class extends Error { -}; +var HarnessError = class extends Error {}; // ../tools/dist/packages.js -var import_node_path3 = __toESM(require("path"), 1); -var import_node_fs3 = __toESM(require("fs"), 1); +var import_node_path3 = __toESM(require('path'), 1); +var import_node_fs3 = __toESM(require('fs'), 1); // ../tools/dist/crash-artifacts.js -var import_node_fs4 = __toESM(require("fs"), 1); -var import_node_path4 = __toESM(require("path"), 1); -var DEFAULT_ARTIFACT_ROOT = import_node_path4.default.join(process.cwd(), ".harness", "crash-reports"); +var import_node_fs4 = __toESM(require('fs'), 1); +var import_node_path4 = __toESM(require('path'), 1); +var DEFAULT_ARTIFACT_ROOT = import_node_path4.default.join( + process.cwd(), + '.harness', + 'crash-reports' +); // ../plugins/dist/utils.js var isHookTree = (value) => { - if (value == null || typeof value !== "object" || Array.isArray(value)) { + if (value == null || typeof value !== 'object' || Array.isArray(value)) { return false; } for (const child of Object.values(value)) { if (child === void 0) { continue; } - if (typeof child === "function") { + if (typeof child === 'function') { continue; } - if (child == null || typeof child !== "object" || Array.isArray(child) || !isHookTree(child)) { + if ( + child == null || + typeof child !== 'object' || + Array.isArray(child) || + !isHookTree(child) + ) { return false; } } @@ -4352,14 +4609,17 @@ var isHookTree = (value) => { // ../plugins/dist/plugin.js var isHarnessPlugin = (value) => { - if (value == null || typeof value !== "object" || Array.isArray(value)) { + if (value == null || typeof value !== 'object' || Array.isArray(value)) { return false; } const candidate = value; - if (typeof candidate.name !== "string" || candidate.name.length === 0) { + if (typeof candidate.name !== 'string' || candidate.name.length === 0) { return false; } - if (candidate.createState != null && typeof candidate.createState !== "function") { + if ( + candidate.createState != null && + typeof candidate.createState !== 'function' + ) { return false; } if (candidate.hooks != null && !isHookTree(candidate.hooks)) { @@ -4369,50 +4629,125 @@ var isHarnessPlugin = (value) => { }; // ../plugins/dist/manager.js -var pluginsLogger = logger.child("plugins"); +var pluginsLogger = logger.child('plugins'); // ../config/dist/types.js var DEFAULT_METRO_PORT = 8081; var RunnerSchema = external_exports.object({ - name: external_exports.string().min(1, "Runner name is required").regex(/^[a-zA-Z0-9._-]+$/, "Runner name can only contain alphanumeric characters, dots, underscores, and hyphens"), + name: external_exports + .string() + .min(1, 'Runner name is required') + .regex( + /^[a-zA-Z0-9._-]+$/, + 'Runner name can only contain alphanumeric characters, dots, underscores, and hyphens' + ), config: external_exports.record(external_exports.any()), runner: external_exports.string(), - platformId: external_exports.string() -}); -var PluginSchema = external_exports.custom((value) => isHarnessPlugin(value), "Invalid Harness plugin"); -var ConfigSchema = external_exports.object({ - entryPoint: external_exports.string().min(1, "Entry point is required"), - appRegistryComponentName: external_exports.string().min(1, "App registry component name is required"), - runners: external_exports.array(RunnerSchema).min(1, "At least one runner is required"), - plugins: external_exports.array(PluginSchema).optional().default([]), - defaultRunner: external_exports.string().optional(), - host: external_exports.string().min(1, "Host is required").optional(), - metroPort: external_exports.number().int("Metro port must be an integer").min(1, "Metro port must be at least 1").max(65535, "Metro port must be at most 65535").optional().default(DEFAULT_METRO_PORT), - webSocketPort: external_exports.number().optional().describe("Deprecated. Bridge traffic now uses metroPort and this value is ignored."), - bridgeTimeout: external_exports.number().min(1e3, "Bridge timeout must be at least 1 second").default(6e4), - bundleStartTimeout: external_exports.number().min(1e3, "Bundle start timeout must be at least 1 second").default(6e4), - maxAppRestarts: external_exports.number().min(0, "Max app restarts must be at least 0").default(2), - resetEnvironmentBetweenTestFiles: external_exports.boolean().optional().default(true), - unstable__skipAlreadyIncludedModules: external_exports.boolean().optional().default(false), - unstable__enableMetroCache: external_exports.boolean().optional().default(false), - detectNativeCrashes: external_exports.boolean().optional().default(true), - crashDetectionInterval: external_exports.number().min(100, "Crash detection interval must be at least 100ms").default(500), - disableViewFlattening: external_exports.boolean().optional().default(false).describe("Disable view flattening in React Native. This will set collapsable={true} for all View components to ensure they are not flattened by the native layout engine."), - coverage: external_exports.object({ - root: external_exports.string().optional().describe(`Root directory for coverage instrumentation in monorepo setups. Specifies the directory from which coverage data should be collected. Use ".." for create-react-native-library projects where tests run from example/ but source files are in parent directory. Passed to babel-plugin-istanbul's cwd option.`) - }).optional(), - forwardClientLogs: external_exports.boolean().optional().default(false).describe("Enable forwarding of console.log, console.warn, console.error, and other console method calls from the React Native app to the terminal. When enabled, all console output from your app will be displayed in the test runner terminal with styled level indicators (log, warn, error)."), - // Deprecated property - used for migration detection - include: external_exports.array(external_exports.string()).optional() -}).refine((config) => { - if (config.defaultRunner) { - return config.runners.some((runner) => runner.name === config.defaultRunner); - } - return true; -}, { - message: "Default runner must match one of the configured runner names", - path: ["defaultRunner"] + platformId: external_exports.string(), }); +var PluginSchema = external_exports.custom( + (value) => isHarnessPlugin(value), + 'Invalid Harness plugin' +); +var ConfigSchema = external_exports + .object({ + entryPoint: external_exports.string().min(1, 'Entry point is required'), + appRegistryComponentName: external_exports + .string() + .min(1, 'App registry component name is required'), + runners: external_exports + .array(RunnerSchema) + .min(1, 'At least one runner is required'), + plugins: external_exports.array(PluginSchema).optional().default([]), + defaultRunner: external_exports.string().optional(), + host: external_exports.string().min(1, 'Host is required').optional(), + metroPort: external_exports + .number() + .int('Metro port must be an integer') + .min(1, 'Metro port must be at least 1') + .max(65535, 'Metro port must be at most 65535') + .optional() + .default(DEFAULT_METRO_PORT), + webSocketPort: external_exports + .number() + .optional() + .describe( + 'Deprecated. Bridge traffic now uses metroPort and this value is ignored.' + ), + bridgeTimeout: external_exports + .number() + .min(1e3, 'Bridge timeout must be at least 1 second') + .default(6e4), + platformReadyTimeout: external_exports + .number() + .min(1e3, 'Platform ready timeout must be at least 1 second') + .default(3e5), + bundleStartTimeout: external_exports + .number() + .min(1e3, 'Bundle start timeout must be at least 1 second') + .default(6e4), + maxAppRestarts: external_exports + .number() + .min(0, 'Max app restarts must be at least 0') + .default(2), + resetEnvironmentBetweenTestFiles: external_exports + .boolean() + .optional() + .default(true), + unstable__skipAlreadyIncludedModules: external_exports + .boolean() + .optional() + .default(false), + unstable__enableMetroCache: external_exports + .boolean() + .optional() + .default(false), + detectNativeCrashes: external_exports.boolean().optional().default(true), + crashDetectionInterval: external_exports + .number() + .min(100, 'Crash detection interval must be at least 100ms') + .default(500), + disableViewFlattening: external_exports + .boolean() + .optional() + .default(false) + .describe( + 'Disable view flattening in React Native. This will set collapsable={true} for all View components to ensure they are not flattened by the native layout engine.' + ), + coverage: external_exports + .object({ + root: external_exports + .string() + .optional() + .describe( + `Root directory for coverage instrumentation in monorepo setups. Specifies the directory from which coverage data should be collected. Use ".." for create-react-native-library projects where tests run from example/ but source files are in parent directory. Passed to babel-plugin-istanbul's cwd option.` + ), + }) + .optional(), + forwardClientLogs: external_exports + .boolean() + .optional() + .default(false) + .describe( + 'Enable forwarding of console.log, console.warn, console.error, and other console method calls from the React Native app to the terminal. When enabled, all console output from your app will be displayed in the test runner terminal with styled level indicators (log, warn, error).' + ), + // Deprecated property - used for migration detection + include: external_exports.array(external_exports.string()).optional(), + }) + .refine( + (config) => { + if (config.defaultRunner) { + return config.runners.some( + (runner) => runner.name === config.defaultRunner + ); + } + return true; + }, + { + message: 'Default runner must match one of the configured runner names', + path: ['defaultRunner'], + } + ); // ../config/dist/errors.js var ConfigValidationError = class extends HarnessError { @@ -4421,40 +4756,44 @@ var ConfigValidationError = class extends HarnessError { constructor(filePath, validationErrors) { const lines = [ `Configuration validation failed in ${filePath}`, - "", - "The following issues were found:", - "" + '', + 'The following issues were found:', + '', ]; validationErrors.forEach((error, index) => { lines.push(` ${index + 1}. ${error}`); }); - lines.push("", "Please fix these issues and try again.", "For more information, visit: https://react-native-harness.dev/docs/configuration"); - super(lines.join("\n")); + lines.push( + '', + 'Please fix these issues and try again.', + 'For more information, visit: https://react-native-harness.dev/docs/configuration' + ); + super(lines.join('\n')); this.filePath = filePath; this.validationErrors = validationErrors; - this.name = "ConfigValidationError"; + this.name = 'ConfigValidationError'; } }; var ConfigNotFoundError = class extends HarnessError { searchPath; constructor(searchPath) { const lines = [ - "Configuration file not found", - "", + 'Configuration file not found', + '', `Searched for configuration files in: ${searchPath}`, - "and all parent directories.", - "", - "React Native Harness looks for one of these files:", - " \u2022 rn-harness.config.js", - " \u2022 rn-harness.config.mjs", - " \u2022 rn-harness.config.cjs", - " \u2022 rn-harness.config.json", - "", - "For more information, visit: https://www.react-native-harness.dev/docs/getting-started/configuration" + 'and all parent directories.', + '', + 'React Native Harness looks for one of these files:', + ' \u2022 rn-harness.config.js', + ' \u2022 rn-harness.config.mjs', + ' \u2022 rn-harness.config.cjs', + ' \u2022 rn-harness.config.json', + '', + 'For more information, visit: https://www.react-native-harness.dev/docs/getting-started/configuration', ]; - super(lines.join("\n")); + super(lines.join('\n')); this.searchPath = searchPath; - this.name = "ConfigNotFoundError"; + this.name = 'ConfigNotFoundError'; } }; var ConfigLoadError = class extends HarnessError { @@ -4462,30 +4801,44 @@ var ConfigLoadError = class extends HarnessError { cause; constructor(filePath, cause) { const lines = [ - "Failed to load configuration file", - "", + 'Failed to load configuration file', + '', `File: ${filePath}`, - "" + '', ]; if (cause) { - lines.push("Error details:"); + lines.push('Error details:'); lines.push(` ${cause.message}`); - lines.push(""); + lines.push(''); } - lines.push("This could be due to:", " \u2022 Syntax errors in your configuration file", " \u2022 Missing dependencies or modules", " \u2022 Invalid file format or encoding", " \u2022 File permissions issues", "", "Troubleshooting steps:", " 1. Check the file syntax and format", " 2. Ensure all required dependencies are installed", " 3. Verify file permissions", " 4. Try creating a new configuration file", "", "For more help, visit: https://www.react-native-harness.dev/docs/getting-started/configuration"); - super(lines.join("\n")); + lines.push( + 'This could be due to:', + ' \u2022 Syntax errors in your configuration file', + ' \u2022 Missing dependencies or modules', + ' \u2022 Invalid file format or encoding', + ' \u2022 File permissions issues', + '', + 'Troubleshooting steps:', + ' 1. Check the file syntax and format', + ' 2. Ensure all required dependencies are installed', + ' 3. Verify file permissions', + ' 4. Try creating a new configuration file', + '', + 'For more help, visit: https://www.react-native-harness.dev/docs/getting-started/configuration' + ); + super(lines.join('\n')); this.filePath = filePath; - this.name = "ConfigLoadError"; + this.name = 'ConfigLoadError'; this.cause = cause; } }; // ../config/dist/reader.js -var import_node_path5 = __toESM(require("path"), 1); -var import_node_fs5 = __toESM(require("fs"), 1); -var import_node_module2 = require("module"); +var import_node_path5 = __toESM(require('path'), 1); +var import_node_fs5 = __toESM(require('fs'), 1); +var import_node_module2 = require('module'); var import_meta = {}; -var extensions = [".js", ".mjs", ".cjs", ".json"]; +var extensions = ['.js', '.mjs', '.cjs', '.json']; var importUp = async (dir, name) => { const filePath = import_node_path5.default.join(dir, name); for (const ext of extensions) { @@ -4493,14 +4846,21 @@ var importUp = async (dir, name) => { if (import_node_fs5.default.existsSync(filePathWithExt)) { let rawConfig; try { - if (ext === ".mjs") { - rawConfig = await import(filePathWithExt).then((module2) => module2.default); + if (ext === '.mjs') { + rawConfig = await import(filePathWithExt).then( + (module2) => module2.default + ); } else { - const require2 = (0, import_node_module2.createRequire)(import_meta.url); + const require2 = (0, import_node_module2.createRequire)( + import_meta.url + ); rawConfig = require2(filePathWithExt); } } catch (error) { - throw new ConfigLoadError(filePathWithExt, error instanceof Error ? error : void 0); + throw new ConfigLoadError( + filePathWithExt, + error instanceof Error ? error : void 0 + ); } try { const config = ConfigSchema.parse(rawConfig); @@ -4508,7 +4868,8 @@ var importUp = async (dir, name) => { } catch (error) { if (error instanceof ZodError) { const validationErrors = error.errors.map((err) => { - const path6 = err.path.length > 0 ? ` at "${err.path.join(".")}"` : ""; + const path6 = + err.path.length > 0 ? ` at "${err.path.join('.')}"` : ''; return `${err.message}${path6}`; }); throw new ConfigValidationError(filePathWithExt, validationErrors); @@ -4524,35 +4885,43 @@ var importUp = async (dir, name) => { return importUp(parentDir, name); }; var getConfig = async (dir) => { - const { config, configDir } = await importUp(dir, "rn-harness.config"); + const { config, configDir } = await importUp(dir, 'rn-harness.config'); return { config, - projectRoot: configDir + projectRoot: configDir, }; }; // src/shared/index.ts -var import_node_path6 = __toESM(require("path")); -var import_node_fs6 = __toESM(require("fs")); +var import_node_path6 = __toESM(require('path')); +var import_node_fs6 = __toESM(require('fs')); var run = async () => { try { const projectRootInput = process.env.INPUT_PROJECTROOT; const runnerInput = process.env.INPUT_RUNNER; if (!runnerInput) { - throw new Error("Runner input is required"); + throw new Error('Runner input is required'); } - const projectRoot = projectRootInput ? import_node_path6.default.resolve(projectRootInput) : process.cwd(); + const projectRoot = projectRootInput + ? import_node_path6.default.resolve(projectRootInput) + : process.cwd(); console.info(`Loading React Native Harness config from: ${projectRoot}`); - const { config, projectRoot: resolvedProjectRoot } = await getConfig(projectRoot); - const runner = config.runners.find((runner2) => runner2.name === runnerInput); + const { config, projectRoot: resolvedProjectRoot } = await getConfig( + projectRoot + ); + const runner = config.runners.find( + (runner2) => runner2.name === runnerInput + ); if (!runner) { throw new Error(`Runner ${runnerInput} not found in config`); } const githubOutput = process.env.GITHUB_OUTPUT; if (!githubOutput) { - throw new Error("GITHUB_OUTPUT environment variable is not set"); + throw new Error('GITHUB_OUTPUT environment variable is not set'); } - const relativeProjectRoot = import_node_path6.default.relative(process.cwd(), resolvedProjectRoot) || "."; + const relativeProjectRoot = + import_node_path6.default.relative(process.cwd(), resolvedProjectRoot) || + '.'; const output = `config=${JSON.stringify(runner)} projectRoot=${relativeProjectRoot} `; @@ -4561,7 +4930,7 @@ projectRoot=${relativeProjectRoot} if (error instanceof Error) { console.error(error.message); } else { - console.error("Failed to load Harness configuration"); + console.error('Failed to load Harness configuration'); } process.exit(1); } diff --git a/apps/playground/rn-harness.config.mjs b/apps/playground/rn-harness.config.mjs index 2b35e6e1..5561f44b 100644 --- a/apps/playground/rn-harness.config.mjs +++ b/apps/playground/rn-harness.config.mjs @@ -115,6 +115,7 @@ export default { }), ], defaultRunner: 'android', + platformReadyTimeout: 300000, bridgeTimeout: 120000, resetEnvironmentBetweenTestFiles: true, diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 1c5d8370..0c83098d 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -52,6 +52,11 @@ export const ConfigSchema = z .min(1000, 'Bridge timeout must be at least 1 second') .default(60000), + platformReadyTimeout: z + .number() + .min(1000, 'Platform ready timeout must be at least 1 second') + .default(300000), + bundleStartTimeout: z .number() .min(1000, 'Bundle start timeout must be at least 1 second') @@ -77,7 +82,7 @@ export const ConfigSchema = z .default(false) .describe( 'Disable view flattening in React Native. This will set collapsable={true} for all View components ' + - 'to ensure they are not flattened by the native layout engine.' + 'to ensure they are not flattened by the native layout engine.' ), coverage: z @@ -87,9 +92,9 @@ export const ConfigSchema = z .optional() .describe( 'Root directory for coverage instrumentation in monorepo setups. ' + - 'Specifies the directory from which coverage data should be collected. ' + - 'Use ".." for create-react-native-library projects where tests run from example/ ' + - "but source files are in parent directory. Passed to babel-plugin-istanbul's cwd option." + 'Specifies the directory from which coverage data should be collected. ' + + 'Use ".." for create-react-native-library projects where tests run from example/ ' + + "but source files are in parent directory. Passed to babel-plugin-istanbul's cwd option." ), }) .optional(), @@ -100,7 +105,7 @@ export const ConfigSchema = z .default(false) .describe( 'Enable forwarding of console.log, console.warn, console.error, and other console method calls from the React Native app to the terminal. ' + - 'When enabled, all console output from your app will be displayed in the test runner terminal with styled level indicators (log, warn, error).' + 'When enabled, all console output from your app will be displayed in the test runner terminal with styled level indicators (log, warn, error).' ), // Deprecated property - used for migration detection diff --git a/packages/jest/src/__tests__/errors.test.ts b/packages/jest/src/__tests__/errors.test.ts index 02ab758d..34e4ff4f 100644 --- a/packages/jest/src/__tests__/errors.test.ts +++ b/packages/jest/src/__tests__/errors.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { NativeCrashError } from '../errors.js'; +import { NativeCrashError, PlatformReadyTimeoutError } from '../errors.js'; + +describe('PlatformReadyTimeoutError', () => { + it('includes the configured timeout and config hint', () => { + expect(new PlatformReadyTimeoutError(300000).message).toBe( + 'The platform did not become ready within 300000ms. Increase "platformReadyTimeout" if your device, simulator, or emulator needs more time to start.' + ); + }); +}); describe('NativeCrashError', () => { it('formats the extracted stack trace in the error message', () => { @@ -35,7 +43,9 @@ describe('NativeCrashError', () => { exceptionType: 'EXC_CRASH', }); - expect(error.message).not.toContain('notify_get_state check indicated test daemon not ready'); + expect(error.message).not.toContain( + 'notify_get_state check indicated test daemon not ready' + ); expect(error.message).toContain('Signal: SIGABRT'); expect(error.message).toContain('Exception: EXC_CRASH'); expect(error.message).toContain('Process: HarnessPlayground (pid 18007)'); @@ -54,9 +64,9 @@ describe('NativeCrashError', () => { stackTrace: [frame], }); - expect(error.message.match(/MainActivity\.onCreate\(MainActivity\.kt:38\)/g)).toHaveLength( - 1 - ); + expect( + error.message.match(/MainActivity\.onCreate\(MainActivity\.kt:38\)/g) + ).toHaveLength(1); }); it('collapses the native crash stack header so jest does not reprint multiline messages', () => { @@ -65,6 +75,8 @@ describe('NativeCrashError', () => { summary: ['line one', 'line two'].join('\n'), }); - expect(error.stack).toBe('NativeCrashError: The native app crashed while preparing to run this test file.'); + expect(error.stack).toBe( + 'NativeCrashError: The native app crashed while preparing to run this test file.' + ); }); }); diff --git a/packages/jest/src/__tests__/harness.test.ts b/packages/jest/src/__tests__/harness.test.ts index 23c7c7fd..c0a8efa1 100644 --- a/packages/jest/src/__tests__/harness.test.ts +++ b/packages/jest/src/__tests__/harness.test.ts @@ -61,7 +61,7 @@ vi.mock('@react-native-harness/tools', async () => { }); import { getHarness, waitForAppReady } from '../harness.js'; -import { StartupStallError } from '../errors.js'; +import { PlatformReadyTimeoutError, StartupStallError } from '../errors.js'; const createBridgeServer = () => { const emitter = new EventEmitter(); @@ -178,6 +178,7 @@ const createHarnessConfig = ( forwardClientLogs: false, maxAppRestarts: 2, metroPort: 8081, + platformReadyTimeout: 300_000, resetEnvironmentBetweenTestFiles: true, runners: [], unstable__enableMetroCache: false, @@ -293,6 +294,46 @@ describe('waitForAppReady', () => { }); describe('getHarness', () => { + it('fails when the platform runner does not become ready within platformReadyTimeout', async () => { + const { serverBridge } = createBridgeServer(); + const metroInstance = createMetroInstance(); + + mocks.getBridgeServer.mockResolvedValue(serverBridge); + mocks.getMetroInstance.mockResolvedValue(metroInstance); + + ( + globalThis as typeof globalThis & { + __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise; + } + ).__HARNESS_PLATFORM_RUNNER__ = vi.fn( + async () => + await new Promise((_, reject) => { + setTimeout(() => { + reject(new DOMException('The operation was aborted', 'AbortError')); + }, 20); + }) + ); + + const platform: HarnessPlatform = { + config: {}, + name: 'ios', + platformId: 'ios', + runner: `data:text/javascript,${encodeURIComponent( + 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);' + )}`, + }; + + await expect( + getHarness( + createHarnessConfig({ + platformReadyTimeout: 10, + }), + platform, + '/tmp/project' + ) + ).rejects.toBeInstanceOf(PlatformReadyTimeoutError); + }); + it('routes ensureAppReady through the shared Metro startup helper', async () => { const { serverBridge, emitReady } = createBridgeServer(); const appMonitor = createAppMonitor(); @@ -336,7 +377,9 @@ describe('getHarness', () => { }; const harness = await getHarness( - createHarnessConfig(), + createHarnessConfig({ + bridgeTimeout: 1, + }), platform, '/tmp/project' ); diff --git a/packages/jest/src/errors.ts b/packages/jest/src/errors.ts index 0c90b2ca..a7502b85 100644 --- a/packages/jest/src/errors.ts +++ b/packages/jest/src/errors.ts @@ -27,6 +27,15 @@ export class InitializationTimeoutError extends HarnessError { } } +export class PlatformReadyTimeoutError extends HarnessError { + constructor(public readonly timeout: number) { + super( + `The platform did not become ready within ${timeout}ms. Increase "platformReadyTimeout" if your device, simulator, or emulator needs more time to start.` + ); + this.name = 'PlatformReadyTimeoutError'; + } +} + export type NativeCrashPhase = 'startup' | 'execution'; export type NativeCrashDetails = AppCrashDetails & { @@ -51,10 +60,7 @@ const buildNativeCrashMessage = ({ const hasCrashBlock = summary?.includes('\n') ?? false; const shouldRenderSummary = Boolean(summary) && - !( - !hasCrashBlock && - artifactType === 'ios-crash-report' - ); + !(!hasCrashBlock && artifactType === 'ios-crash-report'); if (shouldRenderSummary && summary) { lines.push(''); diff --git a/packages/jest/src/harness.ts b/packages/jest/src/harness.ts index 1165f09d..f9a75852 100644 --- a/packages/jest/src/harness.ts +++ b/packages/jest/src/harness.ts @@ -32,8 +32,13 @@ import { type HarnessRunStatus, type HarnessRunSummary, } from '@react-native-harness/plugins'; -import { logger, createCrashArtifactWriter } from '@react-native-harness/tools'; -import { InitializationTimeoutError } from './errors.js'; +import { + logger, + createCrashArtifactWriter, + getTimeoutSignal, + raceAbortSignals, +} from '@react-native-harness/tools'; +import { PlatformReadyTimeoutError } from './errors.js'; import { Config as HarnessConfig } from '@react-native-harness/config'; import { createCrashSupervisor, @@ -102,6 +107,30 @@ const waitForAbort = (signal: AbortSignal): Promise => { }); }; +const withPlatformReadyTimeout = async (options: { + timeout: number; + signal: AbortSignal; + work: (signal: AbortSignal) => Promise; +}): Promise => { + const timeoutSignal = getTimeoutSignal(options.timeout); + const combinedSignal = raceAbortSignals([options.signal, timeoutSignal]); + + try { + return await options.work(combinedSignal); + } catch (error) { + if ( + error instanceof DOMException && + error.name === 'AbortError' && + timeoutSignal.aborted && + !options.signal.aborted + ) { + throw new PlatformReadyTimeoutError(options.timeout); + } + + throw error; + } +}; + export const waitForAppReady = async (options: { metroInstance: MetroInstance; serverBridge: BridgeServer; @@ -307,12 +336,18 @@ const getHarnessInternal = async ( harnessLogger.debug('Metro initialized'); return instance; }), - import(platform.runner) - .then((module) => module.default(platform.config, config)) - .then((instance) => { - harnessLogger.debug('platform runner initialized'); - return instance; - }), + withPlatformReadyTimeout({ + timeout: config.platformReadyTimeout, + signal, + work: async () => { + return await import(platform.runner) + .then((module) => module.default(platform.config, config)) + .then((instance) => { + harnessLogger.debug('platform runner initialized'); + return instance; + }); + }, + }), ]); } catch (error) { serverBridge.dispose(); @@ -716,25 +751,15 @@ export const getHarness = async ( platform: HarnessPlatform, projectRoot: string ): Promise => { - const abortSignal = AbortSignal.timeout(config.bridgeTimeout); harnessLogger.debug( - 'creating Harness with bridge timeout %dms', - config.bridgeTimeout + 'creating Harness with platform ready timeout %dms', + config.platformReadyTimeout ); - try { - const harness = await getHarnessInternal( - config, - platform, - projectRoot, - abortSignal - ); - return harness; - } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') { - throw new InitializationTimeoutError(); - } - - throw error; - } + return await getHarnessInternal( + config, + platform, + projectRoot, + new AbortController().signal + ); }; diff --git a/packages/platform-android/src/targets.ts b/packages/platform-android/src/targets.ts index 694435e2..e3336964 100644 --- a/packages/platform-android/src/targets.ts +++ b/packages/platform-android/src/targets.ts @@ -1,4 +1,4 @@ -import { RunTarget } from "@react-native-harness/platforms"; +import { RunTarget } from '@react-native-harness/platforms'; import * as adb from './adb.js'; export const getRunTargets = async (): Promise => { @@ -15,9 +15,9 @@ export const getRunTargets = async (): Promise => { name: avd, platform: 'android', description: 'Android emulator', - device: { - name: avd, - }, + device: { + name: avd, + }, }); } @@ -27,10 +27,10 @@ export const getRunTargets = async (): Promise => { name: `${device.manufacturer} ${device.model}`, platform: 'android', description: `Physical device (${device.id})`, - device: { - manufacturer: device.manufacturer, - model: device.model, - }, + device: { + manufacturer: device.manufacturer, + model: device.model, + }, }); } diff --git a/website/src/docs/getting-started/configuration.mdx b/website/src/docs/getting-started/configuration.mdx index d57e97f8..4a45f4c7 100644 --- a/website/src/docs/getting-started/configuration.mdx +++ b/website/src/docs/getting-started/configuration.mdx @@ -86,22 +86,24 @@ For Expo projects, the `entryPoint` should be set to the path specified in the ` ## All Configuration Options -| Option | Description | -| :--------------------------------- | :---------------------------------------------------------------------------- | -| `entryPoint` | **Required.** Path to your React Native app's entry point file. | -| `appRegistryComponentName` | **Required.** Name of the component registered with AppRegistry. | -| `runners` | **Required.** Array of test runners (at least one required). | -| `defaultRunner` | Default runner to use when none specified. | -| `host` | Hostname or IP address to bind the Metro server to (default: Metro default). | -| `metroPort` | Port used by Metro and Harness bridge traffic (default: `8081`). | -| `bridgeTimeout` | Bridge timeout in milliseconds (default: `60000`). | -| `resetEnvironmentBetweenTestFiles` | Reset environment between test files (default: `true`). | -| `detectNativeCrashes` | Detect native app crashes during startup and test execution (default: `true`). | -| `crashDetectionInterval` | Interval in milliseconds to check for native crashes (default: `500`). | -| `disableViewFlattening` | Disable view flattening in React Native (default: `false`). | -| `coverage` | Coverage configuration object. | -| `coverage.root` | Root directory for coverage instrumentation (default: `process.cwd()`). | -| `forwardClientLogs` | Forward console logs from the app to the terminal (default: `false`). | +| Option | Description | +| :--------------------------------- | :--------------------------------------------------------------------------------------------------------- | +| `entryPoint` | **Required.** Path to your React Native app's entry point file. | +| `appRegistryComponentName` | **Required.** Name of the component registered with AppRegistry. | +| `runners` | **Required.** Array of test runners (at least one required). | +| `defaultRunner` | Default runner to use when none specified. | +| `host` | Hostname or IP address to bind the Metro server to (default: Metro default). | +| `metroPort` | Port used by Metro and Harness bridge traffic (default: `8081`). | +| `platformReadyTimeout` | Platform-ready timeout in milliseconds (default: `300000`). | +| `bridgeTimeout` | Bridge timeout in milliseconds (default: `60000`). | +| `bundleStartTimeout` | Bundle start timeout in milliseconds (default: `60000`). | +| `resetEnvironmentBetweenTestFiles` | Reset environment between test files (default: `true`). | +| `detectNativeCrashes` | Detect native app crashes during startup and test execution (default: `true`). | +| `crashDetectionInterval` | Interval in milliseconds to check for native crashes (default: `500`). | +| `disableViewFlattening` | Disable view flattening in React Native (default: `false`). | +| `coverage` | Coverage configuration object. | +| `coverage.root` | Root directory for coverage instrumentation (default: `process.cwd()`). | +| `forwardClientLogs` | Forward console logs from the app to the terminal (default: `false`). | | `unstable__enableMetroCache` | Enable Metro transformation cache under `.harness/metro-cache` and log when reusing it (default: `false`). | ## Test Runners @@ -159,9 +161,27 @@ react-native-harness --harnessRunner android react-native-harness --harnessRunner ios ``` +## Platform Ready Timeout + +The platform ready timeout controls how long React Native Harness waits for the selected device, simulator, or emulator to become usable. This includes device discovery, simulator or emulator boot, and platform runtime setup before the app is launched. + +```javascript +{ + platformReadyTimeout: 300000, // 5 minutes in milliseconds +} +``` + +**Default:** 300000 (5 minutes) +**Minimum:** 1000 (1 second) + +Increase this value if you experience startup failures while: + +- Booting cold simulators or emulators +- Starting devices in slower CI environments + ## Bridge Timeout -The bridge timeout controls how long React Native Harness waits for communication between the test runner and the React Native app. This is particularly important for slower devices or complex test setups. +The bridge timeout controls how long React Native Harness waits for the app to report runtime readiness after it has been launched. It does not include simulator or emulator boot time. ```javascript { @@ -174,8 +194,21 @@ The bridge timeout controls how long React Native Harness waits for communicatio Increase this value if you experience timeout errors, especially on: -- Slower devices or simulators - Complex test suites with heavy setup +- Slower app startup after launch + +## Bundle Start Timeout + +The bundle start timeout controls how long React Native Harness waits for the launched app to request its Metro bundle. + +```javascript +{ + bundleStartTimeout: 120000, +} +``` + +**Default:** 60000 (60 seconds) +**Minimum:** 1000 (1 second) ## Environment-Specific Configurations @@ -211,7 +244,8 @@ const config = { }), ], - bridgeTimeout: isCI ? 180000 : 60000, // Longer timeout in CI + platformReadyTimeout: isCI ? 420000 : 300000, + bridgeTimeout: isCI ? 180000 : 60000, }; export default config; From f2695864c27182ea2e0afb8b0d18a21df9ba90ec Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 1 Apr 2026 09:50:10 +0200 Subject: [PATCH 12/26] fix: abort platform startup waits --- packages/jest/src/__tests__/harness.test.ts | 44 +++++++++++++ packages/jest/src/harness.ts | 7 ++- .../src/__tests__/adb.test.ts | 33 ++++++++++ .../src/__tests__/instance.test.ts | 21 +++++-- packages/platform-android/src/adb.ts | 47 ++++++++++---- packages/platform-android/src/instance.ts | 8 ++- packages/platform-android/src/runner.ts | 14 ++++- .../src/__tests__/instance.test.ts | 34 ++++++---- .../platform-ios/src/__tests__/simctl.test.ts | 63 ++++++++++++++----- packages/platform-ios/src/instance.ts | 6 +- packages/platform-ios/src/runner.ts | 10 ++- packages/platform-ios/src/xcrun/simctl.ts | 9 ++- packages/platform-vega/src/runner.ts | 4 +- packages/platform-web/src/runner.ts | 4 +- packages/platforms/src/index.ts | 1 + packages/platforms/src/types.ts | 8 ++- 16 files changed, 248 insertions(+), 65 deletions(-) diff --git a/packages/jest/src/__tests__/harness.test.ts b/packages/jest/src/__tests__/harness.test.ts index c0a8efa1..d2b38bb3 100644 --- a/packages/jest/src/__tests__/harness.test.ts +++ b/packages/jest/src/__tests__/harness.test.ts @@ -334,6 +334,50 @@ describe('getHarness', () => { ).rejects.toBeInstanceOf(PlatformReadyTimeoutError); }); + it('passes a platform init signal to the runner factory', async () => { + const { serverBridge } = createBridgeServer(); + const appMonitor = createAppMonitor(); + const platformInstance = createPlatformRunner({ + createAppMonitor: () => appMonitor.appMonitor, + }); + const metroInstance = createMetroInstance(); + + mocks.getBridgeServer.mockResolvedValue(serverBridge); + mocks.getMetroInstance.mockResolvedValue(metroInstance); + + const runner = vi.fn(async () => platformInstance); + ( + globalThis as typeof globalThis & { + __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise; + } + ).__HARNESS_PLATFORM_RUNNER__ = runner; + + const platform: HarnessPlatform = { + config: {}, + name: 'ios', + platformId: 'ios', + runner: `data:text/javascript,${encodeURIComponent( + 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);' + )}`, + }; + + const harness = await getHarness( + createHarnessConfig(), + platform, + '/tmp/project' + ); + + expect(runner).toHaveBeenCalledWith( + platform.config, + expect.any(Object), + expect.objectContaining({ + signal: expect.any(AbortSignal), + }) + ); + + await harness.dispose(); + }); + it('routes ensureAppReady through the shared Metro startup helper', async () => { const { serverBridge, emitReady } = createBridgeServer(); const appMonitor = createAppMonitor(); diff --git a/packages/jest/src/harness.ts b/packages/jest/src/harness.ts index f9a75852..06e66945 100644 --- a/packages/jest/src/harness.ts +++ b/packages/jest/src/harness.ts @@ -14,6 +14,7 @@ import { type AppMonitorEvent, type AppLaunchOptions, HarnessPlatform, + type HarnessPlatformInitOptions, HarnessPlatformRunner, } from '@react-native-harness/platforms'; import { @@ -341,7 +342,11 @@ const getHarnessInternal = async ( signal, work: async () => { return await import(platform.runner) - .then((module) => module.default(platform.config, config)) + .then((module) => + module.default(platform.config, config, { + signal, + } satisfies HarnessPlatformInitOptions) + ) .then((instance) => { harnessLogger.debug('platform runner initialized'); return instance; diff --git a/packages/platform-android/src/__tests__/adb.test.ts b/packages/platform-android/src/__tests__/adb.test.ts index d63d896f..02a31b6b 100644 --- a/packages/platform-android/src/__tests__/adb.test.ts +++ b/packages/platform-android/src/__tests__/adb.test.ts @@ -6,9 +6,14 @@ import { getStartAppArgs, hasAvd, installApp, + waitForBoot, + waitForEmulator, } from '../adb.js'; import * as tools from '@react-native-harness/tools'; +const createAbortError = () => + new DOMException('The operation was aborted', 'AbortError'); + describe('getStartAppArgs', () => { it('maps supported extras to adb am start flags', () => { expect( @@ -140,4 +145,32 @@ describe('getStartAppArgs', () => { `printf '%s\n%s\n' 'disk.dataPartition.size=1G' 'vm.heapSize=1G' >> "$HOME/.android/avd/Pixel_8_API_35.avd/config.ini"`, ]); }); + + it('aborts while waiting for an emulator to appear', async () => { + vi.useFakeTimers(); + vi.spyOn(tools, 'spawn').mockResolvedValue({ + stdout: 'List of devices attached\n\n', + } as Awaited>); + const controller = new AbortController(); + const waitPromise = waitForEmulator('Pixel_8_API_35', controller.signal); + + await vi.advanceTimersByTimeAsync(1000); + controller.abort(createAbortError()); + + await expect(waitPromise).rejects.toBeInstanceOf(DOMException); + }); + + it('aborts while waiting for boot completion', async () => { + vi.useFakeTimers(); + vi.spyOn(tools, 'spawn').mockResolvedValue({ + stdout: '0\n', + } as Awaited>); + const controller = new AbortController(); + const waitPromise = waitForBoot('emulator-5554', controller.signal); + + await vi.advanceTimersByTimeAsync(1000); + controller.abort(createAbortError()); + + await expect(waitPromise).rejects.toBeInstanceOf(DOMException); + }); }); diff --git a/packages/platform-android/src/__tests__/instance.test.ts b/packages/platform-android/src/__tests__/instance.test.ts index 23e99215..76fdf4c2 100644 --- a/packages/platform-android/src/__tests__/instance.test.ts +++ b/packages/platform-android/src/__tests__/instance.test.ts @@ -17,6 +17,9 @@ import { HarnessAppPathError, HarnessEmulatorConfigError } from '../errors.js'; const harnessConfig = { metroPort: DEFAULT_METRO_PORT, } as HarnessConfig; +const init = { + signal: new AbortController().signal, +}; describe('Android platform instance', () => { beforeEach(() => { @@ -57,7 +60,8 @@ describe('Android platform instance', () => { bundleId: 'com.harnessplayground', activityName: '.MainActivity', }, - harnessConfig + harnessConfig, + init ); await instance.dispose(); @@ -103,7 +107,8 @@ describe('Android platform instance', () => { bundleId: 'com.harnessplayground', activityName: '.MainActivity', }, - harnessConfig + harnessConfig, + init ); expect(createAvd).toHaveBeenCalledWith({ @@ -153,7 +158,8 @@ describe('Android platform instance', () => { bundleId: 'com.harnessplayground', activityName: '.MainActivity', }, - harnessConfig + harnessConfig, + init ) ).resolves.toBeDefined(); @@ -185,7 +191,8 @@ describe('Android platform instance', () => { bundleId: 'com.harnessplayground', activityName: '.MainActivity', }, - harnessConfig + harnessConfig, + init ) ).rejects.toBeInstanceOf(HarnessAppPathError); }); @@ -214,7 +221,8 @@ describe('Android platform instance', () => { bundleId: 'com.harnessplayground', activityName: '.MainActivity', }, - harnessConfig + harnessConfig, + init ) ).rejects.toBeInstanceOf(HarnessAppPathError); }); @@ -233,7 +241,8 @@ describe('Android platform instance', () => { bundleId: 'com.harnessplayground', activityName: '.MainActivity', }, - harnessConfig + harnessConfig, + init ) ).rejects.toBeInstanceOf(HarnessEmulatorConfigError); }); diff --git a/packages/platform-android/src/adb.ts b/packages/platform-android/src/adb.ts index 941df119..b1255fb3 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -14,6 +14,33 @@ const wait = async (ms: number): Promise => { }); }; +const waitForAbort = (signal: AbortSignal): Promise => { + if (signal.aborted) { + return Promise.reject(signal.reason); + } + + return new Promise((_, reject) => { + signal.addEventListener( + 'abort', + () => { + reject(signal.reason); + }, + { once: true } + ); + }); +}; + +const waitWithSignal = async ( + ms: number, + signal: AbortSignal +): Promise => { + if (signal.aborted) { + throw signal.reason; + } + + await Promise.race([wait(ms), waitForAbort(signal)]); +}; + const getSystemImagePackage = (apiLevel: number): string => { return `system-images;android-${apiLevel};default;x86_64`; }; @@ -261,11 +288,9 @@ export const startEmulator = async (name: string): Promise => { export const waitForEmulator = async ( name: string, - timeoutMs: number = 120000 + signal: AbortSignal ): Promise => { - const startedAt = Date.now(); - - while (Date.now() - startedAt < timeoutMs) { + while (!signal.aborted) { const adbIds = await getDeviceIds(); for (const adbId of adbIds) { @@ -280,27 +305,25 @@ export const waitForEmulator = async ( } } - await wait(1000); + await waitWithSignal(1000, signal); } - throw new Error(`Timed out waiting for emulator "${name}" to appear in adb.`); + throw signal.reason; }; export const waitForBoot = async ( adbId: string, - timeoutMs: number = 300000 + signal: AbortSignal ): Promise => { - const startedAt = Date.now(); - - while (Date.now() - startedAt < timeoutMs) { + while (!signal.aborted) { if (await isBootCompleted(adbId)) { return; } - await wait(1000); + await waitWithSignal(1000, signal); } - throw new Error(`Timed out waiting for emulator "${adbId}" to boot.`); + throw signal.reason; }; export const isAppRunning = async ( diff --git a/packages/platform-android/src/instance.ts b/packages/platform-android/src/instance.ts index 38ffa167..af053c8c 100644 --- a/packages/platform-android/src/instance.ts +++ b/packages/platform-android/src/instance.ts @@ -2,6 +2,7 @@ import { AppNotInstalledError, CreateAppMonitorOptions, DeviceNotFoundError, + type HarnessPlatformInitOptions, HarnessPlatformRunner, } from '@react-native-harness/platforms'; import type { Config as HarnessConfig } from '@react-native-harness/config'; @@ -57,7 +58,8 @@ const configureAndroidRuntime = async ( export const getAndroidEmulatorPlatformInstance = async ( config: AndroidPlatformConfig, - harnessConfig: HarnessConfig + harnessConfig: HarnessConfig, + init: HarnessPlatformInitOptions ): Promise => { assertAndroidDeviceEmulator(config.device); @@ -96,7 +98,7 @@ export const getAndroidEmulatorPlatformInstance = async ( config.device.name ); await adb.startEmulator(config.device.name); - adbId = await adb.waitForEmulator(config.device.name); + adbId = await adb.waitForEmulator(config.device.name, init.signal); startedByHarness = true; androidInstanceLogger.debug( @@ -114,7 +116,7 @@ export const getAndroidEmulatorPlatformInstance = async ( 'waiting for Android emulator %s to finish booting', adbId ); - await adb.waitForBoot(adbId); + await adb.waitForBoot(adbId, init.signal); const isInstalled = await adb.isAppInstalled(adbId, config.bundleId); diff --git a/packages/platform-android/src/runner.ts b/packages/platform-android/src/runner.ts index f62ccd63..c0205e94 100644 --- a/packages/platform-android/src/runner.ts +++ b/packages/platform-android/src/runner.ts @@ -1,4 +1,7 @@ -import { HarnessPlatformRunner } from '@react-native-harness/platforms'; +import { + HarnessPlatformRunner, + type HarnessPlatformInitOptions, +} from '@react-native-harness/platforms'; import type { Config as HarnessConfig } from '@react-native-harness/config'; import { AndroidPlatformConfigSchema, @@ -13,14 +16,19 @@ import { initializeAndroidProcessEnv } from './environment.js'; const getAndroidRunner = async ( config: AndroidPlatformConfig, - harnessConfig: HarnessConfig + harnessConfig: HarnessConfig, + init: HarnessPlatformInitOptions ): Promise => { const parsedConfig = AndroidPlatformConfigSchema.parse(config); initializeAndroidProcessEnv(); if (isAndroidDeviceEmulator(parsedConfig.device)) { - return getAndroidEmulatorPlatformInstance(parsedConfig, harnessConfig); + return getAndroidEmulatorPlatformInstance( + parsedConfig, + harnessConfig, + init + ); } return getAndroidPhysicalDevicePlatformInstance(parsedConfig, harnessConfig); diff --git a/packages/platform-ios/src/__tests__/instance.test.ts b/packages/platform-ios/src/__tests__/instance.test.ts index d0cb24ab..891d8a89 100644 --- a/packages/platform-ios/src/__tests__/instance.test.ts +++ b/packages/platform-ios/src/__tests__/instance.test.ts @@ -18,6 +18,9 @@ import { join } from 'node:path'; const harnessConfig = { metroPort: DEFAULT_METRO_PORT, } as HarnessConfig; +const init = { + signal: new AbortController().signal, +}; describe('iOS platform instance dependency validation', () => { beforeEach(() => { @@ -47,7 +50,7 @@ describe('iOS platform instance dependency validation', () => { }; await expect( - getAppleSimulatorPlatformInstance(config, harnessConfig) + getAppleSimulatorPlatformInstance(config, harnessConfig, init) ).resolves.toBeDefined(); expect(assertInstalled).not.toHaveBeenCalled(); }); @@ -94,7 +97,7 @@ describe('iOS platform instance dependency validation', () => { }; await expect( - getAppleSimulatorPlatformInstance(config, harnessConfig) + getAppleSimulatorPlatformInstance(config, harnessConfig, init) ).resolves.toBeDefined(); expect(getSimulatorId).toHaveBeenCalled(); }); @@ -143,7 +146,8 @@ describe('iOS platform instance dependency validation', () => { }, bundleId: 'com.harnessplayground', }, - harnessConfig + harnessConfig, + init ); expect(applyOverride).toHaveBeenCalledWith( @@ -193,11 +197,12 @@ describe('iOS platform instance dependency validation', () => { }, bundleId: 'com.harnessplayground', }, - harnessConfig + harnessConfig, + init ); expect(bootSimulator).toHaveBeenCalledWith('sim-udid'); - expect(waitForBoot).toHaveBeenCalledWith('sim-udid'); + expect(waitForBoot).toHaveBeenCalledWith('sim-udid', init.signal); await instance.dispose(); @@ -235,11 +240,12 @@ describe('iOS platform instance dependency validation', () => { }, bundleId: 'com.harnessplayground', }, - harnessConfig + harnessConfig, + init ); expect(bootSimulator).not.toHaveBeenCalled(); - expect(waitForBoot).toHaveBeenCalledWith('sim-udid'); + expect(waitForBoot).toHaveBeenCalledWith('sim-udid', init.signal); await instance.dispose(); @@ -277,11 +283,12 @@ describe('iOS platform instance dependency validation', () => { }, bundleId: 'com.harnessplayground', }, - harnessConfig + harnessConfig, + init ); expect(bootSimulator).toHaveBeenCalledWith('sim-udid'); - expect(waitForBoot).toHaveBeenCalledWith('sim-udid'); + expect(waitForBoot).toHaveBeenCalledWith('sim-udid', init.signal); await instance.dispose(); @@ -315,7 +322,8 @@ describe('iOS platform instance dependency validation', () => { }, bundleId: 'com.harnessplayground', }, - harnessConfig + harnessConfig, + init ) ).resolves.toBeDefined(); @@ -341,7 +349,8 @@ describe('iOS platform instance dependency validation', () => { }, bundleId: 'com.harnessplayground', }, - harnessConfig + harnessConfig, + init ) ).rejects.toBeInstanceOf(HarnessAppPathError); }); @@ -366,7 +375,8 @@ describe('iOS platform instance dependency validation', () => { }, bundleId: 'com.harnessplayground', }, - harnessConfig + harnessConfig, + init ) ).rejects.toBeInstanceOf(HarnessAppPathError); }); diff --git a/packages/platform-ios/src/__tests__/simctl.test.ts b/packages/platform-ios/src/__tests__/simctl.test.ts index 4cb41d0c..2f179d1d 100644 --- a/packages/platform-ios/src/__tests__/simctl.test.ts +++ b/packages/platform-ios/src/__tests__/simctl.test.ts @@ -3,7 +3,29 @@ import fs from 'node:fs'; import { join } from 'node:path'; import { homedir, tmpdir } from 'node:os'; import { createCrashArtifactWriter } from '@react-native-harness/tools'; -import { collectCrashReports } from '../xcrun/simctl.js'; +import * as tools from '@react-native-harness/tools'; +import { collectCrashReports, waitForBoot } from '../xcrun/simctl.js'; + +describe('simctl startup', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('passes the abort signal to simctl bootstatus', async () => { + const signal = new AbortController().signal; + const spawnSpy = vi + .spyOn(tools, 'spawn') + .mockResolvedValue({} as Awaited>); + + await waitForBoot('sim-udid', signal); + + expect(spawnSpy).toHaveBeenCalledWith( + 'xcrun', + ['simctl', 'bootstatus', 'sim-udid', '-b'], + { signal } + ); + }); +}); describe('simctl collectCrashReports', () => { beforeEach(() => { @@ -33,8 +55,7 @@ describe('simctl collectCrashReports', () => { JSON.stringify({ pid: 1234, procName: 'HarnessPlayground', - procPath: - `${homedir()}/Library/Developer/CoreSimulator/Devices/sim-udid/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground`, + procPath: `${homedir()}/Library/Developer/CoreSimulator/Devices/sim-udid/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground`, faultingThread: 0, threads: [ { @@ -118,8 +139,7 @@ describe('simctl collectCrashReports', () => { JSON.stringify({ pid: 1234, procName: 'HarnessPlayground', - procPath: - `${homedir()}/Library/Developer/CoreSimulator/Devices/sim-udid/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground`, + procPath: `${homedir()}/Library/Developer/CoreSimulator/Devices/sim-udid/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground`, exception: { type: 'EXC_BREAKPOINT', signal: 'SIGTRAP', @@ -130,7 +150,9 @@ describe('simctl collectCrashReports', () => { vi.spyOn(fs, 'statSync').mockReturnValue({ mtimeMs: 123456, } as fs.Stats); - const copyFileSyncSpy = vi.spyOn(fs, 'copyFileSync').mockImplementation(() => undefined); + const copyFileSyncSpy = vi + .spyOn(fs, 'copyFileSync') + .mockImplementation(() => undefined); const writer = createCrashArtifactWriter({ runnerName: 'ios-sim', platformId: 'ios', @@ -158,7 +180,9 @@ describe('simctl collectCrashReports', () => { 'HarnessPlayground-2026-03-12-113008.ips', 'HarnessPlayground-2026-03-12-114008.ips', ] as unknown as ReturnType); - vi.spyOn(fs, 'readFileSync').mockImplementation(((input: fs.PathOrFileDescriptor) => { + vi.spyOn(fs, 'readFileSync').mockImplementation((( + input: fs.PathOrFileDescriptor + ) => { const filePath = String(input); return [ @@ -170,8 +194,7 @@ describe('simctl collectCrashReports', () => { JSON.stringify({ pid: filePath.includes('113008') ? 1234 : 1235, procName: 'HarnessPlayground', - procPath: - `${homedir()}/Library/Developer/CoreSimulator/Devices/sim-udid/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground`, + procPath: `${homedir()}/Library/Developer/CoreSimulator/Devices/sim-udid/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground`, exception: { type: 'EXC_BREAKPOINT', signal: 'SIGTRAP', @@ -203,19 +226,27 @@ describe('simctl collectCrashReports', () => { 'HarnessPlayground-2026-03-12-120000.ips', 'HarnessPlayground-2026-03-12-130000.ips', ] as unknown as ReturnType); - vi.spyOn(fs, 'readFileSync').mockImplementation(((input: fs.PathOrFileDescriptor) => { + vi.spyOn(fs, 'readFileSync').mockImplementation((( + input: fs.PathOrFileDescriptor + ) => { const filePath = String(input); // The newest file (130000) belongs to a different simulator; the second-newest (120000) is ours const udid = filePath.includes('130000') ? 'other-sim-udid' : 'sim-udid'; - const pid = filePath.includes('110000') ? 1001 : filePath.includes('120000') ? 1002 : 1003; + const pid = filePath.includes('110000') + ? 1001 + : filePath.includes('120000') + ? 1002 + : 1003; return [ - JSON.stringify({ app_name: 'HarnessPlayground', bundleID: 'com.harnessplayground' }), + JSON.stringify({ + app_name: 'HarnessPlayground', + bundleID: 'com.harnessplayground', + }), JSON.stringify({ pid, procName: 'HarnessPlayground', - procPath: - `${homedir()}/Library/Developer/CoreSimulator/Devices/${udid}/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground`, + procPath: `${homedir()}/Library/Developer/CoreSimulator/Devices/${udid}/data/Containers/Bundle/Application/ABC/HarnessPlayground.app/HarnessPlayground`, exception: { type: 'EXC_BREAKPOINT', signal: 'SIGTRAP' }, }), ].join('\n'); @@ -225,8 +256,8 @@ describe('simctl collectCrashReports', () => { const mtimeMs = filePath.includes('110000') ? Date.parse('2026-03-12T11:00:00.000Z') : filePath.includes('120000') - ? Date.parse('2026-03-12T12:00:00.000Z') - : Date.parse('2026-03-12T13:00:00.000Z'); + ? Date.parse('2026-03-12T12:00:00.000Z') + : Date.parse('2026-03-12T13:00:00.000Z'); return { mtimeMs } as fs.Stats; }) as typeof fs.statSync); diff --git a/packages/platform-ios/src/instance.ts b/packages/platform-ios/src/instance.ts index 7f64623f..9089021b 100644 --- a/packages/platform-ios/src/instance.ts +++ b/packages/platform-ios/src/instance.ts @@ -2,6 +2,7 @@ import { AppNotInstalledError, CreateAppMonitorOptions, DeviceNotFoundError, + type HarnessPlatformInitOptions, HarnessPlatformRunner, } from '@react-native-harness/platforms'; import { @@ -43,7 +44,8 @@ const getHarnessAppPath = (): string => { export const getAppleSimulatorPlatformInstance = async ( config: ApplePlatformConfig, - harnessConfig: HarnessConfig + harnessConfig: HarnessConfig, + init: HarnessPlatformInitOptions ): Promise => { assertAppleDeviceSimulator(config.device); @@ -83,7 +85,7 @@ export const getAppleSimulatorPlatformInstance = async ( 'waiting for iOS simulator %s to finish booting', udid ); - await simctl.waitForBoot(udid); + await simctl.waitForBoot(udid, init.signal); } const isInstalled = await simctl.isAppInstalled(udid, config.bundleId); diff --git a/packages/platform-ios/src/runner.ts b/packages/platform-ios/src/runner.ts index 350de6e4..59b464df 100644 --- a/packages/platform-ios/src/runner.ts +++ b/packages/platform-ios/src/runner.ts @@ -1,4 +1,7 @@ -import { HarnessPlatformRunner } from '@react-native-harness/platforms'; +import { + HarnessPlatformRunner, + type HarnessPlatformInitOptions, +} from '@react-native-harness/platforms'; import type { Config as HarnessConfig } from '@react-native-harness/config'; import { ApplePlatformConfigSchema, @@ -12,12 +15,13 @@ import { const getAppleRunner = async ( config: ApplePlatformConfig, - harnessConfig: HarnessConfig + harnessConfig: HarnessConfig, + init: HarnessPlatformInitOptions ): Promise => { const parsedConfig = ApplePlatformConfigSchema.parse(config); if (isAppleDeviceSimulator(parsedConfig.device)) { - return getAppleSimulatorPlatformInstance(parsedConfig, harnessConfig); + return getAppleSimulatorPlatformInstance(parsedConfig, harnessConfig, init); } return getApplePhysicalDevicePlatformInstance(parsedConfig, harnessConfig); diff --git a/packages/platform-ios/src/xcrun/simctl.ts b/packages/platform-ios/src/xcrun/simctl.ts index f1665606..195c7843 100644 --- a/packages/platform-ios/src/xcrun/simctl.ts +++ b/packages/platform-ios/src/xcrun/simctl.ts @@ -300,8 +300,13 @@ export const bootSimulator = async (udid: string): Promise => { await spawn('xcrun', ['simctl', 'boot', udid]); }; -export const waitForBoot = async (udid: string): Promise => { - await spawn('xcrun', ['simctl', 'bootstatus', udid, '-b']); +export const waitForBoot = async ( + udid: string, + signal: AbortSignal +): Promise => { + await spawn('xcrun', ['simctl', 'bootstatus', udid, '-b'], { + signal, + }); }; export const shutdownSimulator = async (udid: string): Promise => { diff --git a/packages/platform-vega/src/runner.ts b/packages/platform-vega/src/runner.ts index 14b6b3b4..5027efc0 100644 --- a/packages/platform-vega/src/runner.ts +++ b/packages/platform-vega/src/runner.ts @@ -4,6 +4,7 @@ import { DeviceNotFoundError, AppNotInstalledError, type CreateAppMonitorOptions, + type HarnessPlatformInitOptions, HarnessPlatformRunner, } from '@react-native-harness/platforms'; import { getEmitter } from '@react-native-harness/tools'; @@ -67,7 +68,8 @@ const createPollingAppMonitor = ({ }; const getVegaRunner = async ( - config: VegaPlatformConfig + config: VegaPlatformConfig, + _init?: HarnessPlatformInitOptions ): Promise => { const parsedConfig = VegaPlatformConfigSchema.parse(config); const deviceId = parsedConfig.device.deviceId; diff --git a/packages/platform-web/src/runner.ts b/packages/platform-web/src/runner.ts index f77a1660..c52e7283 100644 --- a/packages/platform-web/src/runner.ts +++ b/packages/platform-web/src/runner.ts @@ -2,6 +2,7 @@ import { type AppMonitor, type AppMonitorEvent, type CreateAppMonitorOptions, + type HarnessPlatformInitOptions, HarnessPlatformRunner, } from '@react-native-harness/platforms'; import { chromium, firefox, webkit, type Browser, type Page } from 'playwright'; @@ -65,7 +66,8 @@ const createPollingAppMonitor = ({ }; const getWebRunner = async ( - config: WebPlatformConfig + config: WebPlatformConfig, + _init?: HarnessPlatformInitOptions ): Promise => { const parsedConfig = WebPlatformConfigSchema.parse(config); diff --git a/packages/platforms/src/index.ts b/packages/platforms/src/index.ts index 35369eb7..c9eac857 100644 --- a/packages/platforms/src/index.ts +++ b/packages/platforms/src/index.ts @@ -11,6 +11,7 @@ export type { CrashArtifactWriter, CreateAppMonitorOptions, HarnessPlatform, + HarnessPlatformInitOptions, HarnessPlatformRunner, RunTarget, VegaAppLaunchOptions, diff --git a/packages/platforms/src/types.ts b/packages/platforms/src/types.ts index d61cb108..a5c58a10 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -7,9 +7,7 @@ export type AppCrashDetails = { pid?: number; stackTrace?: string[]; rawLines?: string[]; - artifactType?: - | 'logcat' - | 'ios-crash-report'; + artifactType?: 'logcat' | 'ios-crash-report'; artifactPath?: string; }; @@ -112,6 +110,10 @@ export type HarnessPlatformRunner = { ) => Promise; }; +export type HarnessPlatformInitOptions = { + signal: AbortSignal; +}; + export type HarnessPlatform> = { name: string; config: TConfig; From 7db4a1e43aad9277208073c41b9c448b71c4bb70 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 1 Apr 2026 11:07:01 +0200 Subject: [PATCH 13/26] fix: capture Android emulator startup failures --- .../src/__tests__/adb.test.ts | 133 ++++++++++++++- packages/platform-android/src/adb.ts | 160 +++++++++++++++++- 2 files changed, 276 insertions(+), 17 deletions(-) diff --git a/packages/platform-android/src/__tests__/adb.test.ts b/packages/platform-android/src/__tests__/adb.test.ts index 02a31b6b..8d14b0ed 100644 --- a/packages/platform-android/src/__tests__/adb.test.ts +++ b/packages/platform-android/src/__tests__/adb.test.ts @@ -1,11 +1,15 @@ -import { describe, expect, it, vi } from 'vitest'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createAvd, + emulatorProcess, getAppUid, getLogcatTimestamp, getStartAppArgs, hasAvd, installApp, + startEmulator, waitForBoot, waitForEmulator, } from '../adb.js'; @@ -14,6 +18,24 @@ import * as tools from '@react-native-harness/tools'; const createAbortError = () => new DOMException('The operation was aborted', 'AbortError'); +const createMockChildProcess = () => { + const process = new EventEmitter() as EventEmitter & { + stdout: PassThrough; + stderr: PassThrough; + unref: ReturnType; + }; + + process.stdout = new PassThrough(); + process.stderr = new PassThrough(); + process.unref = vi.fn(); + + return process; +}; + +beforeEach(() => { + vi.restoreAllMocks(); +}); + describe('getStartAppArgs', () => { it('maps supported extras to adb am start flags', () => { expect( @@ -66,7 +88,7 @@ describe('getStartAppArgs', () => { 10234 ); - expect(spawnSpy).toHaveBeenCalledWith('adb', [ + expect(spawnSpy).toHaveBeenCalledWith(expect.stringMatching(/adb$/), [ '-s', 'emulator-5554', 'shell', @@ -86,7 +108,7 @@ describe('getStartAppArgs', () => { '03-12 11:35:08.000' ); - expect(spawnSpy).toHaveBeenCalledWith('adb', [ + expect(spawnSpy).toHaveBeenCalledWith(expect.stringMatching(/adb$/), [ '-s', 'emulator-5554', 'shell', @@ -111,7 +133,7 @@ describe('getStartAppArgs', () => { await installApp('emulator-5554', '/tmp/app.apk'); - expect(spawnSpy).toHaveBeenCalledWith('adb', [ + expect(spawnSpy).toHaveBeenCalledWith(expect.stringMatching(/adb$/), [ '-s', 'emulator-5554', 'install', @@ -133,19 +155,112 @@ describe('getStartAppArgs', () => { heapSize: '1G', }); - expect(spawnSpy).toHaveBeenNthCalledWith(1, 'sdkmanager', [ - 'system-images;android-35;default;x86_64', - ]); + expect(spawnSpy).toHaveBeenNthCalledWith( + 1, + expect.stringMatching(/sdkmanager$/), + ['system-images;android-35;default;x86_64'] + ); expect(spawnSpy).toHaveBeenNthCalledWith(2, 'bash', [ '-lc', - `printf 'no\n' | avdmanager create avd --force --name "Pixel_8_API_35" --package "system-images;android-35;default;x86_64" --device "pixel_8"`, + expect.stringContaining( + 'create avd --force --name "Pixel_8_API_35" --package "system-images;android-35;default;x86_64" --device "pixel_8"' + ), ]); expect(spawnSpy).toHaveBeenNthCalledWith(3, 'bash', [ '-lc', - `printf '%s\n%s\n' 'disk.dataPartition.size=1G' 'vm.heapSize=1G' >> "$HOME/.android/avd/Pixel_8_API_35.avd/config.ini"`, + expect.stringContaining( + `'disk.dataPartition.size=1G' 'vm.heapSize=1G' >> ` + ), ]); }); + it('surfaces emulator stdout when startup fails immediately', async () => { + const child = createMockChildProcess(); + let launcherReadyResolve: (() => void) | undefined; + const launcherReady = new Promise((resolve) => { + launcherReadyResolve = resolve; + }); + + vi.spyOn(tools, 'spawn').mockResolvedValue({ + stdout: 'List of devices attached\n\n', + } as Awaited>); + vi.spyOn(emulatorProcess, 'startDetachedProcess').mockImplementation(() => { + launcherReadyResolve?.(); + return child as unknown as ReturnType< + typeof emulatorProcess.startDetachedProcess + >; + }); + + const startPromise = startEmulator('Pixel_8_API_35'); + await launcherReady; + + child.stdout.write('Unknown AVD name [Pixel_8_API_35]\n'); + child.stdout.end(); + child.stderr.end(); + child.emit('close', 1, null); + + await expect(startPromise).rejects.toThrow( + 'Unknown AVD name [Pixel_8_API_35]' + ); + }); + + it('surfaces emulator stderr when startup fails immediately', async () => { + const child = createMockChildProcess(); + let launcherReadyResolve: (() => void) | undefined; + const launcherReady = new Promise((resolve) => { + launcherReadyResolve = resolve; + }); + + vi.spyOn(tools, 'spawn').mockResolvedValue({ + stdout: 'List of devices attached\n\n', + } as Awaited>); + vi.spyOn(emulatorProcess, 'startDetachedProcess').mockImplementation(() => { + launcherReadyResolve?.(); + return child as unknown as ReturnType< + typeof emulatorProcess.startDetachedProcess + >; + }); + + const startPromise = startEmulator('Pixel_8_API_35'); + await launcherReady; + + child.stderr.write('emulator: panic: broken config\n'); + child.stdout.end(); + child.stderr.end(); + child.emit('close', 1, null); + + await expect(startPromise).rejects.toThrow( + 'emulator: panic: broken config' + ); + }); + + it('returns after the emulator appears without waiting for process exit', async () => { + vi.useFakeTimers(); + const child = createMockChildProcess(); + const spawnSpy = vi.spyOn(tools, 'spawn'); + + spawnSpy + .mockResolvedValueOnce({ + stdout: 'List of devices attached\nemulator-5554\tdevice\n', + } as Awaited>) + .mockResolvedValueOnce({ + stdout: 'Pixel_8_API_35\n', + } as Awaited>); + + vi.spyOn(emulatorProcess, 'startDetachedProcess').mockReturnValue( + child as unknown as ReturnType< + typeof emulatorProcess.startDetachedProcess + > + ); + + const startPromise = startEmulator('Pixel_8_API_35'); + + await vi.runAllTimersAsync(); + + await expect(startPromise).resolves.toBeUndefined(); + expect(child.unref).toHaveBeenCalled(); + }); + it('aborts while waiting for an emulator to appear', async () => { vi.useFakeTimers(); vi.spyOn(tools, 'spawn').mockResolvedValue({ diff --git a/packages/platform-android/src/adb.ts b/packages/platform-android/src/adb.ts index b1255fb3..e69dbc77 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -1,6 +1,9 @@ import { type AndroidAppLaunchOptions } from '@react-native-harness/platforms'; import { spawn, SubprocessError } from '@react-native-harness/tools'; +import { spawn as nodeSpawn } from 'node:child_process'; +import type { ChildProcessByStdio } from 'node:child_process'; import { access } from 'node:fs/promises'; +import type { Readable } from 'node:stream'; import { getAdbBinaryPath, getAvdManagerBinaryPath, @@ -50,6 +53,79 @@ const getAvdConfigPath = (name: string): string => process.env.ANDROID_AVD_HOME ?? `${process.env.HOME}/.android/avd` }/${name}.avd/config.ini`; +const EMULATOR_STARTUP_OBSERVATION_TIMEOUT_MS = 5000; +const EMULATOR_OUTPUT_BUFFER_LIMIT = 16 * 1024; + +export const emulatorProcess = { + startDetachedProcess: ( + file: string, + args: readonly string[] + ): ChildProcessByStdio => + nodeSpawn(file, args, { + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + }), +}; + +const appendBoundedOutput = ( + output: string, + chunk: string, + limit: number = EMULATOR_OUTPUT_BUFFER_LIMIT +): string => { + const nextOutput = output + chunk; + + if (nextOutput.length <= limit) { + return nextOutput; + } + + return nextOutput.slice(-limit); +}; + +const formatEmulatorStartupError = ({ + name, + stdout, + stderr, + exitCode, + signal, + error, +}: { + name: string; + stdout: string; + stderr: string; + exitCode?: number | null; + signal?: NodeJS.Signals | null; + error?: unknown; +}): Error => { + const sections = [`Failed to start Android emulator @${name}.`]; + + if (typeof exitCode === 'number') { + sections.push(`Exit code: ${exitCode}`); + } + + if (signal) { + sections.push(`Signal: ${signal}`); + } + + if (error instanceof Error) { + sections.push(`Cause: ${error.message}`); + } + + const trimmedStdout = stdout.trim(); + const trimmedStderr = stderr.trim(); + + if (trimmedStdout !== '') { + sections.push(`stdout:\n${trimmedStdout}`); + } + + if (trimmedStderr !== '') { + sections.push(`stderr:\n${trimmedStderr}`); + } + + return new Error(sections.join('\n\n'), { + cause: error instanceof Error ? error : undefined, + }); +}; + const ensureEmulatorInstalled = async (): Promise => { const emulatorBinaryPath = getEmulatorBinaryPath(); @@ -264,8 +340,7 @@ export const createAvd = async ({ export const startEmulator = async (name: string): Promise => { const emulatorBinaryPath = await ensureEmulatorInstalled(); - - void spawn( + const childProcess = emulatorProcess.startDetachedProcess( emulatorBinaryPath, [ `@${name}`, @@ -277,13 +352,82 @@ export const startEmulator = async (name: string): Promise => { '-no-boot-anim', '-camera-back', 'none', - ], - { - detached: true, - stdout: 'ignore', - stderr: 'ignore', - } + ] + ); + + let stdout = ''; + let stderr = ''; + + childProcess.stdout?.setEncoding('utf8'); + childProcess.stderr?.setEncoding('utf8'); + + const onStdout = (chunk: string | Buffer) => { + stdout = appendBoundedOutput(stdout, chunk.toString()); + }; + const onStderr = (chunk: string | Buffer) => { + stderr = appendBoundedOutput(stderr, chunk.toString()); + }; + + childProcess.stdout?.on('data', onStdout); + childProcess.stderr?.on('data', onStderr); + + const startupAbortController = new AbortController(); + const cleanup = () => { + startupAbortController.abort(); + childProcess.stdout?.off('data', onStdout); + childProcess.stderr?.off('data', onStderr); + childProcess.removeAllListeners('error'); + childProcess.removeAllListeners('close'); + }; + + const earlyExit = new Promise((_, reject) => { + childProcess.once('error', (error) => { + reject( + formatEmulatorStartupError({ + name, + stdout, + stderr, + error, + }) + ); + }); + + childProcess.once('close', (exitCode, signal) => { + reject( + formatEmulatorStartupError({ + name, + stdout, + stderr, + exitCode, + signal, + }) + ); + }); + }); + + const observedBoot = waitForEmulator(name, startupAbortController.signal) + .then(() => 'booted' as const) + .catch((error: unknown) => { + if (startupAbortController.signal.aborted) { + return 'aborted' as const; + } + + throw error; + }); + + const observationTimeout = wait(EMULATOR_STARTUP_OBSERVATION_TIMEOUT_MS).then( + () => 'timeout' as const ); + + try { + await Promise.race([earlyExit, observedBoot, observationTimeout]); + } finally { + cleanup(); + } + + childProcess.stdout?.destroy(); + childProcess.stderr?.destroy(); + childProcess.unref(); }; export const waitForEmulator = async ( From c9f683a8194a41872ddd9ee057bcb3d5f5c8517d Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 1 Apr 2026 12:08:28 +0200 Subject: [PATCH 14/26] fix: self-heal Android SDK and AVD setup --- action.yml | 20 + packages/github-action/src/action.yml | 20 + packages/github-action/src/android/action.yml | 20 + packages/platform-android/README.md | 11 +- .../src/__tests__/adb.test.ts | 49 +- .../src/__tests__/ci-action.test.ts | 24 + .../src/__tests__/environment.test.ts | 87 ++++ .../src/__tests__/instance.test.ts | 65 +++ packages/platform-android/src/adb.ts | 29 +- packages/platform-android/src/environment.ts | 461 +++++++++++++++++- packages/platform-android/src/instance.ts | 5 + packages/platform-android/src/runner.ts | 12 +- packages/platform-android/src/targets.ts | 3 + 13 files changed, 769 insertions(+), 37 deletions(-) create mode 100644 packages/platform-android/src/__tests__/ci-action.test.ts create mode 100644 packages/platform-android/src/__tests__/environment.test.ts diff --git a/action.yml b/action.yml index c41aac43..a0769e94 100644 --- a/action.yml +++ b/action.yml @@ -130,6 +130,26 @@ runs: ~/.android/avd ~/.android/adb* key: ${{ steps.avd-key.outputs.key }} + - name: Verify Android SDK packages + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} + shell: bash + run: | + if [ -n "$ANDROID_HOME" ]; then + SDK_ROOT="$ANDROID_HOME" + elif [ -n "$ANDROID_SDK_ROOT" ]; then + SDK_ROOT="$ANDROID_SDK_ROOT" + elif [ "${{ runner.os }}" = "macOS" ]; then + SDK_ROOT="$HOME/Library/Android/sdk" + else + SDK_ROOT="$HOME/Android/Sdk" + fi + SDKMANAGER_BIN="$SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" + yes | "$SDKMANAGER_BIN" --licenses >/dev/null + yes | "$SDKMANAGER_BIN" \ + "platform-tools" \ + "emulator" \ + "platforms;android-${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }}" \ + "system-images;android-${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }};default;${{ steps.arch.outputs.arch }}" - name: Create AVD and generate snapshot for caching if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} uses: reactivecircus/android-emulator-runner@v2 diff --git a/packages/github-action/src/action.yml b/packages/github-action/src/action.yml index c41aac43..a0769e94 100644 --- a/packages/github-action/src/action.yml +++ b/packages/github-action/src/action.yml @@ -130,6 +130,26 @@ runs: ~/.android/avd ~/.android/adb* key: ${{ steps.avd-key.outputs.key }} + - name: Verify Android SDK packages + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} + shell: bash + run: | + if [ -n "$ANDROID_HOME" ]; then + SDK_ROOT="$ANDROID_HOME" + elif [ -n "$ANDROID_SDK_ROOT" ]; then + SDK_ROOT="$ANDROID_SDK_ROOT" + elif [ "${{ runner.os }}" = "macOS" ]; then + SDK_ROOT="$HOME/Library/Android/sdk" + else + SDK_ROOT="$HOME/Android/Sdk" + fi + SDKMANAGER_BIN="$SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" + yes | "$SDKMANAGER_BIN" --licenses >/dev/null + yes | "$SDKMANAGER_BIN" \ + "platform-tools" \ + "emulator" \ + "platforms;android-${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }}" \ + "system-images;android-${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }};default;${{ steps.arch.outputs.arch }}" - name: Create AVD and generate snapshot for caching if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} uses: reactivecircus/android-emulator-runner@v2 diff --git a/packages/github-action/src/android/action.yml b/packages/github-action/src/android/action.yml index 4dc1ed2b..19c45561 100644 --- a/packages/github-action/src/android/action.yml +++ b/packages/github-action/src/android/action.yml @@ -104,6 +104,26 @@ runs: ~/.android/avd ~/.android/adb* key: ${{ steps.avd-key.outputs.key }} + - name: Verify Android SDK packages + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} + shell: bash + run: | + if [ -n "$ANDROID_HOME" ]; then + SDK_ROOT="$ANDROID_HOME" + elif [ -n "$ANDROID_SDK_ROOT" ]; then + SDK_ROOT="$ANDROID_SDK_ROOT" + elif [ "${{ runner.os }}" = "macOS" ]; then + SDK_ROOT="$HOME/Library/Android/sdk" + else + SDK_ROOT="$HOME/Android/Sdk" + fi + SDKMANAGER_BIN="$SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" + yes | "$SDKMANAGER_BIN" --licenses >/dev/null + yes | "$SDKMANAGER_BIN" \ + "platform-tools" \ + "emulator" \ + "platforms;android-${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }}" \ + "system-images;android-${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }};default;${{ steps.arch.outputs.arch }}" - name: Create AVD and generate snapshot for caching if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} uses: reactivecircus/android-emulator-runner@v2 diff --git a/packages/platform-android/README.md b/packages/platform-android/README.md index 49a0e02b..6932ccab 100644 --- a/packages/platform-android/README.md +++ b/packages/platform-android/README.md @@ -32,7 +32,12 @@ const config = { runners: [ androidPlatform({ name: 'android', - device: androidEmulator('Pixel_8_API_35'), + device: androidEmulator('Pixel_8_API_35', { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + }), bundleId: 'com.your.app', }), androidPlatform({ @@ -78,7 +83,9 @@ Creates a physical Android device configuration. ## Requirements -- Android SDK installed +- On macOS and Linux, Harness can resolve the SDK root from `ANDROID_HOME`, then `ANDROID_SDK_ROOT`, then the default SDK path (`~/Library/Android/sdk` on macOS or `~/Android/Sdk` on Linux) +- For emulator runners with an `avd` config, Harness reads the runner config and automatically verifies or installs missing SDK packages, including `platform-tools`, `emulator`, `platforms;android-`, and the matching system image for the host architecture +- If the SDK root does not exist yet on macOS or Linux, Harness bootstraps Android command-line tools and accepts licenses for non-interactive installs - Android emulator or physical device connected - React Native project configured for Android diff --git a/packages/platform-android/src/__tests__/adb.test.ts b/packages/platform-android/src/__tests__/adb.test.ts index 8d14b0ed..595a89f7 100644 --- a/packages/platform-android/src/__tests__/adb.test.ts +++ b/packages/platform-android/src/__tests__/adb.test.ts @@ -14,6 +14,7 @@ import { waitForEmulator, } from '../adb.js'; import * as tools from '@react-native-harness/tools'; +import * as environment from '../environment.js'; const createAbortError = () => new DOMException('The operation was aborted', 'AbortError'); @@ -146,6 +147,12 @@ describe('getStartAppArgs', () => { const spawnSpy = vi .spyOn(tools, 'spawn') .mockResolvedValue({} as Awaited>); + const verifyAndroidEmulatorSdk = vi + .spyOn(environment, 'ensureAndroidSdkPackages') + .mockResolvedValue('/tmp/android-sdk'); + vi.spyOn(environment, 'getHostAndroidSystemImageArch').mockReturnValue( + 'x86_64' + ); await createAvd({ name: 'Pixel_8_API_35', @@ -155,18 +162,19 @@ describe('getStartAppArgs', () => { heapSize: '1G', }); - expect(spawnSpy).toHaveBeenNthCalledWith( - 1, - expect.stringMatching(/sdkmanager$/), - ['system-images;android-35;default;x86_64'] - ); - expect(spawnSpy).toHaveBeenNthCalledWith(2, 'bash', [ + expect(verifyAndroidEmulatorSdk).toHaveBeenCalledWith([ + 'platform-tools', + 'emulator', + 'platforms;android-35', + 'system-images;android-35;default;x86_64', + ]); + expect(spawnSpy).toHaveBeenNthCalledWith(1, 'bash', [ '-lc', expect.stringContaining( 'create avd --force --name "Pixel_8_API_35" --package "system-images;android-35;default;x86_64" --device "pixel_8"' ), ]); - expect(spawnSpy).toHaveBeenNthCalledWith(3, 'bash', [ + expect(spawnSpy).toHaveBeenNthCalledWith(2, 'bash', [ '-lc', expect.stringContaining( `'disk.dataPartition.size=1G' 'vm.heapSize=1G' >> ` @@ -174,6 +182,33 @@ describe('getStartAppArgs', () => { ]); }); + it('creates an AVD with arm64 system image packages on arm64 hosts', async () => { + vi.spyOn(tools, 'spawn').mockResolvedValue( + {} as Awaited> + ); + const ensureAndroidSdkPackages = vi + .spyOn(environment, 'ensureAndroidSdkPackages') + .mockResolvedValue('/tmp/android-sdk'); + vi.spyOn(environment, 'getHostAndroidSystemImageArch').mockReturnValue( + 'arm64-v8a' + ); + + await createAvd({ + name: 'Pixel_8_API_35', + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + }); + + expect(ensureAndroidSdkPackages).toHaveBeenCalledWith([ + 'platform-tools', + 'emulator', + 'platforms;android-35', + 'system-images;android-35;default;arm64-v8a', + ]); + }); + it('surfaces emulator stdout when startup fails immediately', async () => { const child = createMockChildProcess(); let launcherReadyResolve: (() => void) | undefined; diff --git a/packages/platform-android/src/__tests__/ci-action.test.ts b/packages/platform-android/src/__tests__/ci-action.test.ts new file mode 100644 index 00000000..a77f443b --- /dev/null +++ b/packages/platform-android/src/__tests__/ci-action.test.ts @@ -0,0 +1,24 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const workspaceRoot = path.resolve(import.meta.dirname, '../../../..'); + +describe('Android GitHub action config', () => { + it('keeps SDK verification enabled even when the AVD cache hits', async () => { + const [rootAction, packageAction] = await Promise.all([ + readFile(path.join(workspaceRoot, 'action.yml'), 'utf8'), + readFile( + path.join(workspaceRoot, 'packages/github-action/src/action.yml'), + 'utf8' + ), + ]); + + for (const actionYaml of [rootAction, packageAction]) { + expect(actionYaml).toContain('Verify Android SDK packages'); + expect(actionYaml).toContain( + "steps.avd-cache.outputs.cache-hit != 'true'" + ); + } + }); +}); diff --git a/packages/platform-android/src/__tests__/environment.test.ts b/packages/platform-android/src/__tests__/environment.test.ts new file mode 100644 index 00000000..3fb7c069 --- /dev/null +++ b/packages/platform-android/src/__tests__/environment.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + getAndroidSdkRoot, + getAndroidSystemImagePackage, + getDefaultUnixAndroidSdkRoot, + getHostAndroidSystemImageArch, + getRequiredAndroidSdkPackages, +} from '../environment.js'; + +describe('Android environment', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it('uses the default Unix SDK root when env vars are missing', () => { + expect( + getDefaultUnixAndroidSdkRoot({ + platform: 'darwin', + homeDirectory: '/Users/tester', + }) + ).toBe('/Users/tester/Library/Android/sdk'); + + expect( + getAndroidSdkRoot( + {}, + { + platform: 'linux', + homeDirectory: '/home/tester', + } + ) + ).toBe('/home/tester/Android/Sdk'); + }); + + it('prefers ANDROID_HOME and ANDROID_SDK_ROOT over default paths', () => { + expect( + getAndroidSdkRoot( + { + ANDROID_HOME: '/env/android-home', + ANDROID_SDK_ROOT: '/env/android-sdk-root', + }, + { + platform: 'darwin', + homeDirectory: '/Users/tester', + } + ) + ).toBe('/env/android-home'); + + expect( + getAndroidSdkRoot( + { + ANDROID_SDK_ROOT: '/env/android-sdk-root', + }, + { + platform: 'linux', + homeDirectory: '/home/tester', + } + ) + ).toBe('/env/android-sdk-root'); + }); + + it('selects Android packages using the host architecture', () => { + expect(getHostAndroidSystemImageArch('x64')).toBe('x86_64'); + expect(getHostAndroidSystemImageArch('arm64')).toBe('arm64-v8a'); + expect(getAndroidSystemImagePackage(35, 'x86_64')).toBe( + 'system-images;android-35;default;x86_64' + ); + expect(getAndroidSystemImagePackage(35, 'arm64-v8a')).toBe( + 'system-images;android-35;default;arm64-v8a' + ); + }); + + it('derives emulator package requirements from runner config fields', () => { + expect( + getRequiredAndroidSdkPackages({ + apiLevel: 34, + includeEmulator: true, + architecture: 'x86_64', + }) + ).toEqual([ + 'platform-tools', + 'emulator', + 'platforms;android-34', + 'system-images;android-34;default;x86_64', + ]); + }); +}); diff --git a/packages/platform-android/src/__tests__/instance.test.ts b/packages/platform-android/src/__tests__/instance.test.ts index 76fdf4c2..b3621df3 100644 --- a/packages/platform-android/src/__tests__/instance.test.ts +++ b/packages/platform-android/src/__tests__/instance.test.ts @@ -28,6 +28,12 @@ describe('Android platform instance', () => { }); it('reuses a running emulator and does not shut it down on dispose', async () => { + const ensureAndroidEmulatorEnvironment = vi + .spyOn( + await import('../environment.js'), + 'ensureAndroidEmulatorEnvironment' + ) + .mockResolvedValue('/tmp/android-sdk'); vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']); vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35'); vi.spyOn(adb, 'waitForBoot').mockResolvedValue(undefined); @@ -66,10 +72,15 @@ describe('Android platform instance', () => { await instance.dispose(); + expect(ensureAndroidEmulatorEnvironment).toHaveBeenCalledWith(35); expect(stopEmulator).not.toHaveBeenCalled(); }); it('creates and boots an emulator when missing and shuts it down on dispose', async () => { + vi.spyOn( + await import('../environment.js'), + 'ensureAndroidEmulatorEnvironment' + ).mockResolvedValue('/tmp/android-sdk'); vi.spyOn(adb, 'getDeviceIds').mockResolvedValue([]); vi.spyOn(adb, 'hasAvd').mockResolvedValue(false); const createAvd = vi.spyOn(adb, 'createAvd').mockResolvedValue(undefined); @@ -125,10 +136,64 @@ describe('Android platform instance', () => { expect(stopEmulator).toHaveBeenCalledWith('emulator-5554'); }); + it('verifies SDK assets before booting an existing AVD', async () => { + const ensureAndroidEmulatorEnvironment = vi + .spyOn( + await import('../environment.js'), + 'ensureAndroidEmulatorEnvironment' + ) + .mockResolvedValue('/tmp/android-sdk'); + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue([]); + vi.spyOn(adb, 'hasAvd').mockResolvedValue(true); + const createAvd = vi.spyOn(adb, 'createAvd').mockResolvedValue(undefined); + const startEmulator = vi + .spyOn(adb, 'startEmulator') + .mockResolvedValue(undefined); + vi.spyOn(adb, 'waitForEmulator').mockResolvedValue('emulator-5554'); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue(undefined); + vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); + vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); + vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( + undefined + ); + + await expect( + getAndroidEmulatorPlatformInstance( + { + name: 'android', + device: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + }, + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfig, + init + ) + ).resolves.toBeDefined(); + + expect(ensureAndroidEmulatorEnvironment).toHaveBeenCalledWith(35); + expect(createAvd).not.toHaveBeenCalled(); + expect(startEmulator).toHaveBeenCalledWith('Pixel_8_API_35'); + }); + it('installs the app from HARNESS_APP_PATH when missing', async () => { const appPath = path.join(os.tmpdir(), 'HarnessPlayground.apk'); fs.writeFileSync(appPath, 'apk'); vi.stubEnv('HARNESS_APP_PATH', appPath); + vi.spyOn( + await import('../environment.js'), + 'ensureAndroidEmulatorEnvironment' + ).mockResolvedValue('/tmp/android-sdk'); vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']); vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35'); vi.spyOn(adb, 'waitForBoot').mockResolvedValue(undefined); diff --git a/packages/platform-android/src/adb.ts b/packages/platform-android/src/adb.ts index e69dbc77..2ca74a80 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -5,9 +5,13 @@ import type { ChildProcessByStdio } from 'node:child_process'; import { access } from 'node:fs/promises'; import type { Readable } from 'node:stream'; import { + ensureAndroidSdkPackages, getAdbBinaryPath, + getAndroidSystemImagePackage, getAvdManagerBinaryPath, getEmulatorBinaryPath, + getHostAndroidSystemImageArch, + getRequiredAndroidSdkPackages, getSdkManagerBinaryPath, } from './environment.js'; @@ -44,10 +48,6 @@ const waitWithSignal = async ( await Promise.race([wait(ms), waitForAbort(signal)]); }; -const getSystemImagePackage = (apiLevel: number): string => { - return `system-images;android-${apiLevel};default;x86_64`; -}; - const getAvdConfigPath = (name: string): string => `${ process.env.ANDROID_AVD_HOME ?? `${process.env.HOME}/.android/avd` @@ -147,6 +147,20 @@ export type CreateAvdOptions = { heapSize: string; }; +export const getRequiredEmulatorPackages = (apiLevel: number): string[] => { + return getRequiredAndroidSdkPackages({ + apiLevel, + includeEmulator: true, + architecture: getHostAndroidSystemImageArch(), + }); +}; + +export const verifyAndroidEmulatorSdk = async ( + apiLevel: number +): Promise => { + await ensureAndroidSdkPackages(getRequiredEmulatorPackages(apiLevel)); +}; + export const getStartAppArgs = ( bundleId: string, activityName: string, @@ -323,9 +337,12 @@ export const createAvd = async ({ diskSize, heapSize, }: CreateAvdOptions): Promise => { - const systemImagePackage = getSystemImagePackage(apiLevel); + const systemImagePackage = getAndroidSystemImagePackage( + apiLevel, + getHostAndroidSystemImageArch() + ); - await spawn(getSdkManagerBinaryPath(), [systemImagePackage]); + await verifyAndroidEmulatorSdk(apiLevel); await spawn('bash', [ '-lc', `printf 'no\n' | "${getAvdManagerBinaryPath()}" create avd --force --name "${name}" --package "${systemImagePackage}" --device "${profile}"`, diff --git a/packages/platform-android/src/environment.ts b/packages/platform-android/src/environment.ts index 363d1fbb..dbbf8a6f 100644 --- a/packages/platform-android/src/environment.ts +++ b/packages/platform-android/src/environment.ts @@ -1,16 +1,326 @@ +import { spawn } from '@react-native-harness/tools'; +import { createWriteStream } from 'node:fs'; +import { access, cp, mkdir, mkdtemp, rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import https from 'node:https'; const CMDLINE_TOOLS_PATH_SEGMENTS = ['cmdline-tools', 'latest']; +const ANDROID_REPOSITORY_INDEX_URL = + 'https://dl.google.com/android/repository/repository2-1.xml'; -export const getAndroidSdkRoot = ( +export type AndroidSystemImageArch = 'x86_64' | 'arm64-v8a' | 'armeabi-v7a'; + +type AndroidSdkRootOptions = { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + homeDirectory?: string; +}; + +const getConfiguredAndroidSdkRoot = ( env: NodeJS.ProcessEnv = process.env ): string | null => { return env.ANDROID_HOME ?? env.ANDROID_SDK_ROOT ?? null; }; -const getRequiredAndroidSdkRoot = (): string => { - const sdkRoot = getAndroidSdkRoot(); +export const getDefaultUnixAndroidSdkRoot = ({ + platform = process.platform, + homeDirectory = os.homedir(), +}: Omit = {}): string | null => { + if (platform === 'darwin') { + return path.join(homeDirectory, 'Library', 'Android', 'sdk'); + } + + if (platform === 'linux') { + return path.join(homeDirectory, 'Android', 'Sdk'); + } + + return null; +}; + +const canBootstrapAndroidSdk = ( + platform: NodeJS.Platform = process.platform +) => { + return platform === 'darwin' || platform === 'linux'; +}; + +const pathExists = async (filePath: string): Promise => { + try { + await access(filePath); + return true; + } catch { + return false; + } +}; + +const quoteShell = (value: string): string => { + return `'${value.replace(/'/g, `'\\''`)}'`; +}; + +const downloadText = async (url: string): Promise => { + return new Promise((resolve, reject) => { + const request = https.get(url, (response) => { + const { statusCode = 0, headers } = response; + + if ( + statusCode >= 300 && + statusCode < 400 && + typeof headers.location === 'string' + ) { + response.resume(); + resolve(downloadText(headers.location)); + return; + } + + if (statusCode !== 200) { + response.resume(); + reject( + new Error( + `Failed to download Android repository index from ${url} (status ${statusCode}).` + ) + ); + return; + } + + response.setEncoding('utf8'); + + let body = ''; + response.on('data', (chunk: string) => { + body += chunk; + }); + response.once('end', () => { + resolve(body); + }); + }); + + request.once('error', reject); + }); +}; + +const downloadFile = async ( + url: string, + destinationPath: string +): Promise => { + await new Promise((resolve, reject) => { + const request = https.get(url, (response) => { + const { statusCode = 0, headers } = response; + + if ( + statusCode >= 300 && + statusCode < 400 && + typeof headers.location === 'string' + ) { + response.resume(); + resolve(downloadFile(headers.location, destinationPath)); + return; + } + + if (statusCode !== 200) { + response.resume(); + reject( + new Error( + `Failed to download Android command-line tools from ${url} (status ${statusCode}).` + ) + ); + return; + } + + const output = createWriteStream(destinationPath); + pipeline(response, output).then(resolve).catch(reject); + }); + + request.once('error', reject); + }); +}; + +const getCommandLineToolsArchiveUrl = async ( + platform: NodeJS.Platform = process.platform +): Promise => { + const archivePlatform = + platform === 'darwin' ? 'mac' : platform === 'linux' ? 'linux' : null; + + if (!archivePlatform) { + throw new Error( + 'Automatic Android SDK bootstrap is only supported on macOS and Linux.' + ); + } + + const repositoryIndex = await downloadText(ANDROID_REPOSITORY_INDEX_URL); + const archivePattern = new RegExp( + `commandlinetools-${archivePlatform}-(\\d+)_latest\\.zip`, + 'g' + ); + const matches = [...repositoryIndex.matchAll(archivePattern)]; + + if (matches.length === 0) { + throw new Error( + `Failed to resolve Android command-line tools archive for ${archivePlatform}.` + ); + } + + const newestArchive = matches + .map((match) => ({ + fileName: match[0], + revision: Number(match[1]), + })) + .sort((left, right) => right.revision - left.revision)[0]; + + return `https://dl.google.com/android/repository/${newestArchive.fileName}`; +}; + +const ensureAndroidCommandLineTools = async ( + sdkRoot: string, + platform: NodeJS.Platform = process.platform +): Promise => { + if ( + (await pathExists(getSdkManagerBinaryPath(sdkRoot))) && + (await pathExists(getAvdManagerBinaryPath(sdkRoot))) + ) { + return; + } + + if (!canBootstrapAndroidSdk(platform)) { + throw new Error( + 'Android command-line tools are missing. Set ANDROID_HOME or ANDROID_SDK_ROOT to an initialized SDK.' + ); + } + + await mkdir(sdkRoot, { recursive: true }); + + const temporaryDirectory = await mkdtemp( + path.join(os.tmpdir(), 'android-cmdline-tools-') + ); + const archivePath = path.join(temporaryDirectory, 'cmdline-tools.zip'); + const extractedPath = path.join(temporaryDirectory, 'extracted'); + const sourceDirectory = path.join(extractedPath, 'cmdline-tools'); + const targetDirectory = path.join(sdkRoot, ...CMDLINE_TOOLS_PATH_SEGMENTS); + + try { + await downloadFile( + await getCommandLineToolsArchiveUrl(platform), + archivePath + ); + await spawn('unzip', ['-q', archivePath, '-d', extractedPath]); + await rm(targetDirectory, { force: true, recursive: true }); + await mkdir(path.dirname(targetDirectory), { recursive: true }); + await cp(sourceDirectory, targetDirectory, { recursive: true }); + } finally { + await rm(temporaryDirectory, { force: true, recursive: true }); + } +}; + +const acceptAndroidLicenses = async (sdkRoot: string): Promise => { + const sdkManagerBinaryPath = getSdkManagerBinaryPath(sdkRoot); + + await spawn( + 'bash', + [ + '-lc', + `yes | ${quoteShell(sdkManagerBinaryPath)} --sdk_root=${quoteShell( + sdkRoot + )} --licenses >/dev/null`, + ], + { + env: getAndroidProcessEnv({ + ...process.env, + ANDROID_HOME: sdkRoot, + ANDROID_SDK_ROOT: sdkRoot, + }), + } + ); +}; + +const getPackageVerificationPath = ( + sdkRoot: string, + packageName: string +): string | null => { + if (packageName === 'platform-tools') { + return getAdbBinaryPath(sdkRoot); + } + + if (packageName === 'emulator') { + return getEmulatorBinaryPath(sdkRoot); + } + + if (packageName.startsWith('platforms;android-')) { + return path.join(sdkRoot, packageName.replace(';', '/')); + } + + if (packageName.startsWith('system-images;android-')) { + return path.join(sdkRoot, packageName.replaceAll(';', path.sep)); + } + + return null; +}; + +const getMissingAndroidSdkPackages = async ( + sdkRoot: string, + packages: readonly string[] +): Promise => { + const missingPackages: string[] = []; + + for (const packageName of packages) { + const verificationPath = getPackageVerificationPath(sdkRoot, packageName); + + if (!verificationPath) { + continue; + } + + if (!(await pathExists(verificationPath))) { + missingPackages.push(packageName); + } + } + + return missingPackages; +}; + +const installAndroidSdkPackages = async ( + sdkRoot: string, + packages: readonly string[] +): Promise => { + if (packages.length === 0) { + return; + } + + const sdkManagerBinaryPath = getSdkManagerBinaryPath(sdkRoot); + const packageArgs = packages + .map((packageName) => quoteShell(packageName)) + .join(' '); + + await acceptAndroidLicenses(sdkRoot); + await spawn( + 'bash', + [ + '-lc', + `yes | ${quoteShell(sdkManagerBinaryPath)} --sdk_root=${quoteShell( + sdkRoot + )} ${packageArgs}`, + ], + { + env: getAndroidProcessEnv({ + ...process.env, + ANDROID_HOME: sdkRoot, + ANDROID_SDK_ROOT: sdkRoot, + }), + } + ); +}; + +export const getAndroidSdkRoot = ( + env: NodeJS.ProcessEnv = process.env, + options: Omit = {} +): string | null => { + return ( + getConfiguredAndroidSdkRoot(env) ?? getDefaultUnixAndroidSdkRoot(options) + ); +}; + +const getRequiredAndroidSdkRoot = ( + env: NodeJS.ProcessEnv = process.env, + options: Omit = {} +): string => { + const sdkRoot = getAndroidSdkRoot(env, options); if (!sdkRoot) { throw new Error( @@ -21,6 +331,119 @@ const getRequiredAndroidSdkRoot = (): string => { return sdkRoot; }; +export const getHostAndroidSystemImageArch = ( + architecture: string = process.arch +): AndroidSystemImageArch => { + switch (architecture) { + case 'arm64': + return 'arm64-v8a'; + case 'arm': + return 'armeabi-v7a'; + case 'x64': + default: + return 'x86_64'; + } +}; + +export const getAndroidPlatformPackage = (apiLevel: number): string => { + return `platforms;android-${apiLevel}`; +}; + +export const getAndroidSystemImagePackage = ( + apiLevel: number, + architecture: AndroidSystemImageArch = getHostAndroidSystemImageArch() +): string => { + return `system-images;android-${apiLevel};default;${architecture}`; +}; + +export const getRequiredAndroidSdkPackages = ({ + apiLevel, + includeEmulator = false, + architecture = getHostAndroidSystemImageArch(), +}: { + apiLevel?: number; + includeEmulator?: boolean; + architecture?: AndroidSystemImageArch; +} = {}): string[] => { + const packages = ['platform-tools']; + + if (!includeEmulator) { + return packages; + } + + packages.push('emulator'); + + if (typeof apiLevel === 'number') { + packages.push(getAndroidPlatformPackage(apiLevel)); + packages.push(getAndroidSystemImagePackage(apiLevel, architecture)); + } + + return packages; +}; + +export const ensureAndroidSdkPackages = async ( + packages: readonly string[], + { + env = process.env, + platform = process.platform, + homeDirectory = os.homedir(), + }: AndroidSdkRootOptions = {} +): Promise => { + const sdkRoot = getRequiredAndroidSdkRoot(env, { platform, homeDirectory }); + + await mkdir(sdkRoot, { recursive: true }); + await ensureAndroidCommandLineTools(sdkRoot, platform); + + const missingPackages = await getMissingAndroidSdkPackages(sdkRoot, packages); + + if (missingPackages.length > 0) { + await installAndroidSdkPackages(sdkRoot, missingPackages); + } + + const unresolvedPackages = await getMissingAndroidSdkPackages( + sdkRoot, + packages + ); + + if (unresolvedPackages.length > 0) { + throw new Error( + `Android SDK packages are still missing after installation: ${unresolvedPackages.join( + ', ' + )}` + ); + } + + return sdkRoot; +}; + +export const ensureAndroidDiscoveryEnvironment = async (): Promise => { + initializeAndroidProcessEnv(); + + return ensureAndroidSdkPackages( + getRequiredAndroidSdkPackages({ includeEmulator: true }) + ); +}; + +export const ensureAndroidPhysicalDeviceEnvironment = + async (): Promise => { + initializeAndroidProcessEnv(); + + return ensureAndroidSdkPackages(getRequiredAndroidSdkPackages()); + }; + +export const ensureAndroidEmulatorEnvironment = async ( + apiLevel: number +): Promise => { + initializeAndroidProcessEnv(); + + return ensureAndroidSdkPackages( + getRequiredAndroidSdkPackages({ + apiLevel, + includeEmulator: true, + }) + ); +}; + export const getAndroidProcessEnv = ( env: NodeJS.ProcessEnv = process.env ): NodeJS.ProcessEnv => { @@ -56,24 +479,20 @@ export const initializeAndroidProcessEnv = (): void => { Object.assign(process.env, getAndroidProcessEnv()); }; -export const getAdbBinaryPath = (): string => - path.join(getRequiredAndroidSdkRoot(), 'platform-tools', 'adb'); +export const getAdbBinaryPath = ( + sdkRoot: string = getRequiredAndroidSdkRoot() +): string => path.join(sdkRoot, 'platform-tools', 'adb'); -export const getEmulatorBinaryPath = (): string => - path.join(getRequiredAndroidSdkRoot(), 'emulator', 'emulator'); +export const getEmulatorBinaryPath = ( + sdkRoot: string = getRequiredAndroidSdkRoot() +): string => path.join(sdkRoot, 'emulator', 'emulator'); -export const getSdkManagerBinaryPath = (): string => - path.join( - getRequiredAndroidSdkRoot(), - ...CMDLINE_TOOLS_PATH_SEGMENTS, - 'bin', - 'sdkmanager' - ); +export const getSdkManagerBinaryPath = ( + sdkRoot: string = getRequiredAndroidSdkRoot() +): string => + path.join(sdkRoot, ...CMDLINE_TOOLS_PATH_SEGMENTS, 'bin', 'sdkmanager'); -export const getAvdManagerBinaryPath = (): string => - path.join( - getRequiredAndroidSdkRoot(), - ...CMDLINE_TOOLS_PATH_SEGMENTS, - 'bin', - 'avdmanager' - ); +export const getAvdManagerBinaryPath = ( + sdkRoot: string = getRequiredAndroidSdkRoot() +): string => + path.join(sdkRoot, ...CMDLINE_TOOLS_PATH_SEGMENTS, 'bin', 'avdmanager'); diff --git a/packages/platform-android/src/instance.ts b/packages/platform-android/src/instance.ts index af053c8c..3090a128 100644 --- a/packages/platform-android/src/instance.ts +++ b/packages/platform-android/src/instance.ts @@ -21,6 +21,7 @@ import { import { getDeviceName } from './utils.js'; import { createAndroidAppMonitor } from './app-monitor.js'; import { HarnessAppPathError, HarnessEmulatorConfigError } from './errors.js'; +import { ensureAndroidEmulatorEnvironment } from './environment.js'; import fs from 'node:fs'; const androidInstanceLogger = logger.child('android-instance'); @@ -79,6 +80,8 @@ export const getAndroidEmulatorPlatformInstance = async ( throw new HarnessEmulatorConfigError(config.device.name); } + await ensureAndroidEmulatorEnvironment(avdConfig.apiLevel); + if (!(await adb.hasAvd(config.device.name))) { androidInstanceLogger.debug( 'creating Android AVD %s before startup', @@ -106,6 +109,8 @@ export const getAndroidEmulatorPlatformInstance = async ( config.device.name, adbId ); + } else if (config.device.avd) { + await ensureAndroidEmulatorEnvironment(config.device.avd.apiLevel); } if (!adbId) { diff --git a/packages/platform-android/src/runner.ts b/packages/platform-android/src/runner.ts index c0205e94..eec611ab 100644 --- a/packages/platform-android/src/runner.ts +++ b/packages/platform-android/src/runner.ts @@ -12,7 +12,11 @@ import { getAndroidEmulatorPlatformInstance, getAndroidPhysicalDevicePlatformInstance, } from './instance.js'; -import { initializeAndroidProcessEnv } from './environment.js'; +import { + ensureAndroidEmulatorEnvironment, + ensureAndroidPhysicalDeviceEnvironment, + initializeAndroidProcessEnv, +} from './environment.js'; const getAndroidRunner = async ( config: AndroidPlatformConfig, @@ -24,6 +28,10 @@ const getAndroidRunner = async ( initializeAndroidProcessEnv(); if (isAndroidDeviceEmulator(parsedConfig.device)) { + if (parsedConfig.device.avd) { + await ensureAndroidEmulatorEnvironment(parsedConfig.device.avd.apiLevel); + } + return getAndroidEmulatorPlatformInstance( parsedConfig, harnessConfig, @@ -31,6 +39,8 @@ const getAndroidRunner = async ( ); } + await ensureAndroidPhysicalDeviceEnvironment(); + return getAndroidPhysicalDevicePlatformInstance(parsedConfig, harnessConfig); }; diff --git a/packages/platform-android/src/targets.ts b/packages/platform-android/src/targets.ts index e3336964..ea4765a8 100644 --- a/packages/platform-android/src/targets.ts +++ b/packages/platform-android/src/targets.ts @@ -1,7 +1,10 @@ import { RunTarget } from '@react-native-harness/platforms'; import * as adb from './adb.js'; +import { ensureAndroidDiscoveryEnvironment } from './environment.js'; export const getRunTargets = async (): Promise => { + await ensureAndroidDiscoveryEnvironment(); + const [avds, connectedDevices] = await Promise.all([ adb.getAvds(), adb.getConnectedDevices(), From 0b9ea211fe2de3d1bf558bad3e6d67768c490e6e Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 1 Apr 2026 12:16:55 +0200 Subject: [PATCH 15/26] fix: remove redundant Android action preflight --- action.yml | 20 ------------------- packages/github-action/src/action.yml | 20 ------------------- packages/github-action/src/android/action.yml | 20 ------------------- .../src/__tests__/ci-action.test.ts | 4 ++-- 4 files changed, 2 insertions(+), 62 deletions(-) diff --git a/action.yml b/action.yml index a0769e94..c41aac43 100644 --- a/action.yml +++ b/action.yml @@ -130,26 +130,6 @@ runs: ~/.android/avd ~/.android/adb* key: ${{ steps.avd-key.outputs.key }} - - name: Verify Android SDK packages - if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} - shell: bash - run: | - if [ -n "$ANDROID_HOME" ]; then - SDK_ROOT="$ANDROID_HOME" - elif [ -n "$ANDROID_SDK_ROOT" ]; then - SDK_ROOT="$ANDROID_SDK_ROOT" - elif [ "${{ runner.os }}" = "macOS" ]; then - SDK_ROOT="$HOME/Library/Android/sdk" - else - SDK_ROOT="$HOME/Android/Sdk" - fi - SDKMANAGER_BIN="$SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" - yes | "$SDKMANAGER_BIN" --licenses >/dev/null - yes | "$SDKMANAGER_BIN" \ - "platform-tools" \ - "emulator" \ - "platforms;android-${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }}" \ - "system-images;android-${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }};default;${{ steps.arch.outputs.arch }}" - name: Create AVD and generate snapshot for caching if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} uses: reactivecircus/android-emulator-runner@v2 diff --git a/packages/github-action/src/action.yml b/packages/github-action/src/action.yml index a0769e94..c41aac43 100644 --- a/packages/github-action/src/action.yml +++ b/packages/github-action/src/action.yml @@ -130,26 +130,6 @@ runs: ~/.android/avd ~/.android/adb* key: ${{ steps.avd-key.outputs.key }} - - name: Verify Android SDK packages - if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} - shell: bash - run: | - if [ -n "$ANDROID_HOME" ]; then - SDK_ROOT="$ANDROID_HOME" - elif [ -n "$ANDROID_SDK_ROOT" ]; then - SDK_ROOT="$ANDROID_SDK_ROOT" - elif [ "${{ runner.os }}" = "macOS" ]; then - SDK_ROOT="$HOME/Library/Android/sdk" - else - SDK_ROOT="$HOME/Android/Sdk" - fi - SDKMANAGER_BIN="$SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" - yes | "$SDKMANAGER_BIN" --licenses >/dev/null - yes | "$SDKMANAGER_BIN" \ - "platform-tools" \ - "emulator" \ - "platforms;android-${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }}" \ - "system-images;android-${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }};default;${{ steps.arch.outputs.arch }}" - name: Create AVD and generate snapshot for caching if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} uses: reactivecircus/android-emulator-runner@v2 diff --git a/packages/github-action/src/android/action.yml b/packages/github-action/src/android/action.yml index 19c45561..4dc1ed2b 100644 --- a/packages/github-action/src/android/action.yml +++ b/packages/github-action/src/android/action.yml @@ -104,26 +104,6 @@ runs: ~/.android/avd ~/.android/adb* key: ${{ steps.avd-key.outputs.key }} - - name: Verify Android SDK packages - if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' }} - shell: bash - run: | - if [ -n "$ANDROID_HOME" ]; then - SDK_ROOT="$ANDROID_HOME" - elif [ -n "$ANDROID_SDK_ROOT" ]; then - SDK_ROOT="$ANDROID_SDK_ROOT" - elif [ "${{ runner.os }}" = "macOS" ]; then - SDK_ROOT="$HOME/Library/Android/sdk" - else - SDK_ROOT="$HOME/Android/Sdk" - fi - SDKMANAGER_BIN="$SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" - yes | "$SDKMANAGER_BIN" --licenses >/dev/null - yes | "$SDKMANAGER_BIN" \ - "platform-tools" \ - "emulator" \ - "platforms;android-${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }}" \ - "system-images;android-${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }};default;${{ steps.arch.outputs.arch }}" - name: Create AVD and generate snapshot for caching if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} uses: reactivecircus/android-emulator-runner@v2 diff --git a/packages/platform-android/src/__tests__/ci-action.test.ts b/packages/platform-android/src/__tests__/ci-action.test.ts index a77f443b..23abec59 100644 --- a/packages/platform-android/src/__tests__/ci-action.test.ts +++ b/packages/platform-android/src/__tests__/ci-action.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; const workspaceRoot = path.resolve(import.meta.dirname, '../../../..'); describe('Android GitHub action config', () => { - it('keeps SDK verification enabled even when the AVD cache hits', async () => { + it('does not duplicate Android SDK verification in the action YAML', async () => { const [rootAction, packageAction] = await Promise.all([ readFile(path.join(workspaceRoot, 'action.yml'), 'utf8'), readFile( @@ -15,7 +15,7 @@ describe('Android GitHub action config', () => { ]); for (const actionYaml of [rootAction, packageAction]) { - expect(actionYaml).toContain('Verify Android SDK packages'); + expect(actionYaml).not.toContain('Verify Android SDK packages'); expect(actionYaml).toContain( "steps.avd-cache.outputs.cache-hit != 'true'" ); From 40fc6c602dc3c81d20a11aa54e172c2ccfc32406 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 1 Apr 2026 12:24:52 +0200 Subject: [PATCH 16/26] fix: retry transient Android boot checks --- .../src/__tests__/adb.test.ts | 24 +++++++++++++++++++ packages/platform-android/src/adb.ts | 16 +++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/platform-android/src/__tests__/adb.test.ts b/packages/platform-android/src/__tests__/adb.test.ts index 595a89f7..8bcd421c 100644 --- a/packages/platform-android/src/__tests__/adb.test.ts +++ b/packages/platform-android/src/__tests__/adb.test.ts @@ -1,6 +1,7 @@ import { EventEmitter } from 'node:events'; import { PassThrough } from 'node:stream'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SubprocessError } from '@react-native-harness/tools'; import { createAvd, emulatorProcess, @@ -323,4 +324,27 @@ describe('getStartAppArgs', () => { await expect(waitPromise).rejects.toBeInstanceOf(DOMException); }); + + it('treats transient adb shell failures as not-yet-booted', async () => { + vi.useFakeTimers(); + const spawnSpy = vi.spyOn(tools, 'spawn'); + const transientShellError = Object.assign(new Error('adb shell failed'), { + exitCode: 1, + }); + Object.setPrototypeOf(transientShellError, SubprocessError.prototype); + + spawnSpy.mockRejectedValueOnce(transientShellError).mockResolvedValueOnce({ + stdout: '1\n', + } as Awaited>); + + const waitPromise = waitForBoot( + 'emulator-5554', + new AbortController().signal + ); + + await vi.advanceTimersByTimeAsync(1000); + + await expect(waitPromise).resolves.toBeUndefined(); + expect(spawnSpy).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/platform-android/src/adb.ts b/packages/platform-android/src/adb.ts index 2ca74a80..6735432a 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -296,6 +296,10 @@ export const getShellProperty = async ( return stdout.trim() || null; }; +const isTransientAdbShellFailure = (error: unknown): boolean => { + return error instanceof SubprocessError && error.exitCode === 1; +}; + export type DeviceInfo = { manufacturer: string | null; model: string | null; @@ -310,8 +314,16 @@ export const getDeviceInfo = async ( }; export const isBootCompleted = async (adbId: string): Promise => { - const bootCompleted = await getShellProperty(adbId, 'sys.boot_completed'); - return bootCompleted === '1'; + try { + const bootCompleted = await getShellProperty(adbId, 'sys.boot_completed'); + return bootCompleted === '1'; + } catch (error) { + if (isTransientAdbShellFailure(error)) { + return false; + } + + throw error; + } }; export const stopEmulator = async (adbId: string): Promise => { From 8759ad597a66347c6cd51acce5fa009268c520af Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 1 Apr 2026 12:41:22 +0200 Subject: [PATCH 17/26] fix: log native simulator setup work --- packages/platform-android/src/environment.ts | 12 ++++++++++++ packages/platform-android/src/instance.ts | 3 +++ packages/platform-ios/src/instance.ts | 10 ++++++++++ 3 files changed, 25 insertions(+) diff --git a/packages/platform-android/src/environment.ts b/packages/platform-android/src/environment.ts index dbbf8a6f..62c31a5e 100644 --- a/packages/platform-android/src/environment.ts +++ b/packages/platform-android/src/environment.ts @@ -1,4 +1,5 @@ import { spawn } from '@react-native-harness/tools'; +import { logger } from '@react-native-harness/tools'; import { createWriteStream } from 'node:fs'; import { access, cp, mkdir, mkdtemp, rm } from 'node:fs/promises'; import os from 'node:os'; @@ -9,6 +10,7 @@ import https from 'node:https'; const CMDLINE_TOOLS_PATH_SEGMENTS = ['cmdline-tools', 'latest']; const ANDROID_REPOSITORY_INDEX_URL = 'https://dl.google.com/android/repository/repository2-1.xml'; +const androidEnvironmentLogger = logger.child('android-environment'); export type AndroidSystemImageArch = 'x86_64' | 'arm64-v8a' | 'armeabi-v7a'; @@ -186,6 +188,11 @@ const ensureAndroidCommandLineTools = async ( ); } + androidEnvironmentLogger.info( + 'Bootstrapping Android command-line tools in %s', + sdkRoot + ); + await mkdir(sdkRoot, { recursive: true }); const temporaryDirectory = await mkdtemp( @@ -288,6 +295,11 @@ const installAndroidSdkPackages = async ( .map((packageName) => quoteShell(packageName)) .join(' '); + androidEnvironmentLogger.info( + 'Installing missing Android SDK packages: %s', + packages.join(', ') + ); + await acceptAndroidLicenses(sdkRoot); await spawn( 'bash', diff --git a/packages/platform-android/src/instance.ts b/packages/platform-android/src/instance.ts index 3090a128..3f667525 100644 --- a/packages/platform-android/src/instance.ts +++ b/packages/platform-android/src/instance.ts @@ -83,6 +83,7 @@ export const getAndroidEmulatorPlatformInstance = async ( await ensureAndroidEmulatorEnvironment(avdConfig.apiLevel); if (!(await adb.hasAvd(config.device.name))) { + logger.info('Creating Android emulator %s...', config.device.name); androidInstanceLogger.debug( 'creating Android AVD %s before startup', config.device.name @@ -94,6 +95,8 @@ export const getAndroidEmulatorPlatformInstance = async ( diskSize: avdConfig.diskSize, heapSize: avdConfig.heapSize, }); + } else { + logger.info('Using existing Android emulator %s...', config.device.name); } androidInstanceLogger.debug( diff --git a/packages/platform-ios/src/instance.ts b/packages/platform-ios/src/instance.ts index 9089021b..f2936f0d 100644 --- a/packages/platform-ios/src/instance.ts +++ b/packages/platform-ios/src/instance.ts @@ -71,6 +71,7 @@ export const getAppleSimulatorPlatformInstance = async ( !simctl.isBootedSimulatorStatus(simulatorStatus) && !simctl.isBootingSimulatorStatus(simulatorStatus) ) { + logger.info('Booting iOS simulator %s...', config.device.name); iosInstanceLogger.debug( 'booting iOS simulator %s from status %s', udid, @@ -80,6 +81,15 @@ export const getAppleSimulatorPlatformInstance = async ( startedByHarness = true; } + if (simctl.isBootedSimulatorStatus(simulatorStatus)) { + logger.info('Using booted iOS simulator %s...', config.device.name); + } else if (simctl.isBootingSimulatorStatus(simulatorStatus)) { + logger.info( + 'Waiting for iOS simulator %s to finish booting...', + config.device.name + ); + } + if (!simctl.isBootedSimulatorStatus(simulatorStatus)) { iosInstanceLogger.debug( 'waiting for iOS simulator %s to finish booting', From a597cc7cb82e7d1aba675ca40c23dd129a90594c Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 1 Apr 2026 12:48:15 +0200 Subject: [PATCH 18/26] fix: use timestamped logs in debug mode --- packages/tools/src/logger.ts | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/tools/src/logger.ts b/packages/tools/src/logger.ts index 2bed5b87..a88a989e 100644 --- a/packages/tools/src/logger.ts +++ b/packages/tools/src/logger.ts @@ -1,4 +1,5 @@ import util from 'node:util'; +import pc from 'picocolors'; let verbose = !!process.env.HARNESS_DEBUG; @@ -19,6 +20,14 @@ export type HarnessLogger = { const BASE_TAG = '[harness]'; +const INFO_TAG = pc.isColorSupported + ? pc.reset(pc.inverse(pc.bold(pc.magenta(' HARNESS ')))) + : 'HARNESS'; + +const ERROR_TAG = pc.isColorSupported + ? pc.reset(pc.inverse(pc.bold(pc.red(' HARNESS ')))) + : 'HARNESS'; + const getTimestamp = (): string => new Date().toISOString(); const normalizeScope = (scope: string): string => @@ -43,14 +52,23 @@ const writeLog = ( scopes: readonly string[], messages: Array ) => { - const method = - level === 'warn' - ? console.warn - : level === 'error' - ? console.error - : level === 'debug' - ? console.debug - : console.info; + if ( + !verbose && + (level === 'info' || level === 'log' || level === 'success') + ) { + const output = util.format(...messages); + const tag = INFO_TAG; + process.stderr.write(`${tag} ${output}\n`); + return; + } + + if (!verbose && level === 'error') { + const output = util.format(...messages); + process.stderr.write(`${ERROR_TAG} ${output}\n`); + return; + } + + const method = level === 'warn' ? console.warn : console.debug; const output = util.format(...messages); const prefix = `${getTimestamp()} ${formatPrefix(scopes)}`; method(mapLines(output, prefix)); From c0ceae662e94597b6df9a3e7faacb835afcd99e2 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 1 Apr 2026 12:52:17 +0200 Subject: [PATCH 19/26] fix: log emulator shutdown work --- packages/platform-android/src/instance.ts | 6 ++++-- packages/platform-ios/src/instance.ts | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/platform-android/src/instance.ts b/packages/platform-android/src/instance.ts index 3f667525..e5134202 100644 --- a/packages/platform-android/src/instance.ts +++ b/packages/platform-android/src/instance.ts @@ -63,6 +63,7 @@ export const getAndroidEmulatorPlatformInstance = async ( init: HarnessPlatformInitOptions ): Promise => { assertAndroidDeviceEmulator(config.device); + const emulatorName = config.device.name; let adbId = await getAdbId(config.device); let startedByHarness = false; @@ -83,7 +84,7 @@ export const getAndroidEmulatorPlatformInstance = async ( await ensureAndroidEmulatorEnvironment(avdConfig.apiLevel); if (!(await adb.hasAvd(config.device.name))) { - logger.info('Creating Android emulator %s...', config.device.name); + logger.info('Creating Android emulator %s...', emulatorName); androidInstanceLogger.debug( 'creating Android AVD %s before startup', config.device.name @@ -96,7 +97,7 @@ export const getAndroidEmulatorPlatformInstance = async ( heapSize: avdConfig.heapSize, }); } else { - logger.info('Using existing Android emulator %s...', config.device.name); + logger.info('Using existing Android emulator %s...', emulatorName); } androidInstanceLogger.debug( @@ -164,6 +165,7 @@ export const getAndroidEmulatorPlatformInstance = async ( await adb.setHideErrorDialogs(adbId, false); if (startedByHarness) { + logger.info('Shutting down Android emulator %s...', emulatorName); await adb.stopEmulator(adbId); } }, diff --git a/packages/platform-ios/src/instance.ts b/packages/platform-ios/src/instance.ts index f2936f0d..c9500a1b 100644 --- a/packages/platform-ios/src/instance.ts +++ b/packages/platform-ios/src/instance.ts @@ -137,6 +137,7 @@ export const getAppleSimulatorPlatformInstance = async ( await simctl.clearHarnessJsLocationOverride(udid, config.bundleId); if (startedByHarness) { + logger.info('Shutting down iOS simulator %s...', config.device.name); await simctl.shutdownSimulator(udid); } }, From cf3bd76017bb968519a8e9844a44b4d5270ebc3a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 1 Apr 2026 12:53:20 +0200 Subject: [PATCH 20/26] chore: add action artifact --- actions/shared/index.cjs | 2666 +++++++++++++++++--------------------- 1 file changed, 1157 insertions(+), 1509 deletions(-) diff --git a/actions/shared/index.cjs b/actions/shared/index.cjs index 90f0c572..9a28d933 100644 --- a/actions/shared/index.cjs +++ b/actions/shared/index.cjs @@ -1,73 +1,48 @@ -'use strict'; +"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; -var __commonJS = (cb, mod) => - function __require() { - return ( - mod || - (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), - mod.exports - ); - }; +var __commonJS = (cb, mod) => function __require() { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; +}; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { - if ((from && typeof from === 'object') || typeof from === 'function') { + if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { - get: () => from[key], - enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable, - }); + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; -var __toESM = (mod, isNodeMode, target) => ( - (target = mod != null ? __create(__getProtoOf(mod)) : {}), - __copyProps( - // If the importer is in node compatibility mode or this is not an ESM - // file that has been converted to a CommonJS file using a Babel- - // compatible transform (i.e. "__esModule" has not been set), then set - // "default" to the CommonJS "module.exports" for node compatibility. - isNodeMode || !mod || !mod.__esModule - ? __defProp(target, 'default', { value: mod, enumerable: true }) - : target, - mod - ) -); +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); // ../../node_modules/picocolors/picocolors.js var require_picocolors = __commonJS({ - '../../node_modules/picocolors/picocolors.js'(exports2, module2) { - 'use strict'; + "../../node_modules/picocolors/picocolors.js"(exports2, module2) { + "use strict"; var p = process || {}; var argv = p.argv || []; var env = p.env || {}; - var isColorSupported = - !(!!env.NO_COLOR || argv.includes('--no-color')) && - (!!env.FORCE_COLOR || - argv.includes('--color') || - p.platform === 'win32' || - ((p.stdout || {}).isTTY && env.TERM !== 'dumb') || - !!env.CI); - var formatter = - (open, close, replace = open) => - (input) => { - let string = '' + input, - index = string.indexOf(close, open.length); - return ~index - ? open + replaceClose(string, close, replace, index) + close - : open + string + close; - }; + var isColorSupported = !(!!env.NO_COLOR || argv.includes("--no-color")) && (!!env.FORCE_COLOR || argv.includes("--color") || p.platform === "win32" || (p.stdout || {}).isTTY && env.TERM !== "dumb" || !!env.CI); + var formatter = (open, close, replace = open) => (input) => { + let string = "" + input, index = string.indexOf(close, open.length); + return ~index ? open + replaceClose(string, close, replace, index) + close : open + string + close; + }; var replaceClose = (string, close, replace, index) => { - let result = '', - cursor = 0; + let result = "", cursor = 0; do { result += string.substring(cursor, index) + replace; cursor = index + close.length; @@ -79,68 +54,68 @@ var require_picocolors = __commonJS({ let f = enabled ? formatter : () => String; return { isColorSupported: enabled, - reset: f('\x1B[0m', '\x1B[0m'), - bold: f('\x1B[1m', '\x1B[22m', '\x1B[22m\x1B[1m'), - dim: f('\x1B[2m', '\x1B[22m', '\x1B[22m\x1B[2m'), - italic: f('\x1B[3m', '\x1B[23m'), - underline: f('\x1B[4m', '\x1B[24m'), - inverse: f('\x1B[7m', '\x1B[27m'), - hidden: f('\x1B[8m', '\x1B[28m'), - strikethrough: f('\x1B[9m', '\x1B[29m'), - black: f('\x1B[30m', '\x1B[39m'), - red: f('\x1B[31m', '\x1B[39m'), - green: f('\x1B[32m', '\x1B[39m'), - yellow: f('\x1B[33m', '\x1B[39m'), - blue: f('\x1B[34m', '\x1B[39m'), - magenta: f('\x1B[35m', '\x1B[39m'), - cyan: f('\x1B[36m', '\x1B[39m'), - white: f('\x1B[37m', '\x1B[39m'), - gray: f('\x1B[90m', '\x1B[39m'), - bgBlack: f('\x1B[40m', '\x1B[49m'), - bgRed: f('\x1B[41m', '\x1B[49m'), - bgGreen: f('\x1B[42m', '\x1B[49m'), - bgYellow: f('\x1B[43m', '\x1B[49m'), - bgBlue: f('\x1B[44m', '\x1B[49m'), - bgMagenta: f('\x1B[45m', '\x1B[49m'), - bgCyan: f('\x1B[46m', '\x1B[49m'), - bgWhite: f('\x1B[47m', '\x1B[49m'), - blackBright: f('\x1B[90m', '\x1B[39m'), - redBright: f('\x1B[91m', '\x1B[39m'), - greenBright: f('\x1B[92m', '\x1B[39m'), - yellowBright: f('\x1B[93m', '\x1B[39m'), - blueBright: f('\x1B[94m', '\x1B[39m'), - magentaBright: f('\x1B[95m', '\x1B[39m'), - cyanBright: f('\x1B[96m', '\x1B[39m'), - whiteBright: f('\x1B[97m', '\x1B[39m'), - bgBlackBright: f('\x1B[100m', '\x1B[49m'), - bgRedBright: f('\x1B[101m', '\x1B[49m'), - bgGreenBright: f('\x1B[102m', '\x1B[49m'), - bgYellowBright: f('\x1B[103m', '\x1B[49m'), - bgBlueBright: f('\x1B[104m', '\x1B[49m'), - bgMagentaBright: f('\x1B[105m', '\x1B[49m'), - bgCyanBright: f('\x1B[106m', '\x1B[49m'), - bgWhiteBright: f('\x1B[107m', '\x1B[49m'), + reset: f("\x1B[0m", "\x1B[0m"), + bold: f("\x1B[1m", "\x1B[22m", "\x1B[22m\x1B[1m"), + dim: f("\x1B[2m", "\x1B[22m", "\x1B[22m\x1B[2m"), + italic: f("\x1B[3m", "\x1B[23m"), + underline: f("\x1B[4m", "\x1B[24m"), + inverse: f("\x1B[7m", "\x1B[27m"), + hidden: f("\x1B[8m", "\x1B[28m"), + strikethrough: f("\x1B[9m", "\x1B[29m"), + black: f("\x1B[30m", "\x1B[39m"), + red: f("\x1B[31m", "\x1B[39m"), + green: f("\x1B[32m", "\x1B[39m"), + yellow: f("\x1B[33m", "\x1B[39m"), + blue: f("\x1B[34m", "\x1B[39m"), + magenta: f("\x1B[35m", "\x1B[39m"), + cyan: f("\x1B[36m", "\x1B[39m"), + white: f("\x1B[37m", "\x1B[39m"), + gray: f("\x1B[90m", "\x1B[39m"), + bgBlack: f("\x1B[40m", "\x1B[49m"), + bgRed: f("\x1B[41m", "\x1B[49m"), + bgGreen: f("\x1B[42m", "\x1B[49m"), + bgYellow: f("\x1B[43m", "\x1B[49m"), + bgBlue: f("\x1B[44m", "\x1B[49m"), + bgMagenta: f("\x1B[45m", "\x1B[49m"), + bgCyan: f("\x1B[46m", "\x1B[49m"), + bgWhite: f("\x1B[47m", "\x1B[49m"), + blackBright: f("\x1B[90m", "\x1B[39m"), + redBright: f("\x1B[91m", "\x1B[39m"), + greenBright: f("\x1B[92m", "\x1B[39m"), + yellowBright: f("\x1B[93m", "\x1B[39m"), + blueBright: f("\x1B[94m", "\x1B[39m"), + magentaBright: f("\x1B[95m", "\x1B[39m"), + cyanBright: f("\x1B[96m", "\x1B[39m"), + whiteBright: f("\x1B[97m", "\x1B[39m"), + bgBlackBright: f("\x1B[100m", "\x1B[49m"), + bgRedBright: f("\x1B[101m", "\x1B[49m"), + bgGreenBright: f("\x1B[102m", "\x1B[49m"), + bgYellowBright: f("\x1B[103m", "\x1B[49m"), + bgBlueBright: f("\x1B[104m", "\x1B[49m"), + bgMagentaBright: f("\x1B[105m", "\x1B[49m"), + bgCyanBright: f("\x1B[106m", "\x1B[49m"), + bgWhiteBright: f("\x1B[107m", "\x1B[49m") }; }; module2.exports = createColors(); module2.exports.createColors = createColors; - }, + } }); // ../../node_modules/sisteransi/src/index.js var require_src = __commonJS({ - '../../node_modules/sisteransi/src/index.js'(exports2, module2) { - 'use strict'; - var ESC = '\x1B'; + "../../node_modules/sisteransi/src/index.js"(exports2, module2) { + "use strict"; + var ESC = "\x1B"; var CSI = `${ESC}[`; - var beep = '\x07'; + var beep = "\x07"; var cursor = { to(x2, y) { if (!y) return `${CSI}${x2 + 1}G`; return `${CSI}${y + 1};${x2 + 1}H`; }, move(x2, y) { - let ret = ''; + let ret = ""; if (x2 < 0) ret += `${CSI}${-x2}D`; else if (x2 > 0) ret += `${CSI}${x2}C`; if (y < 0) ret += `${CSI}${-y}A`; @@ -157,11 +132,11 @@ var require_src = __commonJS({ hide: `${CSI}?25l`, show: `${CSI}?25h`, save: `${ESC}7`, - restore: `${ESC}8`, + restore: `${ESC}8` }; var scroll = { up: (count = 1) => `${CSI}S`.repeat(count), - down: (count = 1) => `${CSI}T`.repeat(count), + down: (count = 1) => `${CSI}T`.repeat(count) }; var erase = { screen: `${CSI}2J`, @@ -171,15 +146,16 @@ var require_src = __commonJS({ lineEnd: `${CSI}K`, lineStart: `${CSI}1K`, lines(count) { - let clear = ''; + let clear = ""; for (let i = 0; i < count; i++) - clear += this.line + (i < count - 1 ? cursor.up() : ''); - if (count) clear += cursor.left; + clear += this.line + (i < count - 1 ? cursor.up() : ""); + if (count) + clear += cursor.left; return clear; - }, + } }; module2.exports = { cursor, scroll, erase, beep }; - }, + } }); // ../../node_modules/zod/dist/esm/v3/external.js @@ -291,14 +267,16 @@ __export(external_exports, { union: () => unionType, unknown: () => unknownType, util: () => util, - void: () => voidType, + void: () => voidType }); // ../../node_modules/zod/dist/esm/v3/helpers/util.js var util; -(function (util3) { - util3.assertEqual = (_2) => {}; - function assertIs(_arg) {} +(function(util3) { + util3.assertEqual = (_2) => { + }; + function assertIs(_arg) { + } util3.assertIs = assertIs; function assertNever(_x) { throw new Error(); @@ -312,9 +290,7 @@ var util; return obj; }; util3.getValidEnumValues = (obj) => { - const validKeys = util3 - .objectKeys(obj) - .filter((k3) => typeof obj[obj[k3]] !== 'number'); + const validKeys = util3.objectKeys(obj).filter((k3) => typeof obj[obj[k3]] !== "number"); const filtered = {}; for (const k3 of validKeys) { filtered[k3] = obj[k3]; @@ -322,119 +298,104 @@ var util; return util3.objectValues(filtered); }; util3.objectValues = (obj) => { - return util3.objectKeys(obj).map(function (e) { + return util3.objectKeys(obj).map(function(e) { return obj[e]; }); }; - util3.objectKeys = - typeof Object.keys === 'function' - ? (obj) => Object.keys(obj) - : (object) => { - const keys = []; - for (const key in object) { - if (Object.prototype.hasOwnProperty.call(object, key)) { - keys.push(key); - } - } - return keys; - }; + util3.objectKeys = typeof Object.keys === "function" ? (obj) => Object.keys(obj) : (object) => { + const keys = []; + for (const key in object) { + if (Object.prototype.hasOwnProperty.call(object, key)) { + keys.push(key); + } + } + return keys; + }; util3.find = (arr, checker) => { for (const item of arr) { - if (checker(item)) return item; + if (checker(item)) + return item; } return void 0; }; - util3.isInteger = - typeof Number.isInteger === 'function' - ? (val) => Number.isInteger(val) - : (val) => - typeof val === 'number' && - Number.isFinite(val) && - Math.floor(val) === val; - function joinValues(array, separator = ' | ') { - return array - .map((val) => (typeof val === 'string' ? `'${val}'` : val)) - .join(separator); + util3.isInteger = typeof Number.isInteger === "function" ? (val) => Number.isInteger(val) : (val) => typeof val === "number" && Number.isFinite(val) && Math.floor(val) === val; + function joinValues(array, separator = " | ") { + return array.map((val) => typeof val === "string" ? `'${val}'` : val).join(separator); } util3.joinValues = joinValues; util3.jsonStringifyReplacer = (_2, value) => { - if (typeof value === 'bigint') { + if (typeof value === "bigint") { return value.toString(); } return value; }; })(util || (util = {})); var objectUtil; -(function (objectUtil2) { +(function(objectUtil2) { objectUtil2.mergeShapes = (first, second) => { return { ...first, - ...second, + ...second // second overwrites first }; }; })(objectUtil || (objectUtil = {})); var ZodParsedType = util.arrayToEnum([ - 'string', - 'nan', - 'number', - 'integer', - 'float', - 'boolean', - 'date', - 'bigint', - 'symbol', - 'function', - 'undefined', - 'null', - 'array', - 'object', - 'unknown', - 'promise', - 'void', - 'never', - 'map', - 'set', + "string", + "nan", + "number", + "integer", + "float", + "boolean", + "date", + "bigint", + "symbol", + "function", + "undefined", + "null", + "array", + "object", + "unknown", + "promise", + "void", + "never", + "map", + "set" ]); var getParsedType = (data) => { const t2 = typeof data; switch (t2) { - case 'undefined': + case "undefined": return ZodParsedType.undefined; - case 'string': + case "string": return ZodParsedType.string; - case 'number': + case "number": return Number.isNaN(data) ? ZodParsedType.nan : ZodParsedType.number; - case 'boolean': + case "boolean": return ZodParsedType.boolean; - case 'function': + case "function": return ZodParsedType.function; - case 'bigint': + case "bigint": return ZodParsedType.bigint; - case 'symbol': + case "symbol": return ZodParsedType.symbol; - case 'object': + case "object": if (Array.isArray(data)) { return ZodParsedType.array; } if (data === null) { return ZodParsedType.null; } - if ( - data.then && - typeof data.then === 'function' && - data.catch && - typeof data.catch === 'function' - ) { + if (data.then && typeof data.then === "function" && data.catch && typeof data.catch === "function") { return ZodParsedType.promise; } - if (typeof Map !== 'undefined' && data instanceof Map) { + if (typeof Map !== "undefined" && data instanceof Map) { return ZodParsedType.map; } - if (typeof Set !== 'undefined' && data instanceof Set) { + if (typeof Set !== "undefined" && data instanceof Set) { return ZodParsedType.set; } - if (typeof Date !== 'undefined' && data instanceof Date) { + if (typeof Date !== "undefined" && data instanceof Date) { return ZodParsedType.date; } return ZodParsedType.object; @@ -445,26 +406,26 @@ var getParsedType = (data) => { // ../../node_modules/zod/dist/esm/v3/ZodError.js var ZodIssueCode = util.arrayToEnum([ - 'invalid_type', - 'invalid_literal', - 'custom', - 'invalid_union', - 'invalid_union_discriminator', - 'invalid_enum_value', - 'unrecognized_keys', - 'invalid_arguments', - 'invalid_return_type', - 'invalid_date', - 'invalid_string', - 'too_small', - 'too_big', - 'invalid_intersection_types', - 'not_multiple_of', - 'not_finite', + "invalid_type", + "invalid_literal", + "custom", + "invalid_union", + "invalid_union_discriminator", + "invalid_enum_value", + "unrecognized_keys", + "invalid_arguments", + "invalid_return_type", + "invalid_date", + "invalid_string", + "too_small", + "too_big", + "invalid_intersection_types", + "not_multiple_of", + "not_finite" ]); var quotelessJson = (obj) => { const json = JSON.stringify(obj, null, 2); - return json.replace(/"([^"]+)":/g, '$1:'); + return json.replace(/"([^"]+)":/g, "$1:"); }; var ZodError = class _ZodError extends Error { get errors() { @@ -485,23 +446,21 @@ var ZodError = class _ZodError extends Error { } else { this.__proto__ = actualProto; } - this.name = 'ZodError'; + this.name = "ZodError"; this.issues = issues; } format(_mapper) { - const mapper = - _mapper || - function (issue) { - return issue.message; - }; + const mapper = _mapper || function(issue) { + return issue.message; + }; const fieldErrors = { _errors: [] }; const processError = (error) => { for (const issue of error.issues) { - if (issue.code === 'invalid_union') { + if (issue.code === "invalid_union") { issue.unionErrors.map(processError); - } else if (issue.code === 'invalid_return_type') { + } else if (issue.code === "invalid_return_type") { processError(issue.returnTypeError); - } else if (issue.code === 'invalid_arguments') { + } else if (issue.code === "invalid_arguments") { processError(issue.argumentsError); } else if (issue.path.length === 0) { fieldErrors._errors.push(mapper(issue)); @@ -568,35 +527,25 @@ var errorMap = (issue, _ctx) => { switch (issue.code) { case ZodIssueCode.invalid_type: if (issue.received === ZodParsedType.undefined) { - message = 'Required'; + message = "Required"; } else { message = `Expected ${issue.expected}, received ${issue.received}`; } break; case ZodIssueCode.invalid_literal: - message = `Invalid literal value, expected ${JSON.stringify( - issue.expected, - util.jsonStringifyReplacer - )}`; + message = `Invalid literal value, expected ${JSON.stringify(issue.expected, util.jsonStringifyReplacer)}`; break; case ZodIssueCode.unrecognized_keys: - message = `Unrecognized key(s) in object: ${util.joinValues( - issue.keys, - ', ' - )}`; + message = `Unrecognized key(s) in object: ${util.joinValues(issue.keys, ", ")}`; break; case ZodIssueCode.invalid_union: message = `Invalid input`; break; case ZodIssueCode.invalid_union_discriminator: - message = `Invalid discriminator value. Expected ${util.joinValues( - issue.options - )}`; + message = `Invalid discriminator value. Expected ${util.joinValues(issue.options)}`; break; case ZodIssueCode.invalid_enum_value: - message = `Invalid enum value. Expected ${util.joinValues( - issue.options - )}, received '${issue.received}'`; + message = `Invalid enum value. Expected ${util.joinValues(issue.options)}, received '${issue.received}'`; break; case ZodIssueCode.invalid_arguments: message = `Invalid function arguments`; @@ -608,86 +557,50 @@ var errorMap = (issue, _ctx) => { message = `Invalid date`; break; case ZodIssueCode.invalid_string: - if (typeof issue.validation === 'object') { - if ('includes' in issue.validation) { + if (typeof issue.validation === "object") { + if ("includes" in issue.validation) { message = `Invalid input: must include "${issue.validation.includes}"`; - if (typeof issue.validation.position === 'number') { + if (typeof issue.validation.position === "number") { message = `${message} at one or more positions greater than or equal to ${issue.validation.position}`; } - } else if ('startsWith' in issue.validation) { + } else if ("startsWith" in issue.validation) { message = `Invalid input: must start with "${issue.validation.startsWith}"`; - } else if ('endsWith' in issue.validation) { + } else if ("endsWith" in issue.validation) { message = `Invalid input: must end with "${issue.validation.endsWith}"`; } else { util.assertNever(issue.validation); } - } else if (issue.validation !== 'regex') { + } else if (issue.validation !== "regex") { message = `Invalid ${issue.validation}`; } else { - message = 'Invalid'; + message = "Invalid"; } break; case ZodIssueCode.too_small: - if (issue.type === 'array') - message = `Array must contain ${ - issue.exact ? 'exactly' : issue.inclusive ? `at least` : `more than` - } ${issue.minimum} element(s)`; - else if (issue.type === 'string') - message = `String must contain ${ - issue.exact ? 'exactly' : issue.inclusive ? `at least` : `over` - } ${issue.minimum} character(s)`; - else if (issue.type === 'number') - message = `Number must be ${ - issue.exact - ? `exactly equal to ` - : issue.inclusive - ? `greater than or equal to ` - : `greater than ` - }${issue.minimum}`; - else if (issue.type === 'date') - message = `Date must be ${ - issue.exact - ? `exactly equal to ` - : issue.inclusive - ? `greater than or equal to ` - : `greater than ` - }${new Date(Number(issue.minimum))}`; - else message = 'Invalid input'; + if (issue.type === "array") + message = `Array must contain ${issue.exact ? "exactly" : issue.inclusive ? `at least` : `more than`} ${issue.minimum} element(s)`; + else if (issue.type === "string") + message = `String must contain ${issue.exact ? "exactly" : issue.inclusive ? `at least` : `over`} ${issue.minimum} character(s)`; + else if (issue.type === "number") + message = `Number must be ${issue.exact ? `exactly equal to ` : issue.inclusive ? `greater than or equal to ` : `greater than `}${issue.minimum}`; + else if (issue.type === "date") + message = `Date must be ${issue.exact ? `exactly equal to ` : issue.inclusive ? `greater than or equal to ` : `greater than `}${new Date(Number(issue.minimum))}`; + else + message = "Invalid input"; break; case ZodIssueCode.too_big: - if (issue.type === 'array') - message = `Array must contain ${ - issue.exact ? `exactly` : issue.inclusive ? `at most` : `less than` - } ${issue.maximum} element(s)`; - else if (issue.type === 'string') - message = `String must contain ${ - issue.exact ? `exactly` : issue.inclusive ? `at most` : `under` - } ${issue.maximum} character(s)`; - else if (issue.type === 'number') - message = `Number must be ${ - issue.exact - ? `exactly` - : issue.inclusive - ? `less than or equal to` - : `less than` - } ${issue.maximum}`; - else if (issue.type === 'bigint') - message = `BigInt must be ${ - issue.exact - ? `exactly` - : issue.inclusive - ? `less than or equal to` - : `less than` - } ${issue.maximum}`; - else if (issue.type === 'date') - message = `Date must be ${ - issue.exact - ? `exactly` - : issue.inclusive - ? `smaller than or equal to` - : `smaller than` - } ${new Date(Number(issue.maximum))}`; - else message = 'Invalid input'; + if (issue.type === "array") + message = `Array must contain ${issue.exact ? `exactly` : issue.inclusive ? `at most` : `less than`} ${issue.maximum} element(s)`; + else if (issue.type === "string") + message = `String must contain ${issue.exact ? `exactly` : issue.inclusive ? `at most` : `under`} ${issue.maximum} character(s)`; + else if (issue.type === "number") + message = `Number must be ${issue.exact ? `exactly` : issue.inclusive ? `less than or equal to` : `less than`} ${issue.maximum}`; + else if (issue.type === "bigint") + message = `BigInt must be ${issue.exact ? `exactly` : issue.inclusive ? `less than or equal to` : `less than`} ${issue.maximum}`; + else if (issue.type === "date") + message = `Date must be ${issue.exact ? `exactly` : issue.inclusive ? `smaller than or equal to` : `smaller than`} ${new Date(Number(issue.maximum))}`; + else + message = "Invalid input"; break; case ZodIssueCode.custom: message = `Invalid input`; @@ -699,7 +612,7 @@ var errorMap = (issue, _ctx) => { message = `Number must be a multiple of ${issue.multipleOf}`; break; case ZodIssueCode.not_finite: - message = 'Number must be finite'; + message = "Number must be finite"; break; default: message = _ctx.defaultError; @@ -721,30 +634,27 @@ function getErrorMap() { // ../../node_modules/zod/dist/esm/v3/helpers/parseUtil.js var makeIssue = (params) => { const { data, path: path6, errorMaps, issueData } = params; - const fullPath = [...path6, ...(issueData.path || [])]; + const fullPath = [...path6, ...issueData.path || []]; const fullIssue = { ...issueData, - path: fullPath, + path: fullPath }; if (issueData.message !== void 0) { return { ...issueData, path: fullPath, - message: issueData.message, + message: issueData.message }; } - let errorMessage = ''; - const maps = errorMaps - .filter((m) => !!m) - .slice() - .reverse(); + let errorMessage = ""; + const maps = errorMaps.filter((m) => !!m).slice().reverse(); for (const map of maps) { errorMessage = map(fullIssue, { data, defaultError: errorMessage }).message; } return { ...issueData, path: fullPath, - message: errorMessage, + message: errorMessage }; }; var EMPTY_PATH = []; @@ -761,27 +671,31 @@ function addIssueToContext(ctx, issueData) { // then schema-bound map if available overrideMap, // then global override map - overrideMap === en_default ? void 0 : en_default, + overrideMap === en_default ? void 0 : en_default // then global default map - ].filter((x2) => !!x2), + ].filter((x2) => !!x2) }); ctx.common.issues.push(issue); } var ParseStatus = class _ParseStatus { constructor() { - this.value = 'valid'; + this.value = "valid"; } dirty() { - if (this.value === 'valid') this.value = 'dirty'; + if (this.value === "valid") + this.value = "dirty"; } abort() { - if (this.value !== 'aborted') this.value = 'aborted'; + if (this.value !== "aborted") + this.value = "aborted"; } static mergeArray(status, results) { const arrayValue = []; for (const s of results) { - if (s.status === 'aborted') return INVALID; - if (s.status === 'dirty') status.dirty(); + if (s.status === "aborted") + return INVALID; + if (s.status === "dirty") + status.dirty(); arrayValue.push(s.value); } return { status: status.value, value: arrayValue }; @@ -793,7 +707,7 @@ var ParseStatus = class _ParseStatus { const value = await pair.value; syncPairs.push({ key, - value, + value }); } return _ParseStatus.mergeObjectSync(status, syncPairs); @@ -802,14 +716,15 @@ var ParseStatus = class _ParseStatus { const finalObject = {}; for (const pair of pairs) { const { key, value } = pair; - if (key.status === 'aborted') return INVALID; - if (value.status === 'aborted') return INVALID; - if (key.status === 'dirty') status.dirty(); - if (value.status === 'dirty') status.dirty(); - if ( - key.value !== '__proto__' && - (typeof value.value !== 'undefined' || pair.alwaysSet) - ) { + if (key.status === "aborted") + return INVALID; + if (value.status === "aborted") + return INVALID; + if (key.status === "dirty") + status.dirty(); + if (value.status === "dirty") + status.dirty(); + if (key.value !== "__proto__" && (typeof value.value !== "undefined" || pair.alwaysSet)) { finalObject[key.value] = value.value; } } @@ -817,22 +732,20 @@ var ParseStatus = class _ParseStatus { } }; var INVALID = Object.freeze({ - status: 'aborted', + status: "aborted" }); -var DIRTY = (value) => ({ status: 'dirty', value }); -var OK = (value) => ({ status: 'valid', value }); -var isAborted = (x2) => x2.status === 'aborted'; -var isDirty = (x2) => x2.status === 'dirty'; -var isValid = (x2) => x2.status === 'valid'; -var isAsync = (x2) => typeof Promise !== 'undefined' && x2 instanceof Promise; +var DIRTY = (value) => ({ status: "dirty", value }); +var OK = (value) => ({ status: "valid", value }); +var isAborted = (x2) => x2.status === "aborted"; +var isDirty = (x2) => x2.status === "dirty"; +var isValid = (x2) => x2.status === "valid"; +var isAsync = (x2) => typeof Promise !== "undefined" && x2 instanceof Promise; // ../../node_modules/zod/dist/esm/v3/helpers/errorUtil.js var errorUtil; -(function (errorUtil2) { - errorUtil2.errToObj = (message) => - typeof message === 'string' ? { message } : message || {}; - errorUtil2.toString = (message) => - typeof message === 'string' ? message : message?.message; +(function(errorUtil2) { + errorUtil2.errToObj = (message) => typeof message === "string" ? { message } : message || {}; + errorUtil2.toString = (message) => typeof message === "string" ? message : message?.message; })(errorUtil || (errorUtil = {})); // ../../node_modules/zod/dist/esm/v3/types.js @@ -860,42 +773,39 @@ var handleResult = (ctx, result) => { return { success: true, data: result.value }; } else { if (!ctx.common.issues.length) { - throw new Error('Validation failed but no issues detected.'); + throw new Error("Validation failed but no issues detected."); } return { success: false, get error() { - if (this._error) return this._error; + if (this._error) + return this._error; const error = new ZodError(ctx.common.issues); this._error = error; return this._error; - }, + } }; } }; function processCreateParams(params) { - if (!params) return {}; - const { - errorMap: errorMap2, - invalid_type_error, - required_error, - description, - } = params; + if (!params) + return {}; + const { errorMap: errorMap2, invalid_type_error, required_error, description } = params; if (errorMap2 && (invalid_type_error || required_error)) { - throw new Error( - `Can't use "invalid_type_error" or "required_error" in conjunction with custom error map.` - ); + throw new Error(`Can't use "invalid_type_error" or "required_error" in conjunction with custom error map.`); } - if (errorMap2) return { errorMap: errorMap2, description }; + if (errorMap2) + return { errorMap: errorMap2, description }; const customMap = (iss, ctx) => { const { message } = params; - if (iss.code === 'invalid_enum_value') { + if (iss.code === "invalid_enum_value") { return { message: message ?? ctx.defaultError }; } - if (typeof ctx.data === 'undefined') { + if (typeof ctx.data === "undefined") { return { message: message ?? required_error ?? ctx.defaultError }; } - if (iss.code !== 'invalid_type') return { message: ctx.defaultError }; + if (iss.code !== "invalid_type") + return { message: ctx.defaultError }; return { message: message ?? invalid_type_error ?? ctx.defaultError }; }; return { errorMap: customMap, description }; @@ -908,16 +818,14 @@ var ZodType = class { return getParsedType(input.data); } _getOrReturnCtx(input, ctx) { - return ( - ctx || { - common: input.parent.common, - data: input.data, - parsedType: getParsedType(input.data), - schemaErrorMap: this._def.errorMap, - path: input.path, - parent: input.parent, - } - ); + return ctx || { + common: input.parent.common, + data: input.data, + parsedType: getParsedType(input.data), + schemaErrorMap: this._def.errorMap, + path: input.path, + parent: input.parent + }; } _processInputParams(input) { return { @@ -928,14 +836,14 @@ var ZodType = class { parsedType: getParsedType(input.data), schemaErrorMap: this._def.errorMap, path: input.path, - parent: input.parent, - }, + parent: input.parent + } }; } _parseSync(input) { const result = this._parse(input); if (isAsync(result)) { - throw new Error('Synchronous parse encountered promise.'); + throw new Error("Synchronous parse encountered promise."); } return result; } @@ -945,7 +853,8 @@ var ZodType = class { } parse(data, params) { const result = this.safeParse(data, params); - if (result.success) return result.data; + if (result.success) + return result.data; throw result.error; } safeParse(data, params) { @@ -953,62 +862,57 @@ var ZodType = class { common: { issues: [], async: params?.async ?? false, - contextualErrorMap: params?.errorMap, + contextualErrorMap: params?.errorMap }, path: params?.path || [], schemaErrorMap: this._def.errorMap, parent: null, data, - parsedType: getParsedType(data), + parsedType: getParsedType(data) }; const result = this._parseSync({ data, path: ctx.path, parent: ctx }); return handleResult(ctx, result); } - '~validate'(data) { + "~validate"(data) { const ctx = { common: { issues: [], - async: !!this['~standard'].async, + async: !!this["~standard"].async }, path: [], schemaErrorMap: this._def.errorMap, parent: null, data, - parsedType: getParsedType(data), + parsedType: getParsedType(data) }; - if (!this['~standard'].async) { + if (!this["~standard"].async) { try { const result = this._parseSync({ data, path: [], parent: ctx }); - return isValid(result) - ? { - value: result.value, - } - : { - issues: ctx.common.issues, - }; + return isValid(result) ? { + value: result.value + } : { + issues: ctx.common.issues + }; } catch (err) { - if (err?.message?.toLowerCase()?.includes('encountered')) { - this['~standard'].async = true; + if (err?.message?.toLowerCase()?.includes("encountered")) { + this["~standard"].async = true; } ctx.common = { issues: [], - async: true, + async: true }; } } - return this._parseAsync({ data, path: [], parent: ctx }).then((result) => - isValid(result) - ? { - value: result.value, - } - : { - issues: ctx.common.issues, - } - ); + return this._parseAsync({ data, path: [], parent: ctx }).then((result) => isValid(result) ? { + value: result.value + } : { + issues: ctx.common.issues + }); } async parseAsync(data, params) { const result = await this.safeParseAsync(data, params); - if (result.success) return result.data; + if (result.success) + return result.data; throw result.error; } async safeParseAsync(data, params) { @@ -1016,25 +920,23 @@ var ZodType = class { common: { issues: [], contextualErrorMap: params?.errorMap, - async: true, + async: true }, path: params?.path || [], schemaErrorMap: this._def.errorMap, parent: null, data, - parsedType: getParsedType(data), + parsedType: getParsedType(data) }; const maybeAsyncResult = this._parse({ data, path: ctx.path, parent: ctx }); - const result = await (isAsync(maybeAsyncResult) - ? maybeAsyncResult - : Promise.resolve(maybeAsyncResult)); + const result = await (isAsync(maybeAsyncResult) ? maybeAsyncResult : Promise.resolve(maybeAsyncResult)); return handleResult(ctx, result); } refine(check, message) { const getIssueProperties = (val) => { - if (typeof message === 'string' || typeof message === 'undefined') { + if (typeof message === "string" || typeof message === "undefined") { return { message }; - } else if (typeof message === 'function') { + } else if (typeof message === "function") { return message(val); } else { return message; @@ -1042,12 +944,11 @@ var ZodType = class { }; return this._refinement((val, ctx) => { const result = check(val); - const setError = () => - ctx.addIssue({ - code: ZodIssueCode.custom, - ...getIssueProperties(val), - }); - if (typeof Promise !== 'undefined' && result instanceof Promise) { + const setError = () => ctx.addIssue({ + code: ZodIssueCode.custom, + ...getIssueProperties(val) + }); + if (typeof Promise !== "undefined" && result instanceof Promise) { return result.then((data) => { if (!data) { setError(); @@ -1068,11 +969,7 @@ var ZodType = class { refinement(check, refinementData) { return this._refinement((val, ctx) => { if (!check(val)) { - ctx.addIssue( - typeof refinementData === 'function' - ? refinementData(val, ctx) - : refinementData - ); + ctx.addIssue(typeof refinementData === "function" ? refinementData(val, ctx) : refinementData); return false; } else { return true; @@ -1083,7 +980,7 @@ var ZodType = class { return new ZodEffects({ schema: this, typeName: ZodFirstPartyTypeKind.ZodEffects, - effect: { type: 'refinement', refinement }, + effect: { type: "refinement", refinement } }); } superRefine(refinement) { @@ -1116,10 +1013,10 @@ var ZodType = class { this.readonly = this.readonly.bind(this); this.isNullable = this.isNullable.bind(this); this.isOptional = this.isOptional.bind(this); - this['~standard'] = { + this["~standard"] = { version: 1, - vendor: 'zod', - validate: (data) => this['~validate'](data), + vendor: "zod", + validate: (data) => this["~validate"](data) }; } optional() { @@ -1148,39 +1045,39 @@ var ZodType = class { ...processCreateParams(this._def), schema: this, typeName: ZodFirstPartyTypeKind.ZodEffects, - effect: { type: 'transform', transform }, + effect: { type: "transform", transform } }); } default(def) { - const defaultValueFunc = typeof def === 'function' ? def : () => def; + const defaultValueFunc = typeof def === "function" ? def : () => def; return new ZodDefault({ ...processCreateParams(this._def), innerType: this, defaultValue: defaultValueFunc, - typeName: ZodFirstPartyTypeKind.ZodDefault, + typeName: ZodFirstPartyTypeKind.ZodDefault }); } brand() { return new ZodBranded({ typeName: ZodFirstPartyTypeKind.ZodBranded, type: this, - ...processCreateParams(this._def), + ...processCreateParams(this._def) }); } catch(def) { - const catchValueFunc = typeof def === 'function' ? def : () => def; + const catchValueFunc = typeof def === "function" ? def : () => def; return new ZodCatch({ ...processCreateParams(this._def), innerType: this, catchValue: catchValueFunc, - typeName: ZodFirstPartyTypeKind.ZodCatch, + typeName: ZodFirstPartyTypeKind.ZodCatch }); } describe(description) { const This = this.constructor; return new This({ ...this._def, - description, + description }); } pipe(target) { @@ -1199,28 +1096,19 @@ var ZodType = class { var cuidRegex = /^c[^\s-]{8,}$/i; var cuid2Regex = /^[0-9a-z]+$/; var ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/i; -var uuidRegex = - /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/i; +var uuidRegex = /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/i; var nanoidRegex = /^[a-z0-9_-]{21}$/i; var jwtRegex = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/; -var durationRegex = - /^[-+]?P(?!$)(?:(?:[-+]?\d+Y)|(?:[-+]?\d+[.,]\d+Y$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:(?:[-+]?\d+W)|(?:[-+]?\d+[.,]\d+W$))?(?:(?:[-+]?\d+D)|(?:[-+]?\d+[.,]\d+D$))?(?:T(?=[\d+-])(?:(?:[-+]?\d+H)|(?:[-+]?\d+[.,]\d+H$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:[-+]?\d+(?:[.,]\d+)?S)?)??$/; -var emailRegex = - /^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i; +var durationRegex = /^[-+]?P(?!$)(?:(?:[-+]?\d+Y)|(?:[-+]?\d+[.,]\d+Y$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:(?:[-+]?\d+W)|(?:[-+]?\d+[.,]\d+W$))?(?:(?:[-+]?\d+D)|(?:[-+]?\d+[.,]\d+D$))?(?:T(?=[\d+-])(?:(?:[-+]?\d+H)|(?:[-+]?\d+[.,]\d+H$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:[-+]?\d+(?:[.,]\d+)?S)?)??$/; +var emailRegex = /^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i; var _emojiRegex = `^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$`; var emojiRegex; -var ipv4Regex = - /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/; -var ipv4CidrRegex = - /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/(3[0-2]|[12]?[0-9])$/; -var ipv6Regex = - /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/; -var ipv6CidrRegex = - /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/; -var base64Regex = - /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/; -var base64urlRegex = - /^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$/; +var ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/; +var ipv4CidrRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/(3[0-2]|[12]?[0-9])$/; +var ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/; +var ipv6CidrRegex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/; +var base64Regex = /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/; +var base64urlRegex = /^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$/; var dateRegexSource = `((\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\\d|3[01])|(0[469]|11)-(0[1-9]|[12]\\d|30)|(02)-(0[1-9]|1\\d|2[0-8])))`; var dateRegex = new RegExp(`^${dateRegexSource}$`); function timeRegexSource(args) { @@ -1230,7 +1118,7 @@ function timeRegexSource(args) { } else if (args.precision == null) { secondsRegexSource = `${secondsRegexSource}(\\.\\d+)?`; } - const secondsQuantifier = args.precision ? '+' : '?'; + const secondsQuantifier = args.precision ? "+" : "?"; return `([01]\\d|2[0-3]):[0-5]\\d(:${secondsRegexSource})${secondsQuantifier}`; } function timeRegex(args) { @@ -1240,42 +1128,45 @@ function datetimeRegex(args) { let regex = `${dateRegexSource}T${timeRegexSource(args)}`; const opts = []; opts.push(args.local ? `Z?` : `Z`); - if (args.offset) opts.push(`([+-]\\d{2}:?\\d{2})`); - regex = `${regex}(${opts.join('|')})`; + if (args.offset) + opts.push(`([+-]\\d{2}:?\\d{2})`); + regex = `${regex}(${opts.join("|")})`; return new RegExp(`^${regex}$`); } function isValidIP(ip, version) { - if ((version === 'v4' || !version) && ipv4Regex.test(ip)) { + if ((version === "v4" || !version) && ipv4Regex.test(ip)) { return true; } - if ((version === 'v6' || !version) && ipv6Regex.test(ip)) { + if ((version === "v6" || !version) && ipv6Regex.test(ip)) { return true; } return false; } function isValidJWT(jwt, alg) { - if (!jwtRegex.test(jwt)) return false; + if (!jwtRegex.test(jwt)) + return false; try { - const [header] = jwt.split('.'); - const base64 = header - .replace(/-/g, '+') - .replace(/_/g, '/') - .padEnd(header.length + ((4 - (header.length % 4)) % 4), '='); + const [header] = jwt.split("."); + const base64 = header.replace(/-/g, "+").replace(/_/g, "/").padEnd(header.length + (4 - header.length % 4) % 4, "="); const decoded = JSON.parse(atob(base64)); - if (typeof decoded !== 'object' || decoded === null) return false; - if ('typ' in decoded && decoded?.typ !== 'JWT') return false; - if (!decoded.alg) return false; - if (alg && decoded.alg !== alg) return false; + if (typeof decoded !== "object" || decoded === null) + return false; + if ("typ" in decoded && decoded?.typ !== "JWT") + return false; + if (!decoded.alg) + return false; + if (alg && decoded.alg !== alg) + return false; return true; } catch { return false; } } function isValidCidr(ip, version) { - if ((version === 'v4' || !version) && ipv4CidrRegex.test(ip)) { + if ((version === "v4" || !version) && ipv4CidrRegex.test(ip)) { return true; } - if ((version === 'v6' || !version) && ipv6CidrRegex.test(ip)) { + if ((version === "v6" || !version) && ipv6CidrRegex.test(ip)) { return true; } return false; @@ -1291,40 +1182,40 @@ var ZodString = class _ZodString extends ZodType { addIssueToContext(ctx2, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.string, - received: ctx2.parsedType, + received: ctx2.parsedType }); return INVALID; } const status = new ParseStatus(); let ctx = void 0; for (const check of this._def.checks) { - if (check.kind === 'min') { + if (check.kind === "min") { if (input.data.length < check.value) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.too_small, minimum: check.value, - type: 'string', + type: "string", inclusive: true, exact: false, - message: check.message, + message: check.message }); status.dirty(); } - } else if (check.kind === 'max') { + } else if (check.kind === "max") { if (input.data.length > check.value) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.too_big, maximum: check.value, - type: 'string', + type: "string", inclusive: true, exact: false, - message: check.message, + message: check.message }); status.dirty(); } - } else if (check.kind === 'length') { + } else if (check.kind === "length") { const tooBig = input.data.length > check.value; const tooSmall = input.data.length < check.value; if (tooBig || tooSmall) { @@ -1333,246 +1224,246 @@ var ZodString = class _ZodString extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.too_big, maximum: check.value, - type: 'string', + type: "string", inclusive: true, exact: true, - message: check.message, + message: check.message }); } else if (tooSmall) { addIssueToContext(ctx, { code: ZodIssueCode.too_small, minimum: check.value, - type: 'string', + type: "string", inclusive: true, exact: true, - message: check.message, + message: check.message }); } status.dirty(); } - } else if (check.kind === 'email') { + } else if (check.kind === "email") { if (!emailRegex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: 'email', + validation: "email", code: ZodIssueCode.invalid_string, - message: check.message, + message: check.message }); status.dirty(); } - } else if (check.kind === 'emoji') { + } else if (check.kind === "emoji") { if (!emojiRegex) { - emojiRegex = new RegExp(_emojiRegex, 'u'); + emojiRegex = new RegExp(_emojiRegex, "u"); } if (!emojiRegex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: 'emoji', + validation: "emoji", code: ZodIssueCode.invalid_string, - message: check.message, + message: check.message }); status.dirty(); } - } else if (check.kind === 'uuid') { + } else if (check.kind === "uuid") { if (!uuidRegex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: 'uuid', + validation: "uuid", code: ZodIssueCode.invalid_string, - message: check.message, + message: check.message }); status.dirty(); } - } else if (check.kind === 'nanoid') { + } else if (check.kind === "nanoid") { if (!nanoidRegex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: 'nanoid', + validation: "nanoid", code: ZodIssueCode.invalid_string, - message: check.message, + message: check.message }); status.dirty(); } - } else if (check.kind === 'cuid') { + } else if (check.kind === "cuid") { if (!cuidRegex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: 'cuid', + validation: "cuid", code: ZodIssueCode.invalid_string, - message: check.message, + message: check.message }); status.dirty(); } - } else if (check.kind === 'cuid2') { + } else if (check.kind === "cuid2") { if (!cuid2Regex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: 'cuid2', + validation: "cuid2", code: ZodIssueCode.invalid_string, - message: check.message, + message: check.message }); status.dirty(); } - } else if (check.kind === 'ulid') { + } else if (check.kind === "ulid") { if (!ulidRegex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: 'ulid', + validation: "ulid", code: ZodIssueCode.invalid_string, - message: check.message, + message: check.message }); status.dirty(); } - } else if (check.kind === 'url') { + } else if (check.kind === "url") { try { new URL(input.data); } catch { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: 'url', + validation: "url", code: ZodIssueCode.invalid_string, - message: check.message, + message: check.message }); status.dirty(); } - } else if (check.kind === 'regex') { + } else if (check.kind === "regex") { check.regex.lastIndex = 0; const testResult = check.regex.test(input.data); if (!testResult) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: 'regex', + validation: "regex", code: ZodIssueCode.invalid_string, - message: check.message, + message: check.message }); status.dirty(); } - } else if (check.kind === 'trim') { + } else if (check.kind === "trim") { input.data = input.data.trim(); - } else if (check.kind === 'includes') { + } else if (check.kind === "includes") { if (!input.data.includes(check.value, check.position)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.invalid_string, validation: { includes: check.value, position: check.position }, - message: check.message, + message: check.message }); status.dirty(); } - } else if (check.kind === 'toLowerCase') { + } else if (check.kind === "toLowerCase") { input.data = input.data.toLowerCase(); - } else if (check.kind === 'toUpperCase') { + } else if (check.kind === "toUpperCase") { input.data = input.data.toUpperCase(); - } else if (check.kind === 'startsWith') { + } else if (check.kind === "startsWith") { if (!input.data.startsWith(check.value)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.invalid_string, validation: { startsWith: check.value }, - message: check.message, + message: check.message }); status.dirty(); } - } else if (check.kind === 'endsWith') { + } else if (check.kind === "endsWith") { if (!input.data.endsWith(check.value)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.invalid_string, validation: { endsWith: check.value }, - message: check.message, + message: check.message }); status.dirty(); } - } else if (check.kind === 'datetime') { + } else if (check.kind === "datetime") { const regex = datetimeRegex(check); if (!regex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.invalid_string, - validation: 'datetime', - message: check.message, + validation: "datetime", + message: check.message }); status.dirty(); } - } else if (check.kind === 'date') { + } else if (check.kind === "date") { const regex = dateRegex; if (!regex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.invalid_string, - validation: 'date', - message: check.message, + validation: "date", + message: check.message }); status.dirty(); } - } else if (check.kind === 'time') { + } else if (check.kind === "time") { const regex = timeRegex(check); if (!regex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.invalid_string, - validation: 'time', - message: check.message, + validation: "time", + message: check.message }); status.dirty(); } - } else if (check.kind === 'duration') { + } else if (check.kind === "duration") { if (!durationRegex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: 'duration', + validation: "duration", code: ZodIssueCode.invalid_string, - message: check.message, + message: check.message }); status.dirty(); } - } else if (check.kind === 'ip') { + } else if (check.kind === "ip") { if (!isValidIP(input.data, check.version)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: 'ip', + validation: "ip", code: ZodIssueCode.invalid_string, - message: check.message, + message: check.message }); status.dirty(); } - } else if (check.kind === 'jwt') { + } else if (check.kind === "jwt") { if (!isValidJWT(input.data, check.alg)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: 'jwt', + validation: "jwt", code: ZodIssueCode.invalid_string, - message: check.message, + message: check.message }); status.dirty(); } - } else if (check.kind === 'cidr') { + } else if (check.kind === "cidr") { if (!isValidCidr(input.data, check.version)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: 'cidr', + validation: "cidr", code: ZodIssueCode.invalid_string, - message: check.message, + message: check.message }); status.dirty(); } - } else if (check.kind === 'base64') { + } else if (check.kind === "base64") { if (!base64Regex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: 'base64', + validation: "base64", code: ZodIssueCode.invalid_string, - message: check.message, + message: check.message }); status.dirty(); } - } else if (check.kind === 'base64url') { + } else if (check.kind === "base64url") { if (!base64urlRegex.test(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { - validation: 'base64url', + validation: "base64url", code: ZodIssueCode.invalid_string, - message: check.message, + message: check.message }); status.dirty(); } @@ -1586,145 +1477,143 @@ var ZodString = class _ZodString extends ZodType { return this.refinement((data) => regex.test(data), { validation, code: ZodIssueCode.invalid_string, - ...errorUtil.errToObj(message), + ...errorUtil.errToObj(message) }); } _addCheck(check) { return new _ZodString({ ...this._def, - checks: [...this._def.checks, check], + checks: [...this._def.checks, check] }); } email(message) { - return this._addCheck({ kind: 'email', ...errorUtil.errToObj(message) }); + return this._addCheck({ kind: "email", ...errorUtil.errToObj(message) }); } url(message) { - return this._addCheck({ kind: 'url', ...errorUtil.errToObj(message) }); + return this._addCheck({ kind: "url", ...errorUtil.errToObj(message) }); } emoji(message) { - return this._addCheck({ kind: 'emoji', ...errorUtil.errToObj(message) }); + return this._addCheck({ kind: "emoji", ...errorUtil.errToObj(message) }); } uuid(message) { - return this._addCheck({ kind: 'uuid', ...errorUtil.errToObj(message) }); + return this._addCheck({ kind: "uuid", ...errorUtil.errToObj(message) }); } nanoid(message) { - return this._addCheck({ kind: 'nanoid', ...errorUtil.errToObj(message) }); + return this._addCheck({ kind: "nanoid", ...errorUtil.errToObj(message) }); } cuid(message) { - return this._addCheck({ kind: 'cuid', ...errorUtil.errToObj(message) }); + return this._addCheck({ kind: "cuid", ...errorUtil.errToObj(message) }); } cuid2(message) { - return this._addCheck({ kind: 'cuid2', ...errorUtil.errToObj(message) }); + return this._addCheck({ kind: "cuid2", ...errorUtil.errToObj(message) }); } ulid(message) { - return this._addCheck({ kind: 'ulid', ...errorUtil.errToObj(message) }); + return this._addCheck({ kind: "ulid", ...errorUtil.errToObj(message) }); } base64(message) { - return this._addCheck({ kind: 'base64', ...errorUtil.errToObj(message) }); + return this._addCheck({ kind: "base64", ...errorUtil.errToObj(message) }); } base64url(message) { return this._addCheck({ - kind: 'base64url', - ...errorUtil.errToObj(message), + kind: "base64url", + ...errorUtil.errToObj(message) }); } jwt(options) { - return this._addCheck({ kind: 'jwt', ...errorUtil.errToObj(options) }); + return this._addCheck({ kind: "jwt", ...errorUtil.errToObj(options) }); } ip(options) { - return this._addCheck({ kind: 'ip', ...errorUtil.errToObj(options) }); + return this._addCheck({ kind: "ip", ...errorUtil.errToObj(options) }); } cidr(options) { - return this._addCheck({ kind: 'cidr', ...errorUtil.errToObj(options) }); + return this._addCheck({ kind: "cidr", ...errorUtil.errToObj(options) }); } datetime(options) { - if (typeof options === 'string') { + if (typeof options === "string") { return this._addCheck({ - kind: 'datetime', + kind: "datetime", precision: null, offset: false, local: false, - message: options, + message: options }); } return this._addCheck({ - kind: 'datetime', - precision: - typeof options?.precision === 'undefined' ? null : options?.precision, + kind: "datetime", + precision: typeof options?.precision === "undefined" ? null : options?.precision, offset: options?.offset ?? false, local: options?.local ?? false, - ...errorUtil.errToObj(options?.message), + ...errorUtil.errToObj(options?.message) }); } date(message) { - return this._addCheck({ kind: 'date', message }); + return this._addCheck({ kind: "date", message }); } time(options) { - if (typeof options === 'string') { + if (typeof options === "string") { return this._addCheck({ - kind: 'time', + kind: "time", precision: null, - message: options, + message: options }); } return this._addCheck({ - kind: 'time', - precision: - typeof options?.precision === 'undefined' ? null : options?.precision, - ...errorUtil.errToObj(options?.message), + kind: "time", + precision: typeof options?.precision === "undefined" ? null : options?.precision, + ...errorUtil.errToObj(options?.message) }); } duration(message) { - return this._addCheck({ kind: 'duration', ...errorUtil.errToObj(message) }); + return this._addCheck({ kind: "duration", ...errorUtil.errToObj(message) }); } regex(regex, message) { return this._addCheck({ - kind: 'regex', + kind: "regex", regex, - ...errorUtil.errToObj(message), + ...errorUtil.errToObj(message) }); } includes(value, options) { return this._addCheck({ - kind: 'includes', + kind: "includes", value, position: options?.position, - ...errorUtil.errToObj(options?.message), + ...errorUtil.errToObj(options?.message) }); } startsWith(value, message) { return this._addCheck({ - kind: 'startsWith', + kind: "startsWith", value, - ...errorUtil.errToObj(message), + ...errorUtil.errToObj(message) }); } endsWith(value, message) { return this._addCheck({ - kind: 'endsWith', + kind: "endsWith", value, - ...errorUtil.errToObj(message), + ...errorUtil.errToObj(message) }); } min(minLength, message) { return this._addCheck({ - kind: 'min', + kind: "min", value: minLength, - ...errorUtil.errToObj(message), + ...errorUtil.errToObj(message) }); } max(maxLength, message) { return this._addCheck({ - kind: 'max', + kind: "max", value: maxLength, - ...errorUtil.errToObj(message), + ...errorUtil.errToObj(message) }); } length(len, message) { return this._addCheck({ - kind: 'length', + kind: "length", value: len, - ...errorUtil.errToObj(message), + ...errorUtil.errToObj(message) }); } /** @@ -1736,74 +1625,75 @@ var ZodString = class _ZodString extends ZodType { trim() { return new _ZodString({ ...this._def, - checks: [...this._def.checks, { kind: 'trim' }], + checks: [...this._def.checks, { kind: "trim" }] }); } toLowerCase() { return new _ZodString({ ...this._def, - checks: [...this._def.checks, { kind: 'toLowerCase' }], + checks: [...this._def.checks, { kind: "toLowerCase" }] }); } toUpperCase() { return new _ZodString({ ...this._def, - checks: [...this._def.checks, { kind: 'toUpperCase' }], + checks: [...this._def.checks, { kind: "toUpperCase" }] }); } get isDatetime() { - return !!this._def.checks.find((ch) => ch.kind === 'datetime'); + return !!this._def.checks.find((ch) => ch.kind === "datetime"); } get isDate() { - return !!this._def.checks.find((ch) => ch.kind === 'date'); + return !!this._def.checks.find((ch) => ch.kind === "date"); } get isTime() { - return !!this._def.checks.find((ch) => ch.kind === 'time'); + return !!this._def.checks.find((ch) => ch.kind === "time"); } get isDuration() { - return !!this._def.checks.find((ch) => ch.kind === 'duration'); + return !!this._def.checks.find((ch) => ch.kind === "duration"); } get isEmail() { - return !!this._def.checks.find((ch) => ch.kind === 'email'); + return !!this._def.checks.find((ch) => ch.kind === "email"); } get isURL() { - return !!this._def.checks.find((ch) => ch.kind === 'url'); + return !!this._def.checks.find((ch) => ch.kind === "url"); } get isEmoji() { - return !!this._def.checks.find((ch) => ch.kind === 'emoji'); + return !!this._def.checks.find((ch) => ch.kind === "emoji"); } get isUUID() { - return !!this._def.checks.find((ch) => ch.kind === 'uuid'); + return !!this._def.checks.find((ch) => ch.kind === "uuid"); } get isNANOID() { - return !!this._def.checks.find((ch) => ch.kind === 'nanoid'); + return !!this._def.checks.find((ch) => ch.kind === "nanoid"); } get isCUID() { - return !!this._def.checks.find((ch) => ch.kind === 'cuid'); + return !!this._def.checks.find((ch) => ch.kind === "cuid"); } get isCUID2() { - return !!this._def.checks.find((ch) => ch.kind === 'cuid2'); + return !!this._def.checks.find((ch) => ch.kind === "cuid2"); } get isULID() { - return !!this._def.checks.find((ch) => ch.kind === 'ulid'); + return !!this._def.checks.find((ch) => ch.kind === "ulid"); } get isIP() { - return !!this._def.checks.find((ch) => ch.kind === 'ip'); + return !!this._def.checks.find((ch) => ch.kind === "ip"); } get isCIDR() { - return !!this._def.checks.find((ch) => ch.kind === 'cidr'); + return !!this._def.checks.find((ch) => ch.kind === "cidr"); } get isBase64() { - return !!this._def.checks.find((ch) => ch.kind === 'base64'); + return !!this._def.checks.find((ch) => ch.kind === "base64"); } get isBase64url() { - return !!this._def.checks.find((ch) => ch.kind === 'base64url'); + return !!this._def.checks.find((ch) => ch.kind === "base64url"); } get minLength() { let min = null; for (const ch of this._def.checks) { - if (ch.kind === 'min') { - if (min === null || ch.value > min) min = ch.value; + if (ch.kind === "min") { + if (min === null || ch.value > min) + min = ch.value; } } return min; @@ -1811,8 +1701,9 @@ var ZodString = class _ZodString extends ZodType { get maxLength() { let max = null; for (const ch of this._def.checks) { - if (ch.kind === 'max') { - if (max === null || ch.value < max) max = ch.value; + if (ch.kind === "max") { + if (max === null || ch.value < max) + max = ch.value; } } return max; @@ -1823,16 +1714,16 @@ ZodString.create = (params) => { checks: [], typeName: ZodFirstPartyTypeKind.ZodString, coerce: params?.coerce ?? false, - ...processCreateParams(params), + ...processCreateParams(params) }); }; function floatSafeRemainder(val, step) { - const valDecCount = (val.toString().split('.')[1] || '').length; - const stepDecCount = (step.toString().split('.')[1] || '').length; + const valDecCount = (val.toString().split(".")[1] || "").length; + const stepDecCount = (step.toString().split(".")[1] || "").length; const decCount = valDecCount > stepDecCount ? valDecCount : stepDecCount; - const valInt = Number.parseInt(val.toFixed(decCount).replace('.', '')); - const stepInt = Number.parseInt(step.toFixed(decCount).replace('.', '')); - return (valInt % stepInt) / 10 ** decCount; + const valInt = Number.parseInt(val.toFixed(decCount).replace(".", "")); + const stepInt = Number.parseInt(step.toFixed(decCount).replace(".", "")); + return valInt % stepInt / 10 ** decCount; } var ZodNumber = class _ZodNumber extends ZodType { constructor() { @@ -1851,72 +1742,68 @@ var ZodNumber = class _ZodNumber extends ZodType { addIssueToContext(ctx2, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.number, - received: ctx2.parsedType, + received: ctx2.parsedType }); return INVALID; } let ctx = void 0; const status = new ParseStatus(); for (const check of this._def.checks) { - if (check.kind === 'int') { + if (check.kind === "int") { if (!util.isInteger(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, - expected: 'integer', - received: 'float', - message: check.message, + expected: "integer", + received: "float", + message: check.message }); status.dirty(); } - } else if (check.kind === 'min') { - const tooSmall = check.inclusive - ? input.data < check.value - : input.data <= check.value; + } else if (check.kind === "min") { + const tooSmall = check.inclusive ? input.data < check.value : input.data <= check.value; if (tooSmall) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.too_small, minimum: check.value, - type: 'number', + type: "number", inclusive: check.inclusive, exact: false, - message: check.message, + message: check.message }); status.dirty(); } - } else if (check.kind === 'max') { - const tooBig = check.inclusive - ? input.data > check.value - : input.data >= check.value; + } else if (check.kind === "max") { + const tooBig = check.inclusive ? input.data > check.value : input.data >= check.value; if (tooBig) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.too_big, maximum: check.value, - type: 'number', + type: "number", inclusive: check.inclusive, exact: false, - message: check.message, + message: check.message }); status.dirty(); } - } else if (check.kind === 'multipleOf') { + } else if (check.kind === "multipleOf") { if (floatSafeRemainder(input.data, check.value) !== 0) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.not_multiple_of, multipleOf: check.value, - message: check.message, + message: check.message }); status.dirty(); } - } else if (check.kind === 'finite') { + } else if (check.kind === "finite") { if (!Number.isFinite(input.data)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.not_finite, - message: check.message, + message: check.message }); status.dirty(); } @@ -1927,16 +1814,16 @@ var ZodNumber = class _ZodNumber extends ZodType { return { status: status.value, value: input.data }; } gte(value, message) { - return this.setLimit('min', value, true, errorUtil.toString(message)); + return this.setLimit("min", value, true, errorUtil.toString(message)); } gt(value, message) { - return this.setLimit('min', value, false, errorUtil.toString(message)); + return this.setLimit("min", value, false, errorUtil.toString(message)); } lte(value, message) { - return this.setLimit('max', value, true, errorUtil.toString(message)); + return this.setLimit("max", value, true, errorUtil.toString(message)); } lt(value, message) { - return this.setLimit('max', value, false, errorUtil.toString(message)); + return this.setLimit("max", value, false, errorUtil.toString(message)); } setLimit(kind, value, inclusive, message) { return new _ZodNumber({ @@ -1947,86 +1834,87 @@ var ZodNumber = class _ZodNumber extends ZodType { kind, value, inclusive, - message: errorUtil.toString(message), - }, - ], + message: errorUtil.toString(message) + } + ] }); } _addCheck(check) { return new _ZodNumber({ ...this._def, - checks: [...this._def.checks, check], + checks: [...this._def.checks, check] }); } int(message) { return this._addCheck({ - kind: 'int', - message: errorUtil.toString(message), + kind: "int", + message: errorUtil.toString(message) }); } positive(message) { return this._addCheck({ - kind: 'min', + kind: "min", value: 0, inclusive: false, - message: errorUtil.toString(message), + message: errorUtil.toString(message) }); } negative(message) { return this._addCheck({ - kind: 'max', + kind: "max", value: 0, inclusive: false, - message: errorUtil.toString(message), + message: errorUtil.toString(message) }); } nonpositive(message) { return this._addCheck({ - kind: 'max', + kind: "max", value: 0, inclusive: true, - message: errorUtil.toString(message), + message: errorUtil.toString(message) }); } nonnegative(message) { return this._addCheck({ - kind: 'min', + kind: "min", value: 0, inclusive: true, - message: errorUtil.toString(message), + message: errorUtil.toString(message) }); } multipleOf(value, message) { return this._addCheck({ - kind: 'multipleOf', + kind: "multipleOf", value, - message: errorUtil.toString(message), + message: errorUtil.toString(message) }); } finite(message) { return this._addCheck({ - kind: 'finite', - message: errorUtil.toString(message), + kind: "finite", + message: errorUtil.toString(message) }); } safe(message) { return this._addCheck({ - kind: 'min', + kind: "min", inclusive: true, value: Number.MIN_SAFE_INTEGER, - message: errorUtil.toString(message), + message: errorUtil.toString(message) })._addCheck({ - kind: 'max', + kind: "max", inclusive: true, value: Number.MAX_SAFE_INTEGER, - message: errorUtil.toString(message), + message: errorUtil.toString(message) }); } get minValue() { let min = null; for (const ch of this._def.checks) { - if (ch.kind === 'min') { - if (min === null || ch.value > min) min = ch.value; + if (ch.kind === "min") { + if (min === null || ch.value > min) + min = ch.value; } } return min; @@ -2034,33 +1922,28 @@ var ZodNumber = class _ZodNumber extends ZodType { get maxValue() { let max = null; for (const ch of this._def.checks) { - if (ch.kind === 'max') { - if (max === null || ch.value < max) max = ch.value; + if (ch.kind === "max") { + if (max === null || ch.value < max) + max = ch.value; } } return max; } get isInt() { - return !!this._def.checks.find( - (ch) => - ch.kind === 'int' || - (ch.kind === 'multipleOf' && util.isInteger(ch.value)) - ); + return !!this._def.checks.find((ch) => ch.kind === "int" || ch.kind === "multipleOf" && util.isInteger(ch.value)); } get isFinite() { let max = null; let min = null; for (const ch of this._def.checks) { - if ( - ch.kind === 'finite' || - ch.kind === 'int' || - ch.kind === 'multipleOf' - ) { + if (ch.kind === "finite" || ch.kind === "int" || ch.kind === "multipleOf") { return true; - } else if (ch.kind === 'min') { - if (min === null || ch.value > min) min = ch.value; - } else if (ch.kind === 'max') { - if (max === null || ch.value < max) max = ch.value; + } else if (ch.kind === "min") { + if (min === null || ch.value > min) + min = ch.value; + } else if (ch.kind === "max") { + if (max === null || ch.value < max) + max = ch.value; } } return Number.isFinite(min) && Number.isFinite(max); @@ -2071,7 +1954,7 @@ ZodNumber.create = (params) => { checks: [], typeName: ZodFirstPartyTypeKind.ZodNumber, coerce: params?.coerce || false, - ...processCreateParams(params), + ...processCreateParams(params) }); }; var ZodBigInt = class _ZodBigInt extends ZodType { @@ -2095,43 +1978,39 @@ var ZodBigInt = class _ZodBigInt extends ZodType { let ctx = void 0; const status = new ParseStatus(); for (const check of this._def.checks) { - if (check.kind === 'min') { - const tooSmall = check.inclusive - ? input.data < check.value - : input.data <= check.value; + if (check.kind === "min") { + const tooSmall = check.inclusive ? input.data < check.value : input.data <= check.value; if (tooSmall) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.too_small, - type: 'bigint', + type: "bigint", minimum: check.value, inclusive: check.inclusive, - message: check.message, + message: check.message }); status.dirty(); } - } else if (check.kind === 'max') { - const tooBig = check.inclusive - ? input.data > check.value - : input.data >= check.value; + } else if (check.kind === "max") { + const tooBig = check.inclusive ? input.data > check.value : input.data >= check.value; if (tooBig) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.too_big, - type: 'bigint', + type: "bigint", maximum: check.value, inclusive: check.inclusive, - message: check.message, + message: check.message }); status.dirty(); } - } else if (check.kind === 'multipleOf') { + } else if (check.kind === "multipleOf") { if (input.data % check.value !== BigInt(0)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { code: ZodIssueCode.not_multiple_of, multipleOf: check.value, - message: check.message, + message: check.message }); status.dirty(); } @@ -2146,21 +2025,21 @@ var ZodBigInt = class _ZodBigInt extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.bigint, - received: ctx.parsedType, + received: ctx.parsedType }); return INVALID; } gte(value, message) { - return this.setLimit('min', value, true, errorUtil.toString(message)); + return this.setLimit("min", value, true, errorUtil.toString(message)); } gt(value, message) { - return this.setLimit('min', value, false, errorUtil.toString(message)); + return this.setLimit("min", value, false, errorUtil.toString(message)); } lte(value, message) { - return this.setLimit('max', value, true, errorUtil.toString(message)); + return this.setLimit("max", value, true, errorUtil.toString(message)); } lt(value, message) { - return this.setLimit('max', value, false, errorUtil.toString(message)); + return this.setLimit("max", value, false, errorUtil.toString(message)); } setLimit(kind, value, inclusive, message) { return new _ZodBigInt({ @@ -2171,61 +2050,62 @@ var ZodBigInt = class _ZodBigInt extends ZodType { kind, value, inclusive, - message: errorUtil.toString(message), - }, - ], + message: errorUtil.toString(message) + } + ] }); } _addCheck(check) { return new _ZodBigInt({ ...this._def, - checks: [...this._def.checks, check], + checks: [...this._def.checks, check] }); } positive(message) { return this._addCheck({ - kind: 'min', + kind: "min", value: BigInt(0), inclusive: false, - message: errorUtil.toString(message), + message: errorUtil.toString(message) }); } negative(message) { return this._addCheck({ - kind: 'max', + kind: "max", value: BigInt(0), inclusive: false, - message: errorUtil.toString(message), + message: errorUtil.toString(message) }); } nonpositive(message) { return this._addCheck({ - kind: 'max', + kind: "max", value: BigInt(0), inclusive: true, - message: errorUtil.toString(message), + message: errorUtil.toString(message) }); } nonnegative(message) { return this._addCheck({ - kind: 'min', + kind: "min", value: BigInt(0), inclusive: true, - message: errorUtil.toString(message), + message: errorUtil.toString(message) }); } multipleOf(value, message) { return this._addCheck({ - kind: 'multipleOf', + kind: "multipleOf", value, - message: errorUtil.toString(message), + message: errorUtil.toString(message) }); } get minValue() { let min = null; for (const ch of this._def.checks) { - if (ch.kind === 'min') { - if (min === null || ch.value > min) min = ch.value; + if (ch.kind === "min") { + if (min === null || ch.value > min) + min = ch.value; } } return min; @@ -2233,8 +2113,9 @@ var ZodBigInt = class _ZodBigInt extends ZodType { get maxValue() { let max = null; for (const ch of this._def.checks) { - if (ch.kind === 'max') { - if (max === null || ch.value < max) max = ch.value; + if (ch.kind === "max") { + if (max === null || ch.value < max) + max = ch.value; } } return max; @@ -2245,7 +2126,7 @@ ZodBigInt.create = (params) => { checks: [], typeName: ZodFirstPartyTypeKind.ZodBigInt, coerce: params?.coerce ?? false, - ...processCreateParams(params), + ...processCreateParams(params) }); }; var ZodBoolean = class extends ZodType { @@ -2259,7 +2140,7 @@ var ZodBoolean = class extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.boolean, - received: ctx.parsedType, + received: ctx.parsedType }); return INVALID; } @@ -2270,7 +2151,7 @@ ZodBoolean.create = (params) => { return new ZodBoolean({ typeName: ZodFirstPartyTypeKind.ZodBoolean, coerce: params?.coerce || false, - ...processCreateParams(params), + ...processCreateParams(params) }); }; var ZodDate = class _ZodDate extends ZodType { @@ -2284,21 +2165,21 @@ var ZodDate = class _ZodDate extends ZodType { addIssueToContext(ctx2, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.date, - received: ctx2.parsedType, + received: ctx2.parsedType }); return INVALID; } if (Number.isNaN(input.data.getTime())) { const ctx2 = this._getOrReturnCtx(input); addIssueToContext(ctx2, { - code: ZodIssueCode.invalid_date, + code: ZodIssueCode.invalid_date }); return INVALID; } const status = new ParseStatus(); let ctx = void 0; for (const check of this._def.checks) { - if (check.kind === 'min') { + if (check.kind === "min") { if (input.data.getTime() < check.value) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { @@ -2307,11 +2188,11 @@ var ZodDate = class _ZodDate extends ZodType { inclusive: true, exact: false, minimum: check.value, - type: 'date', + type: "date" }); status.dirty(); } - } else if (check.kind === 'max') { + } else if (check.kind === "max") { if (input.data.getTime() > check.value) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { @@ -2320,7 +2201,7 @@ var ZodDate = class _ZodDate extends ZodType { inclusive: true, exact: false, maximum: check.value, - type: 'date', + type: "date" }); status.dirty(); } @@ -2330,34 +2211,35 @@ var ZodDate = class _ZodDate extends ZodType { } return { status: status.value, - value: new Date(input.data.getTime()), + value: new Date(input.data.getTime()) }; } _addCheck(check) { return new _ZodDate({ ...this._def, - checks: [...this._def.checks, check], + checks: [...this._def.checks, check] }); } min(minDate, message) { return this._addCheck({ - kind: 'min', + kind: "min", value: minDate.getTime(), - message: errorUtil.toString(message), + message: errorUtil.toString(message) }); } max(maxDate, message) { return this._addCheck({ - kind: 'max', + kind: "max", value: maxDate.getTime(), - message: errorUtil.toString(message), + message: errorUtil.toString(message) }); } get minDate() { let min = null; for (const ch of this._def.checks) { - if (ch.kind === 'min') { - if (min === null || ch.value > min) min = ch.value; + if (ch.kind === "min") { + if (min === null || ch.value > min) + min = ch.value; } } return min != null ? new Date(min) : null; @@ -2365,8 +2247,9 @@ var ZodDate = class _ZodDate extends ZodType { get maxDate() { let max = null; for (const ch of this._def.checks) { - if (ch.kind === 'max') { - if (max === null || ch.value < max) max = ch.value; + if (ch.kind === "max") { + if (max === null || ch.value < max) + max = ch.value; } } return max != null ? new Date(max) : null; @@ -2377,7 +2260,7 @@ ZodDate.create = (params) => { checks: [], coerce: params?.coerce || false, typeName: ZodFirstPartyTypeKind.ZodDate, - ...processCreateParams(params), + ...processCreateParams(params) }); }; var ZodSymbol = class extends ZodType { @@ -2388,7 +2271,7 @@ var ZodSymbol = class extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.symbol, - received: ctx.parsedType, + received: ctx.parsedType }); return INVALID; } @@ -2398,7 +2281,7 @@ var ZodSymbol = class extends ZodType { ZodSymbol.create = (params) => { return new ZodSymbol({ typeName: ZodFirstPartyTypeKind.ZodSymbol, - ...processCreateParams(params), + ...processCreateParams(params) }); }; var ZodUndefined = class extends ZodType { @@ -2409,7 +2292,7 @@ var ZodUndefined = class extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.undefined, - received: ctx.parsedType, + received: ctx.parsedType }); return INVALID; } @@ -2419,7 +2302,7 @@ var ZodUndefined = class extends ZodType { ZodUndefined.create = (params) => { return new ZodUndefined({ typeName: ZodFirstPartyTypeKind.ZodUndefined, - ...processCreateParams(params), + ...processCreateParams(params) }); }; var ZodNull = class extends ZodType { @@ -2430,7 +2313,7 @@ var ZodNull = class extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.null, - received: ctx.parsedType, + received: ctx.parsedType }); return INVALID; } @@ -2440,7 +2323,7 @@ var ZodNull = class extends ZodType { ZodNull.create = (params) => { return new ZodNull({ typeName: ZodFirstPartyTypeKind.ZodNull, - ...processCreateParams(params), + ...processCreateParams(params) }); }; var ZodAny = class extends ZodType { @@ -2455,7 +2338,7 @@ var ZodAny = class extends ZodType { ZodAny.create = (params) => { return new ZodAny({ typeName: ZodFirstPartyTypeKind.ZodAny, - ...processCreateParams(params), + ...processCreateParams(params) }); }; var ZodUnknown = class extends ZodType { @@ -2470,7 +2353,7 @@ var ZodUnknown = class extends ZodType { ZodUnknown.create = (params) => { return new ZodUnknown({ typeName: ZodFirstPartyTypeKind.ZodUnknown, - ...processCreateParams(params), + ...processCreateParams(params) }); }; var ZodNever = class extends ZodType { @@ -2479,7 +2362,7 @@ var ZodNever = class extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.never, - received: ctx.parsedType, + received: ctx.parsedType }); return INVALID; } @@ -2487,7 +2370,7 @@ var ZodNever = class extends ZodType { ZodNever.create = (params) => { return new ZodNever({ typeName: ZodFirstPartyTypeKind.ZodNever, - ...processCreateParams(params), + ...processCreateParams(params) }); }; var ZodVoid = class extends ZodType { @@ -2498,7 +2381,7 @@ var ZodVoid = class extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.void, - received: ctx.parsedType, + received: ctx.parsedType }); return INVALID; } @@ -2508,7 +2391,7 @@ var ZodVoid = class extends ZodType { ZodVoid.create = (params) => { return new ZodVoid({ typeName: ZodFirstPartyTypeKind.ZodVoid, - ...processCreateParams(params), + ...processCreateParams(params) }); }; var ZodArray = class _ZodArray extends ZodType { @@ -2519,7 +2402,7 @@ var ZodArray = class _ZodArray extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.array, - received: ctx.parsedType, + received: ctx.parsedType }); return INVALID; } @@ -2531,10 +2414,10 @@ var ZodArray = class _ZodArray extends ZodType { code: tooBig ? ZodIssueCode.too_big : ZodIssueCode.too_small, minimum: tooSmall ? def.exactLength.value : void 0, maximum: tooBig ? def.exactLength.value : void 0, - type: 'array', + type: "array", inclusive: true, exact: true, - message: def.exactLength.message, + message: def.exactLength.message }); status.dirty(); } @@ -2544,10 +2427,10 @@ var ZodArray = class _ZodArray extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.too_small, minimum: def.minLength.value, - type: 'array', + type: "array", inclusive: true, exact: false, - message: def.minLength.message, + message: def.minLength.message }); status.dirty(); } @@ -2557,29 +2440,23 @@ var ZodArray = class _ZodArray extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.too_big, maximum: def.maxLength.value, - type: 'array', + type: "array", inclusive: true, exact: false, - message: def.maxLength.message, + message: def.maxLength.message }); status.dirty(); } } if (ctx.common.async) { - return Promise.all( - [...ctx.data].map((item, i) => { - return def.type._parseAsync( - new ParseInputLazyPath(ctx, item, ctx.path, i) - ); - }) - ).then((result2) => { + return Promise.all([...ctx.data].map((item, i) => { + return def.type._parseAsync(new ParseInputLazyPath(ctx, item, ctx.path, i)); + })).then((result2) => { return ParseStatus.mergeArray(status, result2); }); } const result = [...ctx.data].map((item, i) => { - return def.type._parseSync( - new ParseInputLazyPath(ctx, item, ctx.path, i) - ); + return def.type._parseSync(new ParseInputLazyPath(ctx, item, ctx.path, i)); }); return ParseStatus.mergeArray(status, result); } @@ -2589,19 +2466,19 @@ var ZodArray = class _ZodArray extends ZodType { min(minLength, message) { return new _ZodArray({ ...this._def, - minLength: { value: minLength, message: errorUtil.toString(message) }, + minLength: { value: minLength, message: errorUtil.toString(message) } }); } max(maxLength, message) { return new _ZodArray({ ...this._def, - maxLength: { value: maxLength, message: errorUtil.toString(message) }, + maxLength: { value: maxLength, message: errorUtil.toString(message) } }); } length(len, message) { return new _ZodArray({ ...this._def, - exactLength: { value: len, message: errorUtil.toString(message) }, + exactLength: { value: len, message: errorUtil.toString(message) } }); } nonempty(message) { @@ -2615,7 +2492,7 @@ ZodArray.create = (schema, params) => { maxLength: null, exactLength: null, typeName: ZodFirstPartyTypeKind.ZodArray, - ...processCreateParams(params), + ...processCreateParams(params) }); }; function deepPartialify(schema) { @@ -2627,12 +2504,12 @@ function deepPartialify(schema) { } return new ZodObject({ ...schema._def, - shape: () => newShape, + shape: () => newShape }); } else if (schema instanceof ZodArray) { return new ZodArray({ ...schema._def, - type: deepPartialify(schema.element), + type: deepPartialify(schema.element) }); } else if (schema instanceof ZodOptional) { return ZodOptional.create(deepPartialify(schema.unwrap())); @@ -2652,7 +2529,8 @@ var ZodObject = class _ZodObject extends ZodType { this.augment = this.extend; } _getCached() { - if (this._cached !== null) return this._cached; + if (this._cached !== null) + return this._cached; const shape = this._def.shape(); const keys = util.objectKeys(shape); this._cached = { shape, keys }; @@ -2665,19 +2543,14 @@ var ZodObject = class _ZodObject extends ZodType { addIssueToContext(ctx2, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.object, - received: ctx2.parsedType, + received: ctx2.parsedType }); return INVALID; } const { status, ctx } = this._processInputParams(input); const { shape, keys: shapeKeys } = this._getCached(); const extraKeys = []; - if ( - !( - this._def.catchall instanceof ZodNever && - this._def.unknownKeys === 'strip' - ) - ) { + if (!(this._def.catchall instanceof ZodNever && this._def.unknownKeys === "strip")) { for (const key in ctx.data) { if (!shapeKeys.includes(key)) { extraKeys.push(key); @@ -2689,31 +2562,29 @@ var ZodObject = class _ZodObject extends ZodType { const keyValidator = shape[key]; const value = ctx.data[key]; pairs.push({ - key: { status: 'valid', value: key }, - value: keyValidator._parse( - new ParseInputLazyPath(ctx, value, ctx.path, key) - ), - alwaysSet: key in ctx.data, + key: { status: "valid", value: key }, + value: keyValidator._parse(new ParseInputLazyPath(ctx, value, ctx.path, key)), + alwaysSet: key in ctx.data }); } if (this._def.catchall instanceof ZodNever) { const unknownKeys = this._def.unknownKeys; - if (unknownKeys === 'passthrough') { + if (unknownKeys === "passthrough") { for (const key of extraKeys) { pairs.push({ - key: { status: 'valid', value: key }, - value: { status: 'valid', value: ctx.data[key] }, + key: { status: "valid", value: key }, + value: { status: "valid", value: ctx.data[key] } }); } - } else if (unknownKeys === 'strict') { + } else if (unknownKeys === "strict") { if (extraKeys.length > 0) { addIssueToContext(ctx, { code: ZodIssueCode.unrecognized_keys, - keys: extraKeys, + keys: extraKeys }); status.dirty(); } - } else if (unknownKeys === 'strip') { + } else if (unknownKeys === "strip") { } else { throw new Error(`Internal ZodObject error: invalid unknownKeys value.`); } @@ -2722,33 +2593,31 @@ var ZodObject = class _ZodObject extends ZodType { for (const key of extraKeys) { const value = ctx.data[key]; pairs.push({ - key: { status: 'valid', value: key }, + key: { status: "valid", value: key }, value: catchall._parse( new ParseInputLazyPath(ctx, value, ctx.path, key) //, ctx.child(key), value, getParsedType(value) ), - alwaysSet: key in ctx.data, + alwaysSet: key in ctx.data }); } } if (ctx.common.async) { - return Promise.resolve() - .then(async () => { - const syncPairs = []; - for (const pair of pairs) { - const key = await pair.key; - const value = await pair.value; - syncPairs.push({ - key, - value, - alwaysSet: pair.alwaysSet, - }); - } - return syncPairs; - }) - .then((syncPairs) => { - return ParseStatus.mergeObjectSync(status, syncPairs); - }); + return Promise.resolve().then(async () => { + const syncPairs = []; + for (const pair of pairs) { + const key = await pair.key; + const value = await pair.value; + syncPairs.push({ + key, + value, + alwaysSet: pair.alwaysSet + }); + } + return syncPairs; + }).then((syncPairs) => { + return ParseStatus.mergeObjectSync(status, syncPairs); + }); } else { return ParseStatus.mergeObjectSync(status, pairs); } @@ -2760,34 +2629,31 @@ var ZodObject = class _ZodObject extends ZodType { errorUtil.errToObj; return new _ZodObject({ ...this._def, - unknownKeys: 'strict', - ...(message !== void 0 - ? { - errorMap: (issue, ctx) => { - const defaultError = - this._def.errorMap?.(issue, ctx).message ?? ctx.defaultError; - if (issue.code === 'unrecognized_keys') - return { - message: errorUtil.errToObj(message).message ?? defaultError, - }; - return { - message: defaultError, - }; - }, - } - : {}), + unknownKeys: "strict", + ...message !== void 0 ? { + errorMap: (issue, ctx) => { + const defaultError = this._def.errorMap?.(issue, ctx).message ?? ctx.defaultError; + if (issue.code === "unrecognized_keys") + return { + message: errorUtil.errToObj(message).message ?? defaultError + }; + return { + message: defaultError + }; + } + } : {} }); } strip() { return new _ZodObject({ ...this._def, - unknownKeys: 'strip', + unknownKeys: "strip" }); } passthrough() { return new _ZodObject({ ...this._def, - unknownKeys: 'passthrough', + unknownKeys: "passthrough" }); } // const AugmentFactory = @@ -2812,8 +2678,8 @@ var ZodObject = class _ZodObject extends ZodType { ...this._def, shape: () => ({ ...this._def.shape(), - ...augmentation, - }), + ...augmentation + }) }); } /** @@ -2827,9 +2693,9 @@ var ZodObject = class _ZodObject extends ZodType { catchall: merging._def.catchall, shape: () => ({ ...this._def.shape(), - ...merging._def.shape(), + ...merging._def.shape() }), - typeName: ZodFirstPartyTypeKind.ZodObject, + typeName: ZodFirstPartyTypeKind.ZodObject }); return merged; } @@ -2895,7 +2761,7 @@ var ZodObject = class _ZodObject extends ZodType { catchall(index) { return new _ZodObject({ ...this._def, - catchall: index, + catchall: index }); } pick(mask) { @@ -2907,7 +2773,7 @@ var ZodObject = class _ZodObject extends ZodType { } return new _ZodObject({ ...this._def, - shape: () => shape, + shape: () => shape }); } omit(mask) { @@ -2919,7 +2785,7 @@ var ZodObject = class _ZodObject extends ZodType { } return new _ZodObject({ ...this._def, - shape: () => shape, + shape: () => shape }); } /** @@ -2940,7 +2806,7 @@ var ZodObject = class _ZodObject extends ZodType { } return new _ZodObject({ ...this._def, - shape: () => newShape, + shape: () => newShape }); } required(mask) { @@ -2959,7 +2825,7 @@ var ZodObject = class _ZodObject extends ZodType { } return new _ZodObject({ ...this._def, - shape: () => newShape, + shape: () => newShape }); } keyof() { @@ -2969,28 +2835,28 @@ var ZodObject = class _ZodObject extends ZodType { ZodObject.create = (shape, params) => { return new ZodObject({ shape: () => shape, - unknownKeys: 'strip', + unknownKeys: "strip", catchall: ZodNever.create(), typeName: ZodFirstPartyTypeKind.ZodObject, - ...processCreateParams(params), + ...processCreateParams(params) }); }; ZodObject.strictCreate = (shape, params) => { return new ZodObject({ shape: () => shape, - unknownKeys: 'strict', + unknownKeys: "strict", catchall: ZodNever.create(), typeName: ZodFirstPartyTypeKind.ZodObject, - ...processCreateParams(params), + ...processCreateParams(params) }); }; ZodObject.lazycreate = (shape, params) => { return new ZodObject({ shape, - unknownKeys: 'strip', + unknownKeys: "strip", catchall: ZodNever.create(), typeName: ZodFirstPartyTypeKind.ZodObject, - ...processCreateParams(params), + ...processCreateParams(params) }); }; var ZodUnion = class extends ZodType { @@ -2999,46 +2865,42 @@ var ZodUnion = class extends ZodType { const options = this._def.options; function handleResults(results) { for (const result of results) { - if (result.result.status === 'valid') { + if (result.result.status === "valid") { return result.result; } } for (const result of results) { - if (result.result.status === 'dirty') { + if (result.result.status === "dirty") { ctx.common.issues.push(...result.ctx.common.issues); return result.result; } } - const unionErrors = results.map( - (result) => new ZodError(result.ctx.common.issues) - ); + const unionErrors = results.map((result) => new ZodError(result.ctx.common.issues)); addIssueToContext(ctx, { code: ZodIssueCode.invalid_union, - unionErrors, + unionErrors }); return INVALID; } if (ctx.common.async) { - return Promise.all( - options.map(async (option) => { - const childCtx = { - ...ctx, - common: { - ...ctx.common, - issues: [], - }, - parent: null, - }; - return { - result: await option._parseAsync({ - data: ctx.data, - path: ctx.path, - parent: childCtx, - }), - ctx: childCtx, - }; - }) - ).then(handleResults); + return Promise.all(options.map(async (option) => { + const childCtx = { + ...ctx, + common: { + ...ctx.common, + issues: [] + }, + parent: null + }; + return { + result: await option._parseAsync({ + data: ctx.data, + path: ctx.path, + parent: childCtx + }), + ctx: childCtx + }; + })).then(handleResults); } else { let dirty = void 0; const issues = []; @@ -3047,18 +2909,18 @@ var ZodUnion = class extends ZodType { ...ctx, common: { ...ctx.common, - issues: [], + issues: [] }, - parent: null, + parent: null }; const result = option._parseSync({ data: ctx.data, path: ctx.path, - parent: childCtx, + parent: childCtx }); - if (result.status === 'valid') { + if (result.status === "valid") { return result; - } else if (result.status === 'dirty' && !dirty) { + } else if (result.status === "dirty" && !dirty) { dirty = { result, ctx: childCtx }; } if (childCtx.common.issues.length) { @@ -3072,7 +2934,7 @@ var ZodUnion = class extends ZodType { const unionErrors = issues.map((issues2) => new ZodError(issues2)); addIssueToContext(ctx, { code: ZodIssueCode.invalid_union, - unionErrors, + unionErrors }); return INVALID; } @@ -3085,7 +2947,7 @@ ZodUnion.create = (types, params) => { return new ZodUnion({ options: types, typeName: ZodFirstPartyTypeKind.ZodUnion, - ...processCreateParams(params), + ...processCreateParams(params) }); }; var getDiscriminator = (type) => { @@ -3126,7 +2988,7 @@ var ZodDiscriminatedUnion = class _ZodDiscriminatedUnion extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.object, - received: ctx.parsedType, + received: ctx.parsedType }); return INVALID; } @@ -3137,7 +2999,7 @@ var ZodDiscriminatedUnion = class _ZodDiscriminatedUnion extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_union_discriminator, options: Array.from(this.optionsMap.keys()), - path: [discriminator], + path: [discriminator] }); return INVALID; } @@ -3145,13 +3007,13 @@ var ZodDiscriminatedUnion = class _ZodDiscriminatedUnion extends ZodType { return option._parseAsync({ data: ctx.data, path: ctx.path, - parent: ctx, + parent: ctx }); } else { return option._parseSync({ data: ctx.data, path: ctx.path, - parent: ctx, + parent: ctx }); } } @@ -3177,17 +3039,11 @@ var ZodDiscriminatedUnion = class _ZodDiscriminatedUnion extends ZodType { for (const type of options) { const discriminatorValues = getDiscriminator(type.shape[discriminator]); if (!discriminatorValues.length) { - throw new Error( - `A discriminator value for key \`${discriminator}\` could not be extracted from all schema options` - ); + throw new Error(`A discriminator value for key \`${discriminator}\` could not be extracted from all schema options`); } for (const value of discriminatorValues) { if (optionsMap.has(value)) { - throw new Error( - `Discriminator property ${String( - discriminator - )} has duplicate value ${String(value)}` - ); + throw new Error(`Discriminator property ${String(discriminator)} has duplicate value ${String(value)}`); } optionsMap.set(value, type); } @@ -3197,7 +3053,7 @@ var ZodDiscriminatedUnion = class _ZodDiscriminatedUnion extends ZodType { discriminator, options, optionsMap, - ...processCreateParams(params), + ...processCreateParams(params) }); } }; @@ -3208,9 +3064,7 @@ function mergeValues(a, b) { return { valid: true, data: a }; } else if (aType === ZodParsedType.object && bType === ZodParsedType.object) { const bKeys = util.objectKeys(b); - const sharedKeys = util - .objectKeys(a) - .filter((key) => bKeys.indexOf(key) !== -1); + const sharedKeys = util.objectKeys(a).filter((key) => bKeys.indexOf(key) !== -1); const newObj = { ...a, ...b }; for (const key of sharedKeys) { const sharedValue = mergeValues(a[key], b[key]); @@ -3235,11 +3089,7 @@ function mergeValues(a, b) { newArray.push(sharedValue.data); } return { valid: true, data: newArray }; - } else if ( - aType === ZodParsedType.date && - bType === ZodParsedType.date && - +a === +b - ) { + } else if (aType === ZodParsedType.date && bType === ZodParsedType.date && +a === +b) { return { valid: true, data: a }; } else { return { valid: false }; @@ -3255,7 +3105,7 @@ var ZodIntersection = class extends ZodType { const merged = mergeValues(parsedLeft.value, parsedRight.value); if (!merged.valid) { addIssueToContext(ctx, { - code: ZodIssueCode.invalid_intersection_types, + code: ZodIssueCode.invalid_intersection_types }); return INVALID; } @@ -3269,27 +3119,24 @@ var ZodIntersection = class extends ZodType { this._def.left._parseAsync({ data: ctx.data, path: ctx.path, - parent: ctx, + parent: ctx }), this._def.right._parseAsync({ data: ctx.data, path: ctx.path, - parent: ctx, - }), + parent: ctx + }) ]).then(([left, right]) => handleParsed(left, right)); } else { - return handleParsed( - this._def.left._parseSync({ - data: ctx.data, - path: ctx.path, - parent: ctx, - }), - this._def.right._parseSync({ - data: ctx.data, - path: ctx.path, - parent: ctx, - }) - ); + return handleParsed(this._def.left._parseSync({ + data: ctx.data, + path: ctx.path, + parent: ctx + }), this._def.right._parseSync({ + data: ctx.data, + path: ctx.path, + parent: ctx + })); } } }; @@ -3298,7 +3145,7 @@ ZodIntersection.create = (left, right, params) => { left, right, typeName: ZodFirstPartyTypeKind.ZodIntersection, - ...processCreateParams(params), + ...processCreateParams(params) }); }; var ZodTuple = class _ZodTuple extends ZodType { @@ -3308,7 +3155,7 @@ var ZodTuple = class _ZodTuple extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.array, - received: ctx.parsedType, + received: ctx.parsedType }); return INVALID; } @@ -3318,7 +3165,7 @@ var ZodTuple = class _ZodTuple extends ZodType { minimum: this._def.items.length, inclusive: true, exact: false, - type: 'array', + type: "array" }); return INVALID; } @@ -3329,19 +3176,16 @@ var ZodTuple = class _ZodTuple extends ZodType { maximum: this._def.items.length, inclusive: true, exact: false, - type: 'array', + type: "array" }); status.dirty(); } - const items = [...ctx.data] - .map((item, itemIndex) => { - const schema = this._def.items[itemIndex] || this._def.rest; - if (!schema) return null; - return schema._parse( - new ParseInputLazyPath(ctx, item, ctx.path, itemIndex) - ); - }) - .filter((x2) => !!x2); + const items = [...ctx.data].map((item, itemIndex) => { + const schema = this._def.items[itemIndex] || this._def.rest; + if (!schema) + return null; + return schema._parse(new ParseInputLazyPath(ctx, item, ctx.path, itemIndex)); + }).filter((x2) => !!x2); if (ctx.common.async) { return Promise.all(items).then((results) => { return ParseStatus.mergeArray(status, results); @@ -3356,19 +3200,19 @@ var ZodTuple = class _ZodTuple extends ZodType { rest(rest) { return new _ZodTuple({ ...this._def, - rest, + rest }); } }; ZodTuple.create = (schemas, params) => { if (!Array.isArray(schemas)) { - throw new Error('You must pass an array of schemas to z.tuple([ ... ])'); + throw new Error("You must pass an array of schemas to z.tuple([ ... ])"); } return new ZodTuple({ items: schemas, typeName: ZodFirstPartyTypeKind.ZodTuple, rest: null, - ...processCreateParams(params), + ...processCreateParams(params) }); }; var ZodRecord = class _ZodRecord extends ZodType { @@ -3384,7 +3228,7 @@ var ZodRecord = class _ZodRecord extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.object, - received: ctx.parsedType, + received: ctx.parsedType }); return INVALID; } @@ -3394,10 +3238,8 @@ var ZodRecord = class _ZodRecord extends ZodType { for (const key in ctx.data) { pairs.push({ key: keyType._parse(new ParseInputLazyPath(ctx, key, ctx.path, key)), - value: valueType._parse( - new ParseInputLazyPath(ctx, ctx.data[key], ctx.path, key) - ), - alwaysSet: key in ctx.data, + value: valueType._parse(new ParseInputLazyPath(ctx, ctx.data[key], ctx.path, key)), + alwaysSet: key in ctx.data }); } if (ctx.common.async) { @@ -3415,14 +3257,14 @@ var ZodRecord = class _ZodRecord extends ZodType { keyType: first, valueType: second, typeName: ZodFirstPartyTypeKind.ZodRecord, - ...processCreateParams(third), + ...processCreateParams(third) }); } return new _ZodRecord({ keyType: ZodString.create(), valueType: first, typeName: ZodFirstPartyTypeKind.ZodRecord, - ...processCreateParams(second), + ...processCreateParams(second) }); } }; @@ -3439,7 +3281,7 @@ var ZodMap = class extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.map, - received: ctx.parsedType, + received: ctx.parsedType }); return INVALID; } @@ -3447,12 +3289,8 @@ var ZodMap = class extends ZodType { const valueType = this._def.valueType; const pairs = [...ctx.data.entries()].map(([key, value], index) => { return { - key: keyType._parse( - new ParseInputLazyPath(ctx, key, ctx.path, [index, 'key']) - ), - value: valueType._parse( - new ParseInputLazyPath(ctx, value, ctx.path, [index, 'value']) - ), + key: keyType._parse(new ParseInputLazyPath(ctx, key, ctx.path, [index, "key"])), + value: valueType._parse(new ParseInputLazyPath(ctx, value, ctx.path, [index, "value"])) }; }); if (ctx.common.async) { @@ -3461,10 +3299,10 @@ var ZodMap = class extends ZodType { for (const pair of pairs) { const key = await pair.key; const value = await pair.value; - if (key.status === 'aborted' || value.status === 'aborted') { + if (key.status === "aborted" || value.status === "aborted") { return INVALID; } - if (key.status === 'dirty' || value.status === 'dirty') { + if (key.status === "dirty" || value.status === "dirty") { status.dirty(); } finalMap.set(key.value, value.value); @@ -3476,10 +3314,10 @@ var ZodMap = class extends ZodType { for (const pair of pairs) { const key = pair.key; const value = pair.value; - if (key.status === 'aborted' || value.status === 'aborted') { + if (key.status === "aborted" || value.status === "aborted") { return INVALID; } - if (key.status === 'dirty' || value.status === 'dirty') { + if (key.status === "dirty" || value.status === "dirty") { status.dirty(); } finalMap.set(key.value, value.value); @@ -3493,7 +3331,7 @@ ZodMap.create = (keyType, valueType, params) => { valueType, keyType, typeName: ZodFirstPartyTypeKind.ZodMap, - ...processCreateParams(params), + ...processCreateParams(params) }); }; var ZodSet = class _ZodSet extends ZodType { @@ -3503,7 +3341,7 @@ var ZodSet = class _ZodSet extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.set, - received: ctx.parsedType, + received: ctx.parsedType }); return INVALID; } @@ -3513,10 +3351,10 @@ var ZodSet = class _ZodSet extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.too_small, minimum: def.minSize.value, - type: 'set', + type: "set", inclusive: true, exact: false, - message: def.minSize.message, + message: def.minSize.message }); status.dirty(); } @@ -3526,10 +3364,10 @@ var ZodSet = class _ZodSet extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.too_big, maximum: def.maxSize.value, - type: 'set', + type: "set", inclusive: true, exact: false, - message: def.maxSize.message, + message: def.maxSize.message }); status.dirty(); } @@ -3538,15 +3376,15 @@ var ZodSet = class _ZodSet extends ZodType { function finalizeSet(elements2) { const parsedSet = /* @__PURE__ */ new Set(); for (const element of elements2) { - if (element.status === 'aborted') return INVALID; - if (element.status === 'dirty') status.dirty(); + if (element.status === "aborted") + return INVALID; + if (element.status === "dirty") + status.dirty(); parsedSet.add(element.value); } return { status: status.value, value: parsedSet }; } - const elements = [...ctx.data.values()].map((item, i) => - valueType._parse(new ParseInputLazyPath(ctx, item, ctx.path, i)) - ); + const elements = [...ctx.data.values()].map((item, i) => valueType._parse(new ParseInputLazyPath(ctx, item, ctx.path, i))); if (ctx.common.async) { return Promise.all(elements).then((elements2) => finalizeSet(elements2)); } else { @@ -3556,13 +3394,13 @@ var ZodSet = class _ZodSet extends ZodType { min(minSize, message) { return new _ZodSet({ ...this._def, - minSize: { value: minSize, message: errorUtil.toString(message) }, + minSize: { value: minSize, message: errorUtil.toString(message) } }); } max(maxSize, message) { return new _ZodSet({ ...this._def, - maxSize: { value: maxSize, message: errorUtil.toString(message) }, + maxSize: { value: maxSize, message: errorUtil.toString(message) } }); } size(size, message) { @@ -3578,7 +3416,7 @@ ZodSet.create = (valueType, params) => { minSize: null, maxSize: null, typeName: ZodFirstPartyTypeKind.ZodSet, - ...processCreateParams(params), + ...processCreateParams(params) }); }; var ZodFunction = class _ZodFunction extends ZodType { @@ -3592,7 +3430,7 @@ var ZodFunction = class _ZodFunction extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.function, - received: ctx.parsedType, + received: ctx.parsedType }); return INVALID; } @@ -3600,58 +3438,44 @@ var ZodFunction = class _ZodFunction extends ZodType { return makeIssue({ data: args, path: ctx.path, - errorMaps: [ - ctx.common.contextualErrorMap, - ctx.schemaErrorMap, - getErrorMap(), - en_default, - ].filter((x2) => !!x2), + errorMaps: [ctx.common.contextualErrorMap, ctx.schemaErrorMap, getErrorMap(), en_default].filter((x2) => !!x2), issueData: { code: ZodIssueCode.invalid_arguments, - argumentsError: error, - }, + argumentsError: error + } }); } function makeReturnsIssue(returns, error) { return makeIssue({ data: returns, path: ctx.path, - errorMaps: [ - ctx.common.contextualErrorMap, - ctx.schemaErrorMap, - getErrorMap(), - en_default, - ].filter((x2) => !!x2), + errorMaps: [ctx.common.contextualErrorMap, ctx.schemaErrorMap, getErrorMap(), en_default].filter((x2) => !!x2), issueData: { code: ZodIssueCode.invalid_return_type, - returnTypeError: error, - }, + returnTypeError: error + } }); } const params = { errorMap: ctx.common.contextualErrorMap }; const fn = ctx.data; if (this._def.returns instanceof ZodPromise) { const me2 = this; - return OK(async function (...args) { + return OK(async function(...args) { const error = new ZodError([]); - const parsedArgs = await me2._def.args - .parseAsync(args, params) - .catch((e) => { - error.addIssue(makeArgsIssue(args, e)); - throw error; - }); + const parsedArgs = await me2._def.args.parseAsync(args, params).catch((e) => { + error.addIssue(makeArgsIssue(args, e)); + throw error; + }); const result = await Reflect.apply(fn, this, parsedArgs); - const parsedReturns = await me2._def.returns._def.type - .parseAsync(result, params) - .catch((e) => { - error.addIssue(makeReturnsIssue(result, e)); - throw error; - }); + const parsedReturns = await me2._def.returns._def.type.parseAsync(result, params).catch((e) => { + error.addIssue(makeReturnsIssue(result, e)); + throw error; + }); return parsedReturns; }); } else { const me2 = this; - return OK(function (...args) { + return OK(function(...args) { const parsedArgs = me2._def.args.safeParse(args, params); if (!parsedArgs.success) { throw new ZodError([makeArgsIssue(args, parsedArgs.error)]); @@ -3674,13 +3498,13 @@ var ZodFunction = class _ZodFunction extends ZodType { args(...items) { return new _ZodFunction({ ...this._def, - args: ZodTuple.create(items).rest(ZodUnknown.create()), + args: ZodTuple.create(items).rest(ZodUnknown.create()) }); } returns(returnType) { return new _ZodFunction({ ...this._def, - returns: returnType, + returns: returnType }); } implement(func) { @@ -3696,7 +3520,7 @@ var ZodFunction = class _ZodFunction extends ZodType { args: args ? args : ZodTuple.create([]).rest(ZodUnknown.create()), returns: returns || ZodUnknown.create(), typeName: ZodFirstPartyTypeKind.ZodFunction, - ...processCreateParams(params), + ...processCreateParams(params) }); } }; @@ -3714,7 +3538,7 @@ ZodLazy.create = (getter, params) => { return new ZodLazy({ getter, typeName: ZodFirstPartyTypeKind.ZodLazy, - ...processCreateParams(params), + ...processCreateParams(params) }); }; var ZodLiteral = class extends ZodType { @@ -3724,11 +3548,11 @@ var ZodLiteral = class extends ZodType { addIssueToContext(ctx, { received: ctx.data, code: ZodIssueCode.invalid_literal, - expected: this._def.value, + expected: this._def.value }); return INVALID; } - return { status: 'valid', value: input.data }; + return { status: "valid", value: input.data }; } get value() { return this._def.value; @@ -3738,25 +3562,25 @@ ZodLiteral.create = (value, params) => { return new ZodLiteral({ value, typeName: ZodFirstPartyTypeKind.ZodLiteral, - ...processCreateParams(params), + ...processCreateParams(params) }); }; function createZodEnum(values, params) { return new ZodEnum({ values, typeName: ZodFirstPartyTypeKind.ZodEnum, - ...processCreateParams(params), + ...processCreateParams(params) }); } var ZodEnum = class _ZodEnum extends ZodType { _parse(input) { - if (typeof input.data !== 'string') { + if (typeof input.data !== "string") { const ctx = this._getOrReturnCtx(input); const expectedValues = this._def.values; addIssueToContext(ctx, { expected: util.joinValues(expectedValues), received: ctx.parsedType, - code: ZodIssueCode.invalid_type, + code: ZodIssueCode.invalid_type }); return INVALID; } @@ -3769,7 +3593,7 @@ var ZodEnum = class _ZodEnum extends ZodType { addIssueToContext(ctx, { received: ctx.data, code: ZodIssueCode.invalid_enum_value, - options: expectedValues, + options: expectedValues }); return INVALID; } @@ -3802,17 +3626,14 @@ var ZodEnum = class _ZodEnum extends ZodType { extract(values, newDef = this._def) { return _ZodEnum.create(values, { ...this._def, - ...newDef, + ...newDef }); } exclude(values, newDef = this._def) { - return _ZodEnum.create( - this.options.filter((opt) => !values.includes(opt)), - { - ...this._def, - ...newDef, - } - ); + return _ZodEnum.create(this.options.filter((opt) => !values.includes(opt)), { + ...this._def, + ...newDef + }); } }; ZodEnum.create = createZodEnum; @@ -3820,15 +3641,12 @@ var ZodNativeEnum = class extends ZodType { _parse(input) { const nativeEnumValues = util.getValidEnumValues(this._def.values); const ctx = this._getOrReturnCtx(input); - if ( - ctx.parsedType !== ZodParsedType.string && - ctx.parsedType !== ZodParsedType.number - ) { + if (ctx.parsedType !== ZodParsedType.string && ctx.parsedType !== ZodParsedType.number) { const expectedValues = util.objectValues(nativeEnumValues); addIssueToContext(ctx, { expected: util.joinValues(expectedValues), received: ctx.parsedType, - code: ZodIssueCode.invalid_type, + code: ZodIssueCode.invalid_type }); return INVALID; } @@ -3840,7 +3658,7 @@ var ZodNativeEnum = class extends ZodType { addIssueToContext(ctx, { received: ctx.data, code: ZodIssueCode.invalid_enum_value, - options: expectedValues, + options: expectedValues }); return INVALID; } @@ -3854,7 +3672,7 @@ ZodNativeEnum.create = (values, params) => { return new ZodNativeEnum({ values, typeName: ZodFirstPartyTypeKind.ZodNativeEnum, - ...processCreateParams(params), + ...processCreateParams(params) }); }; var ZodPromise = class extends ZodType { @@ -3863,36 +3681,28 @@ var ZodPromise = class extends ZodType { } _parse(input) { const { ctx } = this._processInputParams(input); - if ( - ctx.parsedType !== ZodParsedType.promise && - ctx.common.async === false - ) { + if (ctx.parsedType !== ZodParsedType.promise && ctx.common.async === false) { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.promise, - received: ctx.parsedType, + received: ctx.parsedType }); return INVALID; } - const promisified = - ctx.parsedType === ZodParsedType.promise - ? ctx.data - : Promise.resolve(ctx.data); - return OK( - promisified.then((data) => { - return this._def.type.parseAsync(data, { - path: ctx.path, - errorMap: ctx.common.contextualErrorMap, - }); - }) - ); + const promisified = ctx.parsedType === ZodParsedType.promise ? ctx.data : Promise.resolve(ctx.data); + return OK(promisified.then((data) => { + return this._def.type.parseAsync(data, { + path: ctx.path, + errorMap: ctx.common.contextualErrorMap + }); + })); } }; ZodPromise.create = (schema, params) => { return new ZodPromise({ type: schema, typeName: ZodFirstPartyTypeKind.ZodPromise, - ...processCreateParams(params), + ...processCreateParams(params) }); }; var ZodEffects = class extends ZodType { @@ -3900,9 +3710,7 @@ var ZodEffects = class extends ZodType { return this._def.schema; } sourceType() { - return this._def.schema._def.typeName === ZodFirstPartyTypeKind.ZodEffects - ? this._def.schema.sourceType() - : this._def.schema; + return this._def.schema._def.typeName === ZodFirstPartyTypeKind.ZodEffects ? this._def.schema.sourceType() : this._def.schema; } _parse(input) { const { status, ctx } = this._processInputParams(input); @@ -3918,47 +3726,53 @@ var ZodEffects = class extends ZodType { }, get path() { return ctx.path; - }, + } }; checkCtx.addIssue = checkCtx.addIssue.bind(checkCtx); - if (effect.type === 'preprocess') { + if (effect.type === "preprocess") { const processed = effect.transform(ctx.data, checkCtx); if (ctx.common.async) { return Promise.resolve(processed).then(async (processed2) => { - if (status.value === 'aborted') return INVALID; + if (status.value === "aborted") + return INVALID; const result = await this._def.schema._parseAsync({ data: processed2, path: ctx.path, - parent: ctx, + parent: ctx }); - if (result.status === 'aborted') return INVALID; - if (result.status === 'dirty') return DIRTY(result.value); - if (status.value === 'dirty') return DIRTY(result.value); + if (result.status === "aborted") + return INVALID; + if (result.status === "dirty") + return DIRTY(result.value); + if (status.value === "dirty") + return DIRTY(result.value); return result; }); } else { - if (status.value === 'aborted') return INVALID; + if (status.value === "aborted") + return INVALID; const result = this._def.schema._parseSync({ data: processed, path: ctx.path, - parent: ctx, + parent: ctx }); - if (result.status === 'aborted') return INVALID; - if (result.status === 'dirty') return DIRTY(result.value); - if (status.value === 'dirty') return DIRTY(result.value); + if (result.status === "aborted") + return INVALID; + if (result.status === "dirty") + return DIRTY(result.value); + if (status.value === "dirty") + return DIRTY(result.value); return result; } } - if (effect.type === 'refinement') { + if (effect.type === "refinement") { const executeRefinement = (acc) => { const result = effect.refinement(acc, checkCtx); if (ctx.common.async) { return Promise.resolve(result); } if (result instanceof Promise) { - throw new Error( - 'Async refinement encountered during synchronous parse operation. Use .parseAsync instead.' - ); + throw new Error("Async refinement encountered during synchronous parse operation. Use .parseAsync instead."); } return acc; }; @@ -3966,51 +3780,49 @@ var ZodEffects = class extends ZodType { const inner = this._def.schema._parseSync({ data: ctx.data, path: ctx.path, - parent: ctx, + parent: ctx }); - if (inner.status === 'aborted') return INVALID; - if (inner.status === 'dirty') status.dirty(); + if (inner.status === "aborted") + return INVALID; + if (inner.status === "dirty") + status.dirty(); executeRefinement(inner.value); return { status: status.value, value: inner.value }; } else { - return this._def.schema - ._parseAsync({ data: ctx.data, path: ctx.path, parent: ctx }) - .then((inner) => { - if (inner.status === 'aborted') return INVALID; - if (inner.status === 'dirty') status.dirty(); - return executeRefinement(inner.value).then(() => { - return { status: status.value, value: inner.value }; - }); + return this._def.schema._parseAsync({ data: ctx.data, path: ctx.path, parent: ctx }).then((inner) => { + if (inner.status === "aborted") + return INVALID; + if (inner.status === "dirty") + status.dirty(); + return executeRefinement(inner.value).then(() => { + return { status: status.value, value: inner.value }; }); + }); } } - if (effect.type === 'transform') { + if (effect.type === "transform") { if (ctx.common.async === false) { const base = this._def.schema._parseSync({ data: ctx.data, path: ctx.path, - parent: ctx, + parent: ctx }); - if (!isValid(base)) return INVALID; + if (!isValid(base)) + return INVALID; const result = effect.transform(base.value, checkCtx); if (result instanceof Promise) { - throw new Error( - `Asynchronous transform encountered during synchronous parse operation. Use .parseAsync instead.` - ); + throw new Error(`Asynchronous transform encountered during synchronous parse operation. Use .parseAsync instead.`); } return { status: status.value, value: result }; } else { - return this._def.schema - ._parseAsync({ data: ctx.data, path: ctx.path, parent: ctx }) - .then((base) => { - if (!isValid(base)) return INVALID; - return Promise.resolve(effect.transform(base.value, checkCtx)).then( - (result) => ({ - status: status.value, - value: result, - }) - ); - }); + return this._def.schema._parseAsync({ data: ctx.data, path: ctx.path, parent: ctx }).then((base) => { + if (!isValid(base)) + return INVALID; + return Promise.resolve(effect.transform(base.value, checkCtx)).then((result) => ({ + status: status.value, + value: result + })); + }); } } util.assertNever(effect); @@ -4021,15 +3833,15 @@ ZodEffects.create = (schema, effect, params) => { schema, typeName: ZodFirstPartyTypeKind.ZodEffects, effect, - ...processCreateParams(params), + ...processCreateParams(params) }); }; ZodEffects.createWithPreprocess = (preprocess, schema, params) => { return new ZodEffects({ schema, - effect: { type: 'preprocess', transform: preprocess }, + effect: { type: "preprocess", transform: preprocess }, typeName: ZodFirstPartyTypeKind.ZodEffects, - ...processCreateParams(params), + ...processCreateParams(params) }); }; var ZodOptional = class extends ZodType { @@ -4048,7 +3860,7 @@ ZodOptional.create = (type, params) => { return new ZodOptional({ innerType: type, typeName: ZodFirstPartyTypeKind.ZodOptional, - ...processCreateParams(params), + ...processCreateParams(params) }); }; var ZodNullable = class extends ZodType { @@ -4067,7 +3879,7 @@ ZodNullable.create = (type, params) => { return new ZodNullable({ innerType: type, typeName: ZodFirstPartyTypeKind.ZodNullable, - ...processCreateParams(params), + ...processCreateParams(params) }); }; var ZodDefault = class extends ZodType { @@ -4080,7 +3892,7 @@ var ZodDefault = class extends ZodType { return this._def.innerType._parse({ data, path: ctx.path, - parent: ctx, + parent: ctx }); } removeDefault() { @@ -4091,11 +3903,8 @@ ZodDefault.create = (type, params) => { return new ZodDefault({ innerType: type, typeName: ZodFirstPartyTypeKind.ZodDefault, - defaultValue: - typeof params.default === 'function' - ? params.default - : () => params.default, - ...processCreateParams(params), + defaultValue: typeof params.default === "function" ? params.default : () => params.default, + ...processCreateParams(params) }); }; var ZodCatch = class extends ZodType { @@ -4105,43 +3914,37 @@ var ZodCatch = class extends ZodType { ...ctx, common: { ...ctx.common, - issues: [], - }, + issues: [] + } }; const result = this._def.innerType._parse({ data: newCtx.data, path: newCtx.path, parent: { - ...newCtx, - }, + ...newCtx + } }); if (isAsync(result)) { return result.then((result2) => { return { - status: 'valid', - value: - result2.status === 'valid' - ? result2.value - : this._def.catchValue({ - get error() { - return new ZodError(newCtx.common.issues); - }, - input: newCtx.data, - }), + status: "valid", + value: result2.status === "valid" ? result2.value : this._def.catchValue({ + get error() { + return new ZodError(newCtx.common.issues); + }, + input: newCtx.data + }) }; }); } else { return { - status: 'valid', - value: - result.status === 'valid' - ? result.value - : this._def.catchValue({ - get error() { - return new ZodError(newCtx.common.issues); - }, - input: newCtx.data, - }), + status: "valid", + value: result.status === "valid" ? result.value : this._def.catchValue({ + get error() { + return new ZodError(newCtx.common.issues); + }, + input: newCtx.data + }) }; } } @@ -4153,9 +3956,8 @@ ZodCatch.create = (type, params) => { return new ZodCatch({ innerType: type, typeName: ZodFirstPartyTypeKind.ZodCatch, - catchValue: - typeof params.catch === 'function' ? params.catch : () => params.catch, - ...processCreateParams(params), + catchValue: typeof params.catch === "function" ? params.catch : () => params.catch, + ...processCreateParams(params) }); }; var ZodNaN = class extends ZodType { @@ -4166,20 +3968,20 @@ var ZodNaN = class extends ZodType { addIssueToContext(ctx, { code: ZodIssueCode.invalid_type, expected: ZodParsedType.nan, - received: ctx.parsedType, + received: ctx.parsedType }); return INVALID; } - return { status: 'valid', value: input.data }; + return { status: "valid", value: input.data }; } }; ZodNaN.create = (params) => { return new ZodNaN({ typeName: ZodFirstPartyTypeKind.ZodNaN, - ...processCreateParams(params), + ...processCreateParams(params) }); }; -var BRAND = /* @__PURE__ */ Symbol('zod_brand'); +var BRAND = /* @__PURE__ */ Symbol("zod_brand"); var ZodBranded = class extends ZodType { _parse(input) { const { ctx } = this._processInputParams(input); @@ -4187,7 +3989,7 @@ var ZodBranded = class extends ZodType { return this._def.type._parse({ data, path: ctx.path, - parent: ctx, + parent: ctx }); } unwrap() { @@ -4202,17 +4004,18 @@ var ZodPipeline = class _ZodPipeline extends ZodType { const inResult = await this._def.in._parseAsync({ data: ctx.data, path: ctx.path, - parent: ctx, + parent: ctx }); - if (inResult.status === 'aborted') return INVALID; - if (inResult.status === 'dirty') { + if (inResult.status === "aborted") + return INVALID; + if (inResult.status === "dirty") { status.dirty(); return DIRTY(inResult.value); } else { return this._def.out._parseAsync({ data: inResult.value, path: ctx.path, - parent: ctx, + parent: ctx }); } }; @@ -4221,20 +4024,21 @@ var ZodPipeline = class _ZodPipeline extends ZodType { const inResult = this._def.in._parseSync({ data: ctx.data, path: ctx.path, - parent: ctx, + parent: ctx }); - if (inResult.status === 'aborted') return INVALID; - if (inResult.status === 'dirty') { + if (inResult.status === "aborted") + return INVALID; + if (inResult.status === "dirty") { status.dirty(); return { - status: 'dirty', - value: inResult.value, + status: "dirty", + value: inResult.value }; } else { return this._def.out._parseSync({ data: inResult.value, path: ctx.path, - parent: ctx, + parent: ctx }); } } @@ -4243,7 +4047,7 @@ var ZodPipeline = class _ZodPipeline extends ZodType { return new _ZodPipeline({ in: a, out: b, - typeName: ZodFirstPartyTypeKind.ZodPipeline, + typeName: ZodFirstPartyTypeKind.ZodPipeline }); } }; @@ -4256,9 +4060,7 @@ var ZodReadonly = class extends ZodType { } return data; }; - return isAsync(result) - ? result.then((data) => freeze(data)) - : freeze(result); + return isAsync(result) ? result.then((data) => freeze(data)) : freeze(result); } unwrap() { return this._def.innerType; @@ -4268,17 +4070,12 @@ ZodReadonly.create = (type, params) => { return new ZodReadonly({ innerType: type, typeName: ZodFirstPartyTypeKind.ZodReadonly, - ...processCreateParams(params), + ...processCreateParams(params) }); }; function cleanParams(params, data) { - const p = - typeof params === 'function' - ? params(data) - : typeof params === 'string' - ? { message: params } - : params; - const p2 = typeof p === 'string' ? { message: p } : p; + const p = typeof params === "function" ? params(data) : typeof params === "string" ? { message: params } : params; + const p2 = typeof p === "string" ? { message: p } : p; return p2; } function custom(check, _params = {}, fatal) { @@ -4290,67 +4087,64 @@ function custom(check, _params = {}, fatal) { if (!r2) { const params = cleanParams(_params, data); const _fatal = params.fatal ?? fatal ?? true; - ctx.addIssue({ code: 'custom', ...params, fatal: _fatal }); + ctx.addIssue({ code: "custom", ...params, fatal: _fatal }); } }); } if (!r) { const params = cleanParams(_params, data); const _fatal = params.fatal ?? fatal ?? true; - ctx.addIssue({ code: 'custom', ...params, fatal: _fatal }); + ctx.addIssue({ code: "custom", ...params, fatal: _fatal }); } return; }); return ZodAny.create(); } var late = { - object: ZodObject.lazycreate, + object: ZodObject.lazycreate }; var ZodFirstPartyTypeKind; -(function (ZodFirstPartyTypeKind2) { - ZodFirstPartyTypeKind2['ZodString'] = 'ZodString'; - ZodFirstPartyTypeKind2['ZodNumber'] = 'ZodNumber'; - ZodFirstPartyTypeKind2['ZodNaN'] = 'ZodNaN'; - ZodFirstPartyTypeKind2['ZodBigInt'] = 'ZodBigInt'; - ZodFirstPartyTypeKind2['ZodBoolean'] = 'ZodBoolean'; - ZodFirstPartyTypeKind2['ZodDate'] = 'ZodDate'; - ZodFirstPartyTypeKind2['ZodSymbol'] = 'ZodSymbol'; - ZodFirstPartyTypeKind2['ZodUndefined'] = 'ZodUndefined'; - ZodFirstPartyTypeKind2['ZodNull'] = 'ZodNull'; - ZodFirstPartyTypeKind2['ZodAny'] = 'ZodAny'; - ZodFirstPartyTypeKind2['ZodUnknown'] = 'ZodUnknown'; - ZodFirstPartyTypeKind2['ZodNever'] = 'ZodNever'; - ZodFirstPartyTypeKind2['ZodVoid'] = 'ZodVoid'; - ZodFirstPartyTypeKind2['ZodArray'] = 'ZodArray'; - ZodFirstPartyTypeKind2['ZodObject'] = 'ZodObject'; - ZodFirstPartyTypeKind2['ZodUnion'] = 'ZodUnion'; - ZodFirstPartyTypeKind2['ZodDiscriminatedUnion'] = 'ZodDiscriminatedUnion'; - ZodFirstPartyTypeKind2['ZodIntersection'] = 'ZodIntersection'; - ZodFirstPartyTypeKind2['ZodTuple'] = 'ZodTuple'; - ZodFirstPartyTypeKind2['ZodRecord'] = 'ZodRecord'; - ZodFirstPartyTypeKind2['ZodMap'] = 'ZodMap'; - ZodFirstPartyTypeKind2['ZodSet'] = 'ZodSet'; - ZodFirstPartyTypeKind2['ZodFunction'] = 'ZodFunction'; - ZodFirstPartyTypeKind2['ZodLazy'] = 'ZodLazy'; - ZodFirstPartyTypeKind2['ZodLiteral'] = 'ZodLiteral'; - ZodFirstPartyTypeKind2['ZodEnum'] = 'ZodEnum'; - ZodFirstPartyTypeKind2['ZodEffects'] = 'ZodEffects'; - ZodFirstPartyTypeKind2['ZodNativeEnum'] = 'ZodNativeEnum'; - ZodFirstPartyTypeKind2['ZodOptional'] = 'ZodOptional'; - ZodFirstPartyTypeKind2['ZodNullable'] = 'ZodNullable'; - ZodFirstPartyTypeKind2['ZodDefault'] = 'ZodDefault'; - ZodFirstPartyTypeKind2['ZodCatch'] = 'ZodCatch'; - ZodFirstPartyTypeKind2['ZodPromise'] = 'ZodPromise'; - ZodFirstPartyTypeKind2['ZodBranded'] = 'ZodBranded'; - ZodFirstPartyTypeKind2['ZodPipeline'] = 'ZodPipeline'; - ZodFirstPartyTypeKind2['ZodReadonly'] = 'ZodReadonly'; +(function(ZodFirstPartyTypeKind2) { + ZodFirstPartyTypeKind2["ZodString"] = "ZodString"; + ZodFirstPartyTypeKind2["ZodNumber"] = "ZodNumber"; + ZodFirstPartyTypeKind2["ZodNaN"] = "ZodNaN"; + ZodFirstPartyTypeKind2["ZodBigInt"] = "ZodBigInt"; + ZodFirstPartyTypeKind2["ZodBoolean"] = "ZodBoolean"; + ZodFirstPartyTypeKind2["ZodDate"] = "ZodDate"; + ZodFirstPartyTypeKind2["ZodSymbol"] = "ZodSymbol"; + ZodFirstPartyTypeKind2["ZodUndefined"] = "ZodUndefined"; + ZodFirstPartyTypeKind2["ZodNull"] = "ZodNull"; + ZodFirstPartyTypeKind2["ZodAny"] = "ZodAny"; + ZodFirstPartyTypeKind2["ZodUnknown"] = "ZodUnknown"; + ZodFirstPartyTypeKind2["ZodNever"] = "ZodNever"; + ZodFirstPartyTypeKind2["ZodVoid"] = "ZodVoid"; + ZodFirstPartyTypeKind2["ZodArray"] = "ZodArray"; + ZodFirstPartyTypeKind2["ZodObject"] = "ZodObject"; + ZodFirstPartyTypeKind2["ZodUnion"] = "ZodUnion"; + ZodFirstPartyTypeKind2["ZodDiscriminatedUnion"] = "ZodDiscriminatedUnion"; + ZodFirstPartyTypeKind2["ZodIntersection"] = "ZodIntersection"; + ZodFirstPartyTypeKind2["ZodTuple"] = "ZodTuple"; + ZodFirstPartyTypeKind2["ZodRecord"] = "ZodRecord"; + ZodFirstPartyTypeKind2["ZodMap"] = "ZodMap"; + ZodFirstPartyTypeKind2["ZodSet"] = "ZodSet"; + ZodFirstPartyTypeKind2["ZodFunction"] = "ZodFunction"; + ZodFirstPartyTypeKind2["ZodLazy"] = "ZodLazy"; + ZodFirstPartyTypeKind2["ZodLiteral"] = "ZodLiteral"; + ZodFirstPartyTypeKind2["ZodEnum"] = "ZodEnum"; + ZodFirstPartyTypeKind2["ZodEffects"] = "ZodEffects"; + ZodFirstPartyTypeKind2["ZodNativeEnum"] = "ZodNativeEnum"; + ZodFirstPartyTypeKind2["ZodOptional"] = "ZodOptional"; + ZodFirstPartyTypeKind2["ZodNullable"] = "ZodNullable"; + ZodFirstPartyTypeKind2["ZodDefault"] = "ZodDefault"; + ZodFirstPartyTypeKind2["ZodCatch"] = "ZodCatch"; + ZodFirstPartyTypeKind2["ZodPromise"] = "ZodPromise"; + ZodFirstPartyTypeKind2["ZodBranded"] = "ZodBranded"; + ZodFirstPartyTypeKind2["ZodPipeline"] = "ZodPipeline"; + ZodFirstPartyTypeKind2["ZodReadonly"] = "ZodReadonly"; })(ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {})); -var instanceOfType = ( - cls, - params = { - message: `Input not instance of ${cls.name}`, - } -) => custom((data) => data instanceof cls, params); +var instanceOfType = (cls, params = { + message: `Input not instance of ${cls.name}` +}) => custom((data) => data instanceof cls, params); var stringType = ZodString.create; var numberType = ZodNumber.create; var nanType = ZodNaN.create; @@ -4389,46 +4183,46 @@ var ostring = () => stringType().optional(); var onumber = () => numberType().optional(); var oboolean = () => booleanType().optional(); var coerce = { - string: (arg) => ZodString.create({ ...arg, coerce: true }), - number: (arg) => ZodNumber.create({ ...arg, coerce: true }), - boolean: (arg) => - ZodBoolean.create({ - ...arg, - coerce: true, - }), - bigint: (arg) => ZodBigInt.create({ ...arg, coerce: true }), - date: (arg) => ZodDate.create({ ...arg, coerce: true }), + string: ((arg) => ZodString.create({ ...arg, coerce: true })), + number: ((arg) => ZodNumber.create({ ...arg, coerce: true })), + boolean: ((arg) => ZodBoolean.create({ + ...arg, + coerce: true + })), + bigint: ((arg) => ZodBigInt.create({ ...arg, coerce: true })), + date: ((arg) => ZodDate.create({ ...arg, coerce: true })) }; var NEVER = INVALID; // ../tools/dist/logger.js -var import_node_util = __toESM(require('util'), 1); +var import_node_util = __toESM(require("util"), 1); +var import_picocolors = __toESM(require_picocolors(), 1); var verbose = !!process.env.HARNESS_DEBUG; -var BASE_TAG = '[harness]'; -var getTimestamp = () => /* @__PURE__ */ new Date().toISOString(); -var normalizeScope = (scope) => - scope - .trim() - .replace(/^\[+|\]+$/g, '') - .replace(/\]\[/g, ']['); +var BASE_TAG = "[harness]"; +var INFO_TAG = import_picocolors.default.isColorSupported ? import_picocolors.default.reset(import_picocolors.default.inverse(import_picocolors.default.bold(import_picocolors.default.magenta(" HARNESS ")))) : "HARNESS"; +var ERROR_TAG = import_picocolors.default.isColorSupported ? import_picocolors.default.reset(import_picocolors.default.inverse(import_picocolors.default.bold(import_picocolors.default.red(" HARNESS ")))) : "HARNESS"; +var getTimestamp = () => (/* @__PURE__ */ new Date()).toISOString(); +var normalizeScope = (scope) => scope.trim().replace(/^\[+|\]+$/g, "").replace(/\]\[/g, "]["); var formatPrefix = (scopes) => { - const suffix = scopes.map((scope) => `[${normalizeScope(scope)}]`).join(''); + const suffix = scopes.map((scope) => `[${normalizeScope(scope)}]`).join(""); return `${BASE_TAG}${suffix}`; }; -var mapLines = (text, prefix) => - text - .split('\n') - .map((line) => `${prefix} ${line}`) - .join('\n'); +var mapLines = (text, prefix) => text.split("\n").map((line) => `${prefix} ${line}`).join("\n"); var writeLog = (level, scopes, messages) => { - const method = - level === 'warn' - ? console.warn - : level === 'error' - ? console.error - : level === 'debug' - ? console.debug - : console.info; + if (!verbose && (level === "info" || level === "log" || level === "success")) { + const output2 = import_node_util.default.format(...messages); + const tag = INFO_TAG; + process.stderr.write(`${tag} ${output2} +`); + return; + } + if (!verbose && level === "error") { + const output2 = import_node_util.default.format(...messages); + process.stderr.write(`${ERROR_TAG} ${output2} +`); + return; + } + const method = level === "warn" ? console.warn : console.debug; const output = import_node_util.default.format(...messages); const prefix = `${getTimestamp()} ${formatPrefix(scopes)}`; method(mapLines(output, prefix)); @@ -4444,163 +4238,128 @@ var createScopedLogger = (scopes = []) => ({ if (!verbose) { return; } - writeLog('debug', scopes, messages); + writeLog("debug", scopes, messages); }, info: (...messages) => { - writeLog('info', scopes, messages); + writeLog("info", scopes, messages); }, warn: (...messages) => { - writeLog('warn', scopes, messages); + writeLog("warn", scopes, messages); }, error: (...messages) => { - writeLog('error', scopes, messages); + writeLog("error", scopes, messages); }, log: (...messages) => { - writeLog('log', scopes, messages); + writeLog("log", scopes, messages); }, success: (...messages) => { - writeLog('success', scopes, messages); + writeLog("success", scopes, messages); }, child: (scope) => createScopedLogger([...scopes, scope]), setVerbose, - isVerbose, + isVerbose }); var logger = createScopedLogger(); // ../../node_modules/@clack/core/dist/index.mjs -var import_node_process = require('process'); -var k = __toESM(require('readline'), 1); -var import_node_readline = __toESM(require('readline'), 1); +var import_node_process = require("process"); +var k = __toESM(require("readline"), 1); +var import_node_readline = __toESM(require("readline"), 1); var import_sisteransi = __toESM(require_src(), 1); -var import_node_tty = require('tty'); -var Ft = { limit: 1 / 0, ellipsis: '' }; -var ft = { limit: 1 / 0, ellipsis: '', ellipsisWidth: 0 }; -var j = '\x07'; -var Q = '['; -var dt = ']'; +var import_node_tty = require("tty"); +var Ft = { limit: 1 / 0, ellipsis: "" }; +var ft = { limit: 1 / 0, ellipsis: "", ellipsisWidth: 0 }; +var j = "\x07"; +var Q = "["; +var dt = "]"; var U = `${dt}8;;`; -var et = new RegExp(`(?:\\${Q}(?\\d+)m|\\${U}(?.*)${j})`, 'y'); -var At = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel']; -var _ = { - actions: new Set(At), - aliases: /* @__PURE__ */ new Map([ - ['k', 'up'], - ['j', 'down'], - ['h', 'left'], - ['l', 'right'], - ['', 'cancel'], - ['escape', 'cancel'], - ]), - messages: { cancel: 'Canceled', error: 'Something went wrong' }, - withGuide: true, -}; -var bt = globalThis.process.platform.startsWith('win'); +var et = new RegExp(`(?:\\${Q}(?\\d+)m|\\${U}(?.*)${j})`, "y"); +var At = ["up", "down", "left", "right", "space", "enter", "cancel"]; +var _ = { actions: new Set(At), aliases: /* @__PURE__ */ new Map([["k", "up"], ["j", "down"], ["h", "left"], ["l", "right"], ["", "cancel"], ["escape", "cancel"]]), messages: { cancel: "Canceled", error: "Something went wrong" }, withGuide: true }; +var bt = globalThis.process.platform.startsWith("win"); // ../../node_modules/@clack/prompts/dist/index.mjs -var import_picocolors = __toESM(require_picocolors(), 1); -var import_node_process2 = __toESM(require('process'), 1); -var import_node_fs = require('fs'); -var import_node_path = require('path'); +var import_picocolors2 = __toESM(require_picocolors(), 1); +var import_node_process2 = __toESM(require("process"), 1); +var import_node_fs = require("fs"); +var import_node_path = require("path"); var import_sisteransi2 = __toESM(require_src(), 1); -var import_node_util2 = require('util'); +var import_node_util2 = require("util"); function ht() { - return import_node_process2.default.platform !== 'win32' - ? import_node_process2.default.env.TERM !== 'linux' - : !!import_node_process2.default.env.CI || - !!import_node_process2.default.env.WT_SESSION || - !!import_node_process2.default.env.TERMINUS_SUBLIME || - import_node_process2.default.env.ConEmuTask === '{cmd::Cmder}' || - import_node_process2.default.env.TERM_PROGRAM === 'Terminus-Sublime' || - import_node_process2.default.env.TERM_PROGRAM === 'vscode' || - import_node_process2.default.env.TERM === 'xterm-256color' || - import_node_process2.default.env.TERM === 'alacritty' || - import_node_process2.default.env.TERMINAL_EMULATOR === - 'JetBrains-JediTerm'; + return import_node_process2.default.platform !== "win32" ? import_node_process2.default.env.TERM !== "linux" : !!import_node_process2.default.env.CI || !!import_node_process2.default.env.WT_SESSION || !!import_node_process2.default.env.TERMINUS_SUBLIME || import_node_process2.default.env.ConEmuTask === "{cmd::Cmder}" || import_node_process2.default.env.TERM_PROGRAM === "Terminus-Sublime" || import_node_process2.default.env.TERM_PROGRAM === "vscode" || import_node_process2.default.env.TERM === "xterm-256color" || import_node_process2.default.env.TERM === "alacritty" || import_node_process2.default.env.TERMINAL_EMULATOR === "JetBrains-JediTerm"; } var ee = ht(); -var w = (e, r) => (ee ? e : r); -var Me = w('\u25C6', '*'); -var ce = w('\u25A0', 'x'); -var de = w('\u25B2', 'x'); -var V = w('\u25C7', 'o'); -var $e = w('\u250C', 'T'); -var h = w('\u2502', '|'); -var x = w('\u2514', '\u2014'); -var Re = w('\u2510', 'T'); -var Oe = w('\u2518', '\u2014'); -var Y = w('\u25CF', '>'); -var K = w('\u25CB', ' '); -var te = w('\u25FB', '[\u2022]'); -var k2 = w('\u25FC', '[+]'); -var z = w('\u25FB', '[ ]'); -var Pe = w('\u25AA', '\u2022'); -var se = w('\u2500', '-'); -var he = w('\u256E', '+'); -var Ne = w('\u251C', '+'); -var me = w('\u256F', '+'); -var pe = w('\u2570', '+'); -var We = w('\u256D', '+'); -var ge = w('\u25CF', '\u2022'); -var fe = w('\u25C6', '*'); -var Fe = w('\u25B2', '!'); -var ye = w('\u25A0', 'x'); -var Ft2 = { limit: 1 / 0, ellipsis: '' }; -var yt2 = { limit: 1 / 0, ellipsis: '', ellipsisWidth: 0 }; -var Ce = '\x07'; -var Ve = '['; -var vt = ']'; +var w = (e, r) => ee ? e : r; +var Me = w("\u25C6", "*"); +var ce = w("\u25A0", "x"); +var de = w("\u25B2", "x"); +var V = w("\u25C7", "o"); +var $e = w("\u250C", "T"); +var h = w("\u2502", "|"); +var x = w("\u2514", "\u2014"); +var Re = w("\u2510", "T"); +var Oe = w("\u2518", "\u2014"); +var Y = w("\u25CF", ">"); +var K = w("\u25CB", " "); +var te = w("\u25FB", "[\u2022]"); +var k2 = w("\u25FC", "[+]"); +var z = w("\u25FB", "[ ]"); +var Pe = w("\u25AA", "\u2022"); +var se = w("\u2500", "-"); +var he = w("\u256E", "+"); +var Ne = w("\u251C", "+"); +var me = w("\u256F", "+"); +var pe = w("\u2570", "+"); +var We = w("\u256D", "+"); +var ge = w("\u25CF", "\u2022"); +var fe = w("\u25C6", "*"); +var Fe = w("\u25B2", "!"); +var ye = w("\u25A0", "x"); +var Ft2 = { limit: 1 / 0, ellipsis: "" }; +var yt2 = { limit: 1 / 0, ellipsis: "", ellipsisWidth: 0 }; +var Ce = "\x07"; +var Ve = "["; +var vt = "]"; var we = `${vt}8;;`; -var Ge = new RegExp(`(?:\\${Ve}(?\\d+)m|\\${we}(?.*)${Ce})`, 'y'); -var Ut = import_picocolors.default.magenta; -var Ye = { - light: w('\u2500', '-'), - heavy: w('\u2501', '='), - block: w('\u2588', '#'), -}; -var ze = `${import_picocolors.default.gray(h)} `; +var Ge = new RegExp(`(?:\\${Ve}(?\\d+)m|\\${we}(?.*)${Ce})`, "y"); +var Ut = import_picocolors2.default.magenta; +var Ye = { light: w("\u2500", "-"), heavy: w("\u2501", "="), block: w("\u2588", "#") }; +var ze = `${import_picocolors2.default.gray(h)} `; // ../tools/dist/spawn.js -var spawnLogger = logger.child('spawn'); +var spawnLogger = logger.child("spawn"); // ../tools/dist/react-native.js -var import_node_module = require('module'); -var import_node_path2 = __toESM(require('path'), 1); -var import_node_fs2 = __toESM(require('fs'), 1); +var import_node_module = require("module"); +var import_node_path2 = __toESM(require("path"), 1); +var import_node_fs2 = __toESM(require("fs"), 1); // ../tools/dist/error.js -var HarnessError = class extends Error {}; +var HarnessError = class extends Error { +}; // ../tools/dist/packages.js -var import_node_path3 = __toESM(require('path'), 1); -var import_node_fs3 = __toESM(require('fs'), 1); +var import_node_path3 = __toESM(require("path"), 1); +var import_node_fs3 = __toESM(require("fs"), 1); // ../tools/dist/crash-artifacts.js -var import_node_fs4 = __toESM(require('fs'), 1); -var import_node_path4 = __toESM(require('path'), 1); -var DEFAULT_ARTIFACT_ROOT = import_node_path4.default.join( - process.cwd(), - '.harness', - 'crash-reports' -); +var import_node_fs4 = __toESM(require("fs"), 1); +var import_node_path4 = __toESM(require("path"), 1); +var DEFAULT_ARTIFACT_ROOT = import_node_path4.default.join(process.cwd(), ".harness", "crash-reports"); // ../plugins/dist/utils.js var isHookTree = (value) => { - if (value == null || typeof value !== 'object' || Array.isArray(value)) { + if (value == null || typeof value !== "object" || Array.isArray(value)) { return false; } for (const child of Object.values(value)) { if (child === void 0) { continue; } - if (typeof child === 'function') { + if (typeof child === "function") { continue; } - if ( - child == null || - typeof child !== 'object' || - Array.isArray(child) || - !isHookTree(child) - ) { + if (child == null || typeof child !== "object" || Array.isArray(child) || !isHookTree(child)) { return false; } } @@ -4609,17 +4368,14 @@ var isHookTree = (value) => { // ../plugins/dist/plugin.js var isHarnessPlugin = (value) => { - if (value == null || typeof value !== 'object' || Array.isArray(value)) { + if (value == null || typeof value !== "object" || Array.isArray(value)) { return false; } const candidate = value; - if (typeof candidate.name !== 'string' || candidate.name.length === 0) { + if (typeof candidate.name !== "string" || candidate.name.length === 0) { return false; } - if ( - candidate.createState != null && - typeof candidate.createState !== 'function' - ) { + if (candidate.createState != null && typeof candidate.createState !== "function") { return false; } if (candidate.hooks != null && !isHookTree(candidate.hooks)) { @@ -4629,125 +4385,51 @@ var isHarnessPlugin = (value) => { }; // ../plugins/dist/manager.js -var pluginsLogger = logger.child('plugins'); +var pluginsLogger = logger.child("plugins"); // ../config/dist/types.js var DEFAULT_METRO_PORT = 8081; var RunnerSchema = external_exports.object({ - name: external_exports - .string() - .min(1, 'Runner name is required') - .regex( - /^[a-zA-Z0-9._-]+$/, - 'Runner name can only contain alphanumeric characters, dots, underscores, and hyphens' - ), + name: external_exports.string().min(1, "Runner name is required").regex(/^[a-zA-Z0-9._-]+$/, "Runner name can only contain alphanumeric characters, dots, underscores, and hyphens"), config: external_exports.record(external_exports.any()), runner: external_exports.string(), - platformId: external_exports.string(), + platformId: external_exports.string() +}); +var PluginSchema = external_exports.custom((value) => isHarnessPlugin(value), "Invalid Harness plugin"); +var ConfigSchema = external_exports.object({ + entryPoint: external_exports.string().min(1, "Entry point is required"), + appRegistryComponentName: external_exports.string().min(1, "App registry component name is required"), + runners: external_exports.array(RunnerSchema).min(1, "At least one runner is required"), + plugins: external_exports.array(PluginSchema).optional().default([]), + defaultRunner: external_exports.string().optional(), + host: external_exports.string().min(1, "Host is required").optional(), + metroPort: external_exports.number().int("Metro port must be an integer").min(1, "Metro port must be at least 1").max(65535, "Metro port must be at most 65535").optional().default(DEFAULT_METRO_PORT), + webSocketPort: external_exports.number().optional().describe("Deprecated. Bridge traffic now uses metroPort and this value is ignored."), + bridgeTimeout: external_exports.number().min(1e3, "Bridge timeout must be at least 1 second").default(6e4), + platformReadyTimeout: external_exports.number().min(1e3, "Platform ready timeout must be at least 1 second").default(3e5), + bundleStartTimeout: external_exports.number().min(1e3, "Bundle start timeout must be at least 1 second").default(6e4), + maxAppRestarts: external_exports.number().min(0, "Max app restarts must be at least 0").default(2), + resetEnvironmentBetweenTestFiles: external_exports.boolean().optional().default(true), + unstable__skipAlreadyIncludedModules: external_exports.boolean().optional().default(false), + unstable__enableMetroCache: external_exports.boolean().optional().default(false), + detectNativeCrashes: external_exports.boolean().optional().default(true), + crashDetectionInterval: external_exports.number().min(100, "Crash detection interval must be at least 100ms").default(500), + disableViewFlattening: external_exports.boolean().optional().default(false).describe("Disable view flattening in React Native. This will set collapsable={true} for all View components to ensure they are not flattened by the native layout engine."), + coverage: external_exports.object({ + root: external_exports.string().optional().describe(`Root directory for coverage instrumentation in monorepo setups. Specifies the directory from which coverage data should be collected. Use ".." for create-react-native-library projects where tests run from example/ but source files are in parent directory. Passed to babel-plugin-istanbul's cwd option.`) + }).optional(), + forwardClientLogs: external_exports.boolean().optional().default(false).describe("Enable forwarding of console.log, console.warn, console.error, and other console method calls from the React Native app to the terminal. When enabled, all console output from your app will be displayed in the test runner terminal with styled level indicators (log, warn, error)."), + // Deprecated property - used for migration detection + include: external_exports.array(external_exports.string()).optional() +}).refine((config) => { + if (config.defaultRunner) { + return config.runners.some((runner) => runner.name === config.defaultRunner); + } + return true; +}, { + message: "Default runner must match one of the configured runner names", + path: ["defaultRunner"] }); -var PluginSchema = external_exports.custom( - (value) => isHarnessPlugin(value), - 'Invalid Harness plugin' -); -var ConfigSchema = external_exports - .object({ - entryPoint: external_exports.string().min(1, 'Entry point is required'), - appRegistryComponentName: external_exports - .string() - .min(1, 'App registry component name is required'), - runners: external_exports - .array(RunnerSchema) - .min(1, 'At least one runner is required'), - plugins: external_exports.array(PluginSchema).optional().default([]), - defaultRunner: external_exports.string().optional(), - host: external_exports.string().min(1, 'Host is required').optional(), - metroPort: external_exports - .number() - .int('Metro port must be an integer') - .min(1, 'Metro port must be at least 1') - .max(65535, 'Metro port must be at most 65535') - .optional() - .default(DEFAULT_METRO_PORT), - webSocketPort: external_exports - .number() - .optional() - .describe( - 'Deprecated. Bridge traffic now uses metroPort and this value is ignored.' - ), - bridgeTimeout: external_exports - .number() - .min(1e3, 'Bridge timeout must be at least 1 second') - .default(6e4), - platformReadyTimeout: external_exports - .number() - .min(1e3, 'Platform ready timeout must be at least 1 second') - .default(3e5), - bundleStartTimeout: external_exports - .number() - .min(1e3, 'Bundle start timeout must be at least 1 second') - .default(6e4), - maxAppRestarts: external_exports - .number() - .min(0, 'Max app restarts must be at least 0') - .default(2), - resetEnvironmentBetweenTestFiles: external_exports - .boolean() - .optional() - .default(true), - unstable__skipAlreadyIncludedModules: external_exports - .boolean() - .optional() - .default(false), - unstable__enableMetroCache: external_exports - .boolean() - .optional() - .default(false), - detectNativeCrashes: external_exports.boolean().optional().default(true), - crashDetectionInterval: external_exports - .number() - .min(100, 'Crash detection interval must be at least 100ms') - .default(500), - disableViewFlattening: external_exports - .boolean() - .optional() - .default(false) - .describe( - 'Disable view flattening in React Native. This will set collapsable={true} for all View components to ensure they are not flattened by the native layout engine.' - ), - coverage: external_exports - .object({ - root: external_exports - .string() - .optional() - .describe( - `Root directory for coverage instrumentation in monorepo setups. Specifies the directory from which coverage data should be collected. Use ".." for create-react-native-library projects where tests run from example/ but source files are in parent directory. Passed to babel-plugin-istanbul's cwd option.` - ), - }) - .optional(), - forwardClientLogs: external_exports - .boolean() - .optional() - .default(false) - .describe( - 'Enable forwarding of console.log, console.warn, console.error, and other console method calls from the React Native app to the terminal. When enabled, all console output from your app will be displayed in the test runner terminal with styled level indicators (log, warn, error).' - ), - // Deprecated property - used for migration detection - include: external_exports.array(external_exports.string()).optional(), - }) - .refine( - (config) => { - if (config.defaultRunner) { - return config.runners.some( - (runner) => runner.name === config.defaultRunner - ); - } - return true; - }, - { - message: 'Default runner must match one of the configured runner names', - path: ['defaultRunner'], - } - ); // ../config/dist/errors.js var ConfigValidationError = class extends HarnessError { @@ -4756,44 +4438,40 @@ var ConfigValidationError = class extends HarnessError { constructor(filePath, validationErrors) { const lines = [ `Configuration validation failed in ${filePath}`, - '', - 'The following issues were found:', - '', + "", + "The following issues were found:", + "" ]; validationErrors.forEach((error, index) => { lines.push(` ${index + 1}. ${error}`); }); - lines.push( - '', - 'Please fix these issues and try again.', - 'For more information, visit: https://react-native-harness.dev/docs/configuration' - ); - super(lines.join('\n')); + lines.push("", "Please fix these issues and try again.", "For more information, visit: https://react-native-harness.dev/docs/configuration"); + super(lines.join("\n")); this.filePath = filePath; this.validationErrors = validationErrors; - this.name = 'ConfigValidationError'; + this.name = "ConfigValidationError"; } }; var ConfigNotFoundError = class extends HarnessError { searchPath; constructor(searchPath) { const lines = [ - 'Configuration file not found', - '', + "Configuration file not found", + "", `Searched for configuration files in: ${searchPath}`, - 'and all parent directories.', - '', - 'React Native Harness looks for one of these files:', - ' \u2022 rn-harness.config.js', - ' \u2022 rn-harness.config.mjs', - ' \u2022 rn-harness.config.cjs', - ' \u2022 rn-harness.config.json', - '', - 'For more information, visit: https://www.react-native-harness.dev/docs/getting-started/configuration', + "and all parent directories.", + "", + "React Native Harness looks for one of these files:", + " \u2022 rn-harness.config.js", + " \u2022 rn-harness.config.mjs", + " \u2022 rn-harness.config.cjs", + " \u2022 rn-harness.config.json", + "", + "For more information, visit: https://www.react-native-harness.dev/docs/getting-started/configuration" ]; - super(lines.join('\n')); + super(lines.join("\n")); this.searchPath = searchPath; - this.name = 'ConfigNotFoundError'; + this.name = "ConfigNotFoundError"; } }; var ConfigLoadError = class extends HarnessError { @@ -4801,44 +4479,30 @@ var ConfigLoadError = class extends HarnessError { cause; constructor(filePath, cause) { const lines = [ - 'Failed to load configuration file', - '', + "Failed to load configuration file", + "", `File: ${filePath}`, - '', + "" ]; if (cause) { - lines.push('Error details:'); + lines.push("Error details:"); lines.push(` ${cause.message}`); - lines.push(''); + lines.push(""); } - lines.push( - 'This could be due to:', - ' \u2022 Syntax errors in your configuration file', - ' \u2022 Missing dependencies or modules', - ' \u2022 Invalid file format or encoding', - ' \u2022 File permissions issues', - '', - 'Troubleshooting steps:', - ' 1. Check the file syntax and format', - ' 2. Ensure all required dependencies are installed', - ' 3. Verify file permissions', - ' 4. Try creating a new configuration file', - '', - 'For more help, visit: https://www.react-native-harness.dev/docs/getting-started/configuration' - ); - super(lines.join('\n')); + lines.push("This could be due to:", " \u2022 Syntax errors in your configuration file", " \u2022 Missing dependencies or modules", " \u2022 Invalid file format or encoding", " \u2022 File permissions issues", "", "Troubleshooting steps:", " 1. Check the file syntax and format", " 2. Ensure all required dependencies are installed", " 3. Verify file permissions", " 4. Try creating a new configuration file", "", "For more help, visit: https://www.react-native-harness.dev/docs/getting-started/configuration"); + super(lines.join("\n")); this.filePath = filePath; - this.name = 'ConfigLoadError'; + this.name = "ConfigLoadError"; this.cause = cause; } }; // ../config/dist/reader.js -var import_node_path5 = __toESM(require('path'), 1); -var import_node_fs5 = __toESM(require('fs'), 1); -var import_node_module2 = require('module'); +var import_node_path5 = __toESM(require("path"), 1); +var import_node_fs5 = __toESM(require("fs"), 1); +var import_node_module2 = require("module"); var import_meta = {}; -var extensions = ['.js', '.mjs', '.cjs', '.json']; +var extensions = [".js", ".mjs", ".cjs", ".json"]; var importUp = async (dir, name) => { const filePath = import_node_path5.default.join(dir, name); for (const ext of extensions) { @@ -4846,21 +4510,14 @@ var importUp = async (dir, name) => { if (import_node_fs5.default.existsSync(filePathWithExt)) { let rawConfig; try { - if (ext === '.mjs') { - rawConfig = await import(filePathWithExt).then( - (module2) => module2.default - ); + if (ext === ".mjs") { + rawConfig = await import(filePathWithExt).then((module2) => module2.default); } else { - const require2 = (0, import_node_module2.createRequire)( - import_meta.url - ); + const require2 = (0, import_node_module2.createRequire)(import_meta.url); rawConfig = require2(filePathWithExt); } } catch (error) { - throw new ConfigLoadError( - filePathWithExt, - error instanceof Error ? error : void 0 - ); + throw new ConfigLoadError(filePathWithExt, error instanceof Error ? error : void 0); } try { const config = ConfigSchema.parse(rawConfig); @@ -4868,8 +4525,7 @@ var importUp = async (dir, name) => { } catch (error) { if (error instanceof ZodError) { const validationErrors = error.errors.map((err) => { - const path6 = - err.path.length > 0 ? ` at "${err.path.join('.')}"` : ''; + const path6 = err.path.length > 0 ? ` at "${err.path.join(".")}"` : ""; return `${err.message}${path6}`; }); throw new ConfigValidationError(filePathWithExt, validationErrors); @@ -4885,43 +4541,35 @@ var importUp = async (dir, name) => { return importUp(parentDir, name); }; var getConfig = async (dir) => { - const { config, configDir } = await importUp(dir, 'rn-harness.config'); + const { config, configDir } = await importUp(dir, "rn-harness.config"); return { config, - projectRoot: configDir, + projectRoot: configDir }; }; // src/shared/index.ts -var import_node_path6 = __toESM(require('path')); -var import_node_fs6 = __toESM(require('fs')); +var import_node_path6 = __toESM(require("path")); +var import_node_fs6 = __toESM(require("fs")); var run = async () => { try { const projectRootInput = process.env.INPUT_PROJECTROOT; const runnerInput = process.env.INPUT_RUNNER; if (!runnerInput) { - throw new Error('Runner input is required'); + throw new Error("Runner input is required"); } - const projectRoot = projectRootInput - ? import_node_path6.default.resolve(projectRootInput) - : process.cwd(); + const projectRoot = projectRootInput ? import_node_path6.default.resolve(projectRootInput) : process.cwd(); console.info(`Loading React Native Harness config from: ${projectRoot}`); - const { config, projectRoot: resolvedProjectRoot } = await getConfig( - projectRoot - ); - const runner = config.runners.find( - (runner2) => runner2.name === runnerInput - ); + const { config, projectRoot: resolvedProjectRoot } = await getConfig(projectRoot); + const runner = config.runners.find((runner2) => runner2.name === runnerInput); if (!runner) { throw new Error(`Runner ${runnerInput} not found in config`); } const githubOutput = process.env.GITHUB_OUTPUT; if (!githubOutput) { - throw new Error('GITHUB_OUTPUT environment variable is not set'); + throw new Error("GITHUB_OUTPUT environment variable is not set"); } - const relativeProjectRoot = - import_node_path6.default.relative(process.cwd(), resolvedProjectRoot) || - '.'; + const relativeProjectRoot = import_node_path6.default.relative(process.cwd(), resolvedProjectRoot) || "."; const output = `config=${JSON.stringify(runner)} projectRoot=${relativeProjectRoot} `; @@ -4930,7 +4578,7 @@ projectRoot=${relativeProjectRoot} if (error instanceof Error) { console.error(error.message); } else { - console.error('Failed to load Harness configuration'); + console.error("Failed to load Harness configuration"); } process.exit(1); } From 0a17257a73f1e221712053f57f2ef25b60cf2024 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 1 Apr 2026 13:30:34 +0200 Subject: [PATCH 21/26] fix: start bundle request timeout after app launch --- .../src/__tests__/startup.test.ts | 181 +++++++++++++----- packages/bundler-metro/src/startup.ts | 173 +++++++++++------ 2 files changed, 243 insertions(+), 111 deletions(-) diff --git a/packages/bundler-metro/src/__tests__/startup.test.ts b/packages/bundler-metro/src/__tests__/startup.test.ts index 656164da..e2925760 100644 --- a/packages/bundler-metro/src/__tests__/startup.test.ts +++ b/packages/bundler-metro/src/__tests__/startup.test.ts @@ -63,9 +63,7 @@ afterEach(() => { describe('waitForMetroBackedAppReady', () => { it('fails when Metro never becomes healthy', async () => { const metroInstance = createMetroInstance({ - waitUntilHealthy: vi.fn( - async () => 'HTTP 503: packager-status:starting' - ), + waitUntilHealthy: vi.fn(async () => 'HTTP 503: packager-status:starting'), }); const startAttempt = vi.fn(async () => undefined); @@ -144,51 +142,89 @@ describe('waitForMetroBackedAppReady', () => { expect(waitForReady).toHaveBeenCalledTimes(1); }); - it('does not miss ready events emitted before bundle-request handling moves to the ready phase', async () => { + it('does not count startAttempt duration against bundleStartTimeout', async () => { + vi.useFakeTimers(); + + const metroInstance = createMetroInstance(); + let resolveStartAttempt!: () => void; + const startAttempt = vi.fn( + async () => + await new Promise((resolve) => { + resolveStartAttempt = resolve; + }) + ); + const waitForReady = vi.fn(async () => undefined); + + let settled = false; + const promise = waitForMetroBackedAppReady({ + metro: metroInstance, + platformId: 'ios', + bundleStartTimeout: 1_000, + readyTimeout: 2_000, + maxAppRestarts: 2, + signal: new AbortController().signal, + startAttempt, + waitForReady, + waitForCrash: async (signal) => await waitForAbort(signal), + }).finally(() => { + settled = true; + }); + + await vi.advanceTimersByTimeAsync(5_000); + + expect(settled).toBe(false); + + resolveStartAttempt(); + await vi.advanceTimersByTimeAsync(0); + emitBundleRequestObserved(metroInstance, 'app'); + await promise; + + expect(startAttempt).toHaveBeenCalledTimes(1); + expect(waitForReady).toHaveBeenCalledTimes(1); + }); + + it('captures app requests emitted while startAttempt is still running', async () => { const metroInstance = createMetroInstance(); - const readyListeners = new Set<() => void>(); - let readyAlreadyReported = false; - - const emitReady = () => { - readyAlreadyReported = true; - for (const listener of readyListeners) { - listener(); - } - readyListeners.clear(); - }; - - const waitForReady = vi.fn(async (signal: AbortSignal) => { - if (readyAlreadyReported) { - return await waitForAbort(signal); - } - - return await new Promise((resolve, reject) => { - const onReady = () => { - cleanup(); - resolve(); - }; - const onAbort = () => { - cleanup(); - reject(signal.reason ?? createAbortError()); - }; - const cleanup = () => { - readyListeners.delete(onReady); - signal.removeEventListener('abort', onAbort); - }; - - readyListeners.add(onReady); - signal.addEventListener('abort', onAbort, { once: true }); - }); + let releaseStartAttempt!: () => void; + const startAttemptGate = new Promise((resolve) => { + releaseStartAttempt = resolve; }); + const startAttempt = vi.fn(async () => { + emitBundleRequestObserved(metroInstance, 'app'); + await startAttemptGate; + }); + const waitForReady = vi.fn(async () => undefined); + + const promise = waitForMetroBackedAppReady({ + metro: metroInstance, + platformId: 'ios', + bundleStartTimeout: 1_000, + readyTimeout: 2_000, + maxAppRestarts: 2, + signal: new AbortController().signal, + startAttempt, + waitForReady, + waitForCrash: async (signal) => await waitForAbort(signal), + }); + + releaseStartAttempt(); + await promise; + + expect(startAttempt).toHaveBeenCalledTimes(1); + expect(waitForReady).toHaveBeenCalledTimes(1); + }); + + it('does not miss ready events emitted before bundle-request handling moves to the ready phase', async () => { + const metroInstance = createMetroInstance(); + const waitForReady = vi.fn(async () => undefined); const startAttempt = vi.fn(async () => { - emitReady(); emitBundleRequestObserved(metroInstance, 'app'); }); await waitForMetroBackedAppReady({ metro: metroInstance, - platformId: 'web', + platformId: 'ios', bundleStartTimeout: 1_000, readyTimeout: 2_000, maxAppRestarts: 2, @@ -210,7 +246,9 @@ describe('waitForMetroBackedAppReady', () => { const startAttempt = vi.fn(async () => { emitBundleRequestObserved(metroInstance, 'app'); setTimeout(() => { - emitMetroEvent(metroInstance, { type: 'bundle_build_started' } as never); + emitMetroEvent(metroInstance, { + type: 'bundle_build_started', + } as never); }, 0); }); const waitForReady = vi.fn( @@ -258,7 +296,9 @@ describe('waitForMetroBackedAppReady', () => { const startAttempt = vi.fn(async () => { emitBundleRequestObserved(metroInstance, 'app'); setTimeout(() => { - emitMetroEvent(metroInstance, { type: 'bundle_build_started' } as never); + emitMetroEvent(metroInstance, { + type: 'bundle_build_started', + } as never); emitMetroEvent(metroInstance, { type: 'bundle_build_done' } as never); }, 0); }); @@ -274,15 +314,16 @@ describe('waitForMetroBackedAppReady', () => { waitForReady: async (signal) => await waitForAbort(signal), waitForCrash: async (signal) => await waitForAbort(signal), }); - - await vi.advanceTimersByTimeAsync(0); - await vi.advanceTimersByTimeAsync(2_000); - - await expect(promise).rejects.toMatchObject({ + const rejection = expect(promise).rejects.toMatchObject({ name: 'StartupStallError', code: 'ready_not_reported', attempts: 1, }); + + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(2_000); + + await rejection; expect(startAttempt).toHaveBeenCalledTimes(1); }); @@ -305,14 +346,15 @@ describe('waitForMetroBackedAppReady', () => { waitForReady: async (signal) => await waitForAbort(signal), waitForCrash: async (signal) => await waitForAbort(signal), }); - - await vi.advanceTimersByTimeAsync(2_000); - - await expect(promise).rejects.toMatchObject({ + const rejection = expect(promise).rejects.toMatchObject({ name: 'StartupStallError', code: 'ready_not_reported', attempts: 1, }); + + await vi.advanceTimersByTimeAsync(2_000); + + await rejection; expect(startAttempt).toHaveBeenCalledTimes(1); }); @@ -368,4 +410,43 @@ describe('waitForMetroBackedAppReady', () => { }); expect(startAttempt).toHaveBeenCalledTimes(3); }); + + it('does not surface a raw bundle timeout while startAttempt is pending', async () => { + vi.useFakeTimers(); + + const metroInstance = createMetroInstance({ + prewarm: vi.fn(async () => true), + }); + let releaseStartAttempt!: () => void; + const startAttemptGate = new Promise((resolve) => { + releaseStartAttempt = resolve; + }); + const startAttempt = vi.fn(async () => { + await startAttemptGate; + }); + + const promise = waitForMetroBackedAppReady({ + metro: metroInstance, + platformId: 'ios', + bundleStartTimeout: 1_000, + readyTimeout: 2_000, + maxAppRestarts: 0, + signal: new AbortController().signal, + startAttempt, + waitForReady: async (signal) => await waitForAbort(signal), + waitForCrash: async (signal) => await waitForAbort(signal), + }); + const rejection = expect(promise).rejects.toMatchObject({ + name: 'StartupStallError', + code: 'bundle_request_not_observed', + attempts: 1, + sawPrewarmRequest: true, + }); + + await vi.advanceTimersByTimeAsync(5_000); + releaseStartAttempt(); + await vi.advanceTimersByTimeAsync(1_000); + + await rejection; + }); }); diff --git a/packages/bundler-metro/src/startup.ts b/packages/bundler-metro/src/startup.ts index e84a2968..c5bc64f1 100644 --- a/packages/bundler-metro/src/startup.ts +++ b/packages/bundler-metro/src/startup.ts @@ -1,4 +1,7 @@ -import { raceAbortSignals, withAbortTimeout } from '@react-native-harness/tools'; +import { + raceAbortSignals, + withAbortTimeout, +} from '@react-native-harness/tools'; import { StartupStallError } from './errors.js'; import type { ReportableEvent } from './reporter.js'; import type { MetroInstance } from './types.js'; @@ -6,8 +9,6 @@ import type { MetroInstance } from './types.js'; type WaitForBundleRequestOptions = { events: MetroInstance['events']; platformId: string; - timeoutMs: number; - signal: AbortSignal; initialPrewarmSeen?: boolean; }; @@ -15,9 +16,18 @@ type BundleRequestObservation = { sawPrewarmRequest: boolean; }; +type BundleRequestObserver = { + sawPrewarmRequest: () => boolean; + hasSeenAppRequest: () => boolean; + waitForAppRequest: () => Promise; + dispose: () => void; +}; + class ReadyTimeoutError extends Error { constructor() { - super('Timed out waiting for the app to become ready after Metro bundling.'); + super( + 'Timed out waiting for the app to become ready after Metro bundling.' + ); this.name = 'ReadyTimeoutError'; } } @@ -47,61 +57,90 @@ const isAbortError = (error: unknown): error is DOMException => { return error instanceof DOMException && error.name === 'AbortError'; }; -const waitForBundleRequest = async ({ +const observeBundleRequest = ({ events, platformId, - timeoutMs, - signal, initialPrewarmSeen = false, -}: WaitForBundleRequestOptions): Promise => { +}: WaitForBundleRequestOptions): BundleRequestObserver => { let sawPrewarmRequest = initialPrewarmSeen; + let sawAppRequest = false; + let settled = false; - return await new Promise((resolve, reject) => { - const requestSignal = withAbortTimeout(signal, timeoutMs); + let resolvePromise!: (value: BundleRequestObservation) => void; + const appRequestPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); - const cleanup = () => { - events.removeListener(onMetroEvent); - requestSignal.removeEventListener('abort', onAbort); - }; + const resolveOnce = () => { + if (settled) { + return; + } - const resolveOnce = () => { - cleanup(); - resolve({ - sawPrewarmRequest, - }); - }; + settled = true; + resolvePromise({ + sawPrewarmRequest, + }); + }; - const rejectOnce = (error: unknown) => { - cleanup(); - reject(error); - }; + const onMetroEvent = (event: ReportableEvent) => { + if (event.type !== 'bundle_request_observed') { + return; + } - const onAbort = () => { - if (signal.aborted) { - rejectOnce(signal.reason ?? new DOMException('The operation was aborted', 'AbortError')); - return; - } + if (event.requestKind === 'prewarm') { + sawPrewarmRequest = true; + return; + } - rejectOnce(new BundleRequestTimeoutError(sawPrewarmRequest)); + if (event.requestKind === 'app' && event.platform === platformId) { + sawAppRequest = true; + resolveOnce(); + } + }; + + events.addListener(onMetroEvent); + + return { + sawPrewarmRequest: () => sawPrewarmRequest, + hasSeenAppRequest: () => sawAppRequest, + waitForAppRequest: async () => await appRequestPromise, + dispose: () => { + events.removeListener(onMetroEvent); + }, + }; +}; + +const waitForBundleRequestTimeout = async ({ + timeoutMs, + signal, + sawPrewarmRequest, +}: { + timeoutMs: number; + signal: AbortSignal; + sawPrewarmRequest: () => boolean; +}): Promise => { + const timeoutSignal = withAbortTimeout(signal, timeoutMs); + + return await new Promise((_, reject) => { + const cleanup = () => { + timeoutSignal.removeEventListener('abort', onAbort); }; - const onMetroEvent = (event: ReportableEvent) => { - if (event.type !== 'bundle_request_observed') { - return; - } + const onAbort = () => { + cleanup(); - if (event.requestKind === 'prewarm') { - sawPrewarmRequest = true; + if (signal.aborted) { + reject( + signal.reason ?? + new DOMException('The operation was aborted', 'AbortError') + ); return; } - if (event.requestKind === 'app' && event.platform === platformId) { - resolveOnce(); - } + reject(new BundleRequestTimeoutError(sawPrewarmRequest())); }; - events.addListener(onMetroEvent); - requestSignal.addEventListener('abort', onAbort, { once: true }); + timeoutSignal.addEventListener('abort', onAbort, { once: true }); }); }; @@ -112,7 +151,8 @@ const waitForReadyAfterBundleRequest = async (options: { readyPromise: Promise; cancelReadyWait: () => void; }): Promise => { - const { events, readyTimeout, signal, readyPromise, cancelReadyWait } = options; + const { events, readyTimeout, signal, readyPromise, cancelReadyWait } = + options; return await new Promise((resolve, reject) => { let bundlingInProgress = false; @@ -161,7 +201,10 @@ const waitForReadyAfterBundleRequest = async (options: { }; const onAbort = () => { - rejectOnce(signal.reason ?? new DOMException('The operation was aborted', 'AbortError')); + rejectOnce( + signal.reason ?? + new DOMException('The operation was aborted', 'AbortError') + ); }; const onMetroEvent = (event: ReportableEvent) => { @@ -190,13 +233,11 @@ const waitForReadyAfterBundleRequest = async (options: { resolveOnce(); }) .catch((error) => { - if ( - error instanceof DOMException && - error.name === 'AbortError' - ) { + if (error instanceof DOMException && error.name === 'AbortError') { if (signal.aborted) { rejectOnce( - signal.reason ?? new DOMException('The operation was aborted', 'AbortError') + signal.reason ?? + new DOMException('The operation was aborted', 'AbortError') ); } return; @@ -248,27 +289,35 @@ export const waitForMetroBackedAppReady = async ({ const attemptController = new AbortController(); const attemptSignal = raceAbortSignals([signal, attemptController.signal]); const crashPromise = waitForCrash(attemptSignal); + void crashPromise.catch(() => undefined); const readyController = new AbortController(); const readyPromise = waitForReady( raceAbortSignals([attemptSignal, readyController.signal]) ); + void readyPromise.catch(() => undefined); + const bundleRequestObserver = observeBundleRequest({ + events: metro.events, + platformId, + initialPrewarmSeen: sawPrewarmRequest, + }); try { - const bundleRequestPromise = waitForBundleRequest({ - events: metro.events, - platformId, - timeoutMs: bundleStartTimeout, - signal: attemptSignal, - initialPrewarmSeen: sawPrewarmRequest, - }); - await startAttempt(); - const bundleRequestResult = await Promise.race([ - bundleRequestPromise, - crashPromise, - ]); - sawPrewarmRequest = bundleRequestResult.sawPrewarmRequest; + if (!bundleRequestObserver.hasSeenAppRequest()) { + const bundleRequestResult = await Promise.race([ + bundleRequestObserver.waitForAppRequest(), + waitForBundleRequestTimeout({ + timeoutMs: bundleStartTimeout, + signal: attemptSignal, + sawPrewarmRequest: bundleRequestObserver.sawPrewarmRequest, + }), + crashPromise, + ]); + sawPrewarmRequest = bundleRequestResult.sawPrewarmRequest; + } else { + sawPrewarmRequest = bundleRequestObserver.sawPrewarmRequest(); + } const readyAfterBundleRequestPromise = waitForReadyAfterBundleRequest({ events: metro.events, @@ -282,10 +331,12 @@ export const waitForMetroBackedAppReady = async ({ }, }); await Promise.race([readyAfterBundleRequestPromise, crashPromise]); + bundleRequestObserver.dispose(); attemptController.abort(); onAttemptReset?.(); return; } catch (error) { + bundleRequestObserver.dispose(); readyController.abort( new DOMException('The operation was aborted', 'AbortError') ); From ea6ff4fa283f6940f3733225503e02956ed2cff1 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 1 Apr 2026 19:07:40 +0200 Subject: [PATCH 22/26] feat: add Android AVD snapshot caching flow --- action.yml | 30 +-- actions/android/action.yml | 30 +-- packages/github-action/src/action.yml | 30 +-- packages/github-action/src/android/action.yml | 30 +-- packages/github-action/src/shared/index.ts | 101 +++++++- .../src/__tests__/adb.test.ts | 113 ++++++++ .../src/__tests__/avd-config.test.ts | 149 +++++++++++ .../src/__tests__/ci-action.test.ts | 41 +++ .../src/__tests__/emulator-startup.test.ts | 32 +++ .../src/__tests__/instance.test.ts | 187 +++++++++++++- packages/platform-android/src/adb.ts | 43 +++- packages/platform-android/src/avd-config.ts | 241 ++++++++++++++++++ packages/platform-android/src/config.ts | 8 + .../platform-android/src/emulator-startup.ts | 28 ++ packages/platform-android/src/index.ts | 5 + packages/platform-android/src/instance.ts | 181 ++++++++++--- 16 files changed, 1113 insertions(+), 136 deletions(-) create mode 100644 packages/platform-android/src/__tests__/avd-config.test.ts create mode 100644 packages/platform-android/src/__tests__/emulator-startup.test.ts create mode 100644 packages/platform-android/src/avd-config.ts create mode 100644 packages/platform-android/src/emulator-startup.ts diff --git a/action.yml b/action.yml index c41aac43..74769b2c 100644 --- a/action.yml +++ b/action.yml @@ -52,6 +52,7 @@ runs: env: INPUT_RUNNER: ${{ inputs.runner }} INPUT_PROJECTROOT: ${{ inputs.projectRoot }} + HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }} run: | node ${{ github.action_path }}/actions/shared/index.cjs - name: Verify native app input @@ -112,40 +113,26 @@ runs: ls /dev/kvm - name: Compute AVD cache key id: avd-key - if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) }} + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled }} shell: bash run: | - CONFIG='${{ steps.load-config.outputs.config }}' - AVD_CONFIG=$(echo "$CONFIG" | jq -c '.config.device.avd') - AVD_CONFIG_HASH=$(echo "$AVD_CONFIG" | sha256sum | cut -d' ' -f1) + CACHE_CONFIG='${{ toJson(fromJson(steps.load-config.outputs.config).action.avdCacheConfig) }}' + AVD_CONFIG_HASH=$(printf '%s' "$CACHE_CONFIG" | sha256sum | cut -d' ' -f1) + AVD_NAME='${{ fromJson(steps.load-config.outputs.config).config.device.name }}' ARCH="${{ steps.arch.outputs.arch }}" - CACHE_KEY="avd-$ARCH-$AVD_CONFIG_HASH" + CACHE_KEY="avd-$AVD_NAME-$ARCH-$AVD_CONFIG_HASH" echo "key=$CACHE_KEY" >> $GITHUB_OUTPUT - name: Restore AVD cache uses: actions/cache/restore@v4 id: avd-cache - if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) }} + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled }} with: path: | ~/.android/avd ~/.android/adb* key: ${{ steps.avd-key.outputs.key }} - - name: Create AVD and generate snapshot for caching - if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }} - arch: ${{ steps.arch.outputs.arch }} - profile: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.profile }} - disk-size: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.diskSize }} - heap-size: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.heapSize }} - force-avd-creation: false - avd-name: ${{ fromJson(steps.load-config.outputs.config).config.device.name }} - disable-animations: true - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - script: echo "Generated AVD snapshot for caching." - name: Save AVD cache - if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} + if: ${{ always() && fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled && steps.avd-cache.outputs.cache-hit != 'true' }} uses: actions/cache/save@v4 with: path: | @@ -218,6 +205,7 @@ runs: AFTER_RUN_HOOK: ${{ inputs.afterRunHook }} HARNESS_RUNNER: ${{ inputs.runner }} HARNESS_APP_PATH: ${{ inputs.app }} + HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }} run: | export HARNESS_PROJECT_ROOT="$PWD" diff --git a/actions/android/action.yml b/actions/android/action.yml index 4dc1ed2b..61282156 100644 --- a/actions/android/action.yml +++ b/actions/android/action.yml @@ -46,6 +46,7 @@ runs: env: INPUT_RUNNER: ${{ inputs.runner }} INPUT_PROJECTROOT: ${{ inputs.projectRoot }} + HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }} run: | node ${{ github.action_path }}/../shared/index.cjs - name: Verify Android config @@ -85,18 +86,18 @@ runs: sudo udevadm trigger --name-match=kvm ls /dev/kvm - name: Compute AVD cache key - if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) }} + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled }} id: avd-key shell: bash run: | - CONFIG='${{ steps.load-config.outputs.config }}' - AVD_CONFIG=$(echo "$CONFIG" | jq -c '.config.device.avd') - AVD_CONFIG_HASH=$(echo "$AVD_CONFIG" | sha256sum | cut -d' ' -f1) + CACHE_CONFIG='${{ toJson(fromJson(steps.load-config.outputs.config).action.avdCacheConfig) }}' + AVD_CONFIG_HASH=$(printf '%s' "$CACHE_CONFIG" | sha256sum | cut -d' ' -f1) + AVD_NAME='${{ fromJson(steps.load-config.outputs.config).config.device.name }}' ARCH="${{ steps.arch.outputs.arch }}" - CACHE_KEY="avd-$ARCH-$AVD_CONFIG_HASH" + CACHE_KEY="avd-$AVD_NAME-$ARCH-$AVD_CONFIG_HASH" echo "key=$CACHE_KEY" >> $GITHUB_OUTPUT - name: Restore AVD cache - if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) }} + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled }} uses: actions/cache/restore@v4 id: avd-cache with: @@ -104,22 +105,8 @@ runs: ~/.android/avd ~/.android/adb* key: ${{ steps.avd-key.outputs.key }} - - name: Create AVD and generate snapshot for caching - if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }} - arch: ${{ steps.arch.outputs.arch }} - profile: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.profile }} - disk-size: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.diskSize }} - heap-size: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.heapSize }} - force-avd-creation: false - avd-name: ${{ fromJson(steps.load-config.outputs.config).config.device.name }} - disable-animations: true - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - script: echo "Generated AVD snapshot for caching." - name: Save AVD cache - if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} + if: ${{ always() && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled && steps.avd-cache.outputs.cache-hit != 'true' }} uses: actions/cache/save@v4 with: path: | @@ -155,6 +142,7 @@ runs: AFTER_RUN_HOOK: ${{ inputs.afterRunHook }} HARNESS_RUNNER: ${{ inputs.runner }} HARNESS_APP_PATH: ${{ inputs.app }} + HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }} run: | export HARNESS_PROJECT_ROOT="$PWD" diff --git a/packages/github-action/src/action.yml b/packages/github-action/src/action.yml index c41aac43..74769b2c 100644 --- a/packages/github-action/src/action.yml +++ b/packages/github-action/src/action.yml @@ -52,6 +52,7 @@ runs: env: INPUT_RUNNER: ${{ inputs.runner }} INPUT_PROJECTROOT: ${{ inputs.projectRoot }} + HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }} run: | node ${{ github.action_path }}/actions/shared/index.cjs - name: Verify native app input @@ -112,40 +113,26 @@ runs: ls /dev/kvm - name: Compute AVD cache key id: avd-key - if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) }} + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled }} shell: bash run: | - CONFIG='${{ steps.load-config.outputs.config }}' - AVD_CONFIG=$(echo "$CONFIG" | jq -c '.config.device.avd') - AVD_CONFIG_HASH=$(echo "$AVD_CONFIG" | sha256sum | cut -d' ' -f1) + CACHE_CONFIG='${{ toJson(fromJson(steps.load-config.outputs.config).action.avdCacheConfig) }}' + AVD_CONFIG_HASH=$(printf '%s' "$CACHE_CONFIG" | sha256sum | cut -d' ' -f1) + AVD_NAME='${{ fromJson(steps.load-config.outputs.config).config.device.name }}' ARCH="${{ steps.arch.outputs.arch }}" - CACHE_KEY="avd-$ARCH-$AVD_CONFIG_HASH" + CACHE_KEY="avd-$AVD_NAME-$ARCH-$AVD_CONFIG_HASH" echo "key=$CACHE_KEY" >> $GITHUB_OUTPUT - name: Restore AVD cache uses: actions/cache/restore@v4 id: avd-cache - if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) }} + if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled }} with: path: | ~/.android/avd ~/.android/adb* key: ${{ steps.avd-key.outputs.key }} - - name: Create AVD and generate snapshot for caching - if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }} - arch: ${{ steps.arch.outputs.arch }} - profile: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.profile }} - disk-size: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.diskSize }} - heap-size: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.heapSize }} - force-avd-creation: false - avd-name: ${{ fromJson(steps.load-config.outputs.config).config.device.name }} - disable-animations: true - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - script: echo "Generated AVD snapshot for caching." - name: Save AVD cache - if: ${{ fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} + if: ${{ always() && fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled && steps.avd-cache.outputs.cache-hit != 'true' }} uses: actions/cache/save@v4 with: path: | @@ -218,6 +205,7 @@ runs: AFTER_RUN_HOOK: ${{ inputs.afterRunHook }} HARNESS_RUNNER: ${{ inputs.runner }} HARNESS_APP_PATH: ${{ inputs.app }} + HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }} run: | export HARNESS_PROJECT_ROOT="$PWD" diff --git a/packages/github-action/src/android/action.yml b/packages/github-action/src/android/action.yml index 4dc1ed2b..61282156 100644 --- a/packages/github-action/src/android/action.yml +++ b/packages/github-action/src/android/action.yml @@ -46,6 +46,7 @@ runs: env: INPUT_RUNNER: ${{ inputs.runner }} INPUT_PROJECTROOT: ${{ inputs.projectRoot }} + HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }} run: | node ${{ github.action_path }}/../shared/index.cjs - name: Verify Android config @@ -85,18 +86,18 @@ runs: sudo udevadm trigger --name-match=kvm ls /dev/kvm - name: Compute AVD cache key - if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) }} + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled }} id: avd-key shell: bash run: | - CONFIG='${{ steps.load-config.outputs.config }}' - AVD_CONFIG=$(echo "$CONFIG" | jq -c '.config.device.avd') - AVD_CONFIG_HASH=$(echo "$AVD_CONFIG" | sha256sum | cut -d' ' -f1) + CACHE_CONFIG='${{ toJson(fromJson(steps.load-config.outputs.config).action.avdCacheConfig) }}' + AVD_CONFIG_HASH=$(printf '%s' "$CACHE_CONFIG" | sha256sum | cut -d' ' -f1) + AVD_NAME='${{ fromJson(steps.load-config.outputs.config).config.device.name }}' ARCH="${{ steps.arch.outputs.arch }}" - CACHE_KEY="avd-$ARCH-$AVD_CONFIG_HASH" + CACHE_KEY="avd-$AVD_NAME-$ARCH-$AVD_CONFIG_HASH" echo "key=$CACHE_KEY" >> $GITHUB_OUTPUT - name: Restore AVD cache - if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) }} + if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled }} uses: actions/cache/restore@v4 id: avd-cache with: @@ -104,22 +105,8 @@ runs: ~/.android/avd ~/.android/adb* key: ${{ steps.avd-key.outputs.key }} - - name: Create AVD and generate snapshot for caching - if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.apiLevel }} - arch: ${{ steps.arch.outputs.arch }} - profile: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.profile }} - disk-size: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.diskSize }} - heap-size: ${{ fromJson(steps.load-config.outputs.config).config.device.avd.heapSize }} - force-avd-creation: false - avd-name: ${{ fromJson(steps.load-config.outputs.config).config.device.name }} - disable-animations: true - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - script: echo "Generated AVD snapshot for caching." - name: Save AVD cache - if: ${{ fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJSON(inputs.cacheAvd) && steps.avd-cache.outputs.cache-hit != 'true' }} + if: ${{ always() && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled && steps.avd-cache.outputs.cache-hit != 'true' }} uses: actions/cache/save@v4 with: path: | @@ -155,6 +142,7 @@ runs: AFTER_RUN_HOOK: ${{ inputs.afterRunHook }} HARNESS_RUNNER: ${{ inputs.runner }} HARNESS_APP_PATH: ${{ inputs.app }} + HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }} run: | export HARNESS_PROJECT_ROOT="$PWD" diff --git a/packages/github-action/src/shared/index.ts b/packages/github-action/src/shared/index.ts index 46e4d2d3..48827834 100644 --- a/packages/github-action/src/shared/index.ts +++ b/packages/github-action/src/shared/index.ts @@ -2,6 +2,97 @@ import { getConfig } from '@react-native-harness/config'; import path from 'node:path'; import fs from 'node:fs'; +const getHostAndroidSystemImageArch = (): + | 'x86_64' + | 'arm64-v8a' + | 'armeabi-v7a' => { + switch (process.arch) { + case 'arm64': + return 'arm64-v8a'; + case 'arm': + return 'armeabi-v7a'; + case 'x64': + default: + return 'x86_64'; + } +}; + +const resolveAvdCachingEnabled = ({ + snapshotEnabled, +}: { + snapshotEnabled?: boolean; +}): boolean => { + const override = process.env.HARNESS_AVD_CACHING; + const requestedValue = + override == null ? snapshotEnabled : override.toLowerCase() === 'true'; + + return requestedValue === true; +}; + +const getNormalizedAvdCacheConfig = ({ + emulator, + hostArch, +}: { + emulator: { + name: string; + avd?: { + apiLevel: number; + profile: string; + diskSize: string; + heapSize: string; + }; + }; + hostArch: 'x86_64' | 'arm64-v8a' | 'armeabi-v7a'; +}) => { + const avd = emulator.avd; + + if (!avd) { + return null; + } + + return { + name: emulator.name, + apiLevel: avd.apiLevel, + arch: hostArch, + profile: avd.profile.trim().toLowerCase(), + diskSize: avd.diskSize.trim().toLowerCase(), + heapSize: avd.heapSize.trim().toLowerCase(), + }; +}; + +const getResolvedRunner = ( + runner: Awaited>['config']['runners'][number] +) => { + if ( + runner.platformId !== 'android' || + runner.config.device.type !== 'emulator' + ) { + return runner; + } + + const avdCachingEnabled = resolveAvdCachingEnabled({ + snapshotEnabled: runner.config.device.avd?.snapshot?.enabled, + }); + + return { + ...runner, + config: { + ...runner.config, + device: { + ...runner.config.device, + avd: runner.config.device.avd, + }, + }, + action: { + avdCachingEnabled, + avdCacheConfig: getNormalizedAvdCacheConfig({ + emulator: runner.config.device, + hostArch: getHostAndroidSystemImageArch(), + }), + }, + }; +}; + const run = async (): Promise => { try { const projectRootInput = process.env.INPUT_PROJECTROOT; @@ -17,8 +108,9 @@ const run = async (): Promise => { console.info(`Loading React Native Harness config from: ${projectRoot}`); - const { config, projectRoot: resolvedProjectRoot } = - await getConfig(projectRoot); + const { config, projectRoot: resolvedProjectRoot } = await getConfig( + projectRoot + ); const runner = config.runners.find((runner) => runner.name === runnerInput); @@ -31,9 +123,12 @@ const run = async (): Promise => { throw new Error('GITHUB_OUTPUT environment variable is not set'); } + const resolvedRunner = getResolvedRunner(runner); const relativeProjectRoot = path.relative(process.cwd(), resolvedProjectRoot) || '.'; - const output = `config=${JSON.stringify(runner)}\nprojectRoot=${relativeProjectRoot}\n`; + const output = `config=${JSON.stringify( + resolvedRunner + )}\nprojectRoot=${relativeProjectRoot}\n`; fs.appendFileSync(githubOutput, output); } catch (error) { if (error instanceof Error) { diff --git a/packages/platform-android/src/__tests__/adb.test.ts b/packages/platform-android/src/__tests__/adb.test.ts index 8bcd421c..b8c2ad1b 100644 --- a/packages/platform-android/src/__tests__/adb.test.ts +++ b/packages/platform-android/src/__tests__/adb.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { SubprocessError } from '@react-native-harness/tools'; import { createAvd, + deleteAvd, emulatorProcess, getAppUid, getLogcatTimestamp, @@ -210,6 +211,25 @@ describe('getStartAppArgs', () => { ]); }); + it('deletes both AVD directory and ini file', async () => { + const rm = vi + .spyOn(await import('node:fs/promises'), 'rm') + .mockResolvedValue(undefined); + + await deleteAvd('Pixel_8_API_35'); + + expect(rm).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('/Pixel_8_API_35.avd'), + { force: true, recursive: true } + ); + expect(rm).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('/Pixel_8_API_35.ini'), + { force: true } + ); + }); + it('surfaces emulator stdout when startup fails immediately', async () => { const child = createMockChildProcess(); let launcherReadyResolve: (() => void) | undefined; @@ -297,6 +317,99 @@ describe('getStartAppArgs', () => { expect(child.unref).toHaveBeenCalled(); }); + it('passes default boot args to the emulator process', async () => { + vi.useFakeTimers(); + const child = createMockChildProcess(); + vi.spyOn(tools, 'spawn') + .mockResolvedValueOnce({ + stdout: 'List of devices attached\nemulator-5554\tdevice\n', + } as Awaited>) + .mockResolvedValueOnce({ + stdout: 'Pixel_8_API_35\n', + } as Awaited>); + const startDetachedProcess = vi + .spyOn(emulatorProcess, 'startDetachedProcess') + .mockReturnValue( + child as unknown as ReturnType< + typeof emulatorProcess.startDetachedProcess + > + ); + + const startPromise = startEmulator('Pixel_8_API_35'); + await vi.runAllTimersAsync(); + await startPromise; + + expect(startDetachedProcess).toHaveBeenCalledWith( + expect.stringMatching(/emulator$/), + expect.arrayContaining(['-no-snapshot-load', '-no-snapshot-save']) + ); + }); + + it('passes clean snapshot generation args to the emulator process', async () => { + vi.useFakeTimers(); + const child = createMockChildProcess(); + vi.spyOn(tools, 'spawn') + .mockResolvedValueOnce({ + stdout: 'List of devices attached\nemulator-5554\tdevice\n', + } as Awaited>) + .mockResolvedValueOnce({ + stdout: 'Pixel_8_API_35\n', + } as Awaited>); + const startDetachedProcess = vi + .spyOn(emulatorProcess, 'startDetachedProcess') + .mockReturnValue( + child as unknown as ReturnType< + typeof emulatorProcess.startDetachedProcess + > + ); + + const startPromise = startEmulator( + 'Pixel_8_API_35', + 'clean-snapshot-generation' + ); + await vi.runAllTimersAsync(); + await startPromise; + + expect(startDetachedProcess).toHaveBeenCalledWith( + expect.stringMatching(/emulator$/), + expect.arrayContaining(['-no-snapshot-load']) + ); + expect(startDetachedProcess.mock.calls[0]?.[1]).not.toContain( + '-no-snapshot-save' + ); + }); + + it('passes snapshot reuse args to the emulator process', async () => { + vi.useFakeTimers(); + const child = createMockChildProcess(); + vi.spyOn(tools, 'spawn') + .mockResolvedValueOnce({ + stdout: 'List of devices attached\nemulator-5554\tdevice\n', + } as Awaited>) + .mockResolvedValueOnce({ + stdout: 'Pixel_8_API_35\n', + } as Awaited>); + const startDetachedProcess = vi + .spyOn(emulatorProcess, 'startDetachedProcess') + .mockReturnValue( + child as unknown as ReturnType< + typeof emulatorProcess.startDetachedProcess + > + ); + + const startPromise = startEmulator('Pixel_8_API_35', 'snapshot-reuse'); + await vi.runAllTimersAsync(); + await startPromise; + + expect(startDetachedProcess).toHaveBeenCalledWith( + expect.stringMatching(/emulator$/), + expect.arrayContaining(['-no-snapshot-save']) + ); + expect(startDetachedProcess.mock.calls[0]?.[1]).not.toContain( + '-no-snapshot-load' + ); + }); + it('aborts while waiting for an emulator to appear', async () => { vi.useFakeTimers(); vi.spyOn(tools, 'spawn').mockResolvedValue({ diff --git a/packages/platform-android/src/__tests__/avd-config.test.ts b/packages/platform-android/src/__tests__/avd-config.test.ts new file mode 100644 index 00000000..54d10790 --- /dev/null +++ b/packages/platform-android/src/__tests__/avd-config.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; +import { + getNormalizedAvdCacheConfig, + isAvdCompatible, + parseAvdConfig, + resolveAvdCachingEnabled, +} from '../avd-config.js'; +import { AndroidPlatformConfigSchema } from '../config.js'; + +describe('AVD config helpers', () => { + it('parses snapshot config from Android schema', () => { + const config = AndroidPlatformConfigSchema.parse({ + name: 'android', + bundleId: 'com.example.app', + device: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '512M', + snapshot: { + enabled: true, + }, + }, + }, + }); + + expect(config.device.type).toBe('emulator'); + if (config.device.type === 'emulator') { + expect(config.device.avd?.snapshot?.enabled).toBe(true); + } + }); + + it('lets HARNESS_AVD_CACHING override config before interactive gating', () => { + expect( + resolveAvdCachingEnabled({ + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + snapshot: { enabled: false }, + }, + isInteractive: false, + env: { + HARNESS_AVD_CACHING: 'true', + }, + }) + ).toBe(true); + }); + + it('disables caching for interactive sessions even when requested', () => { + expect( + resolveAvdCachingEnabled({ + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + snapshot: { enabled: true }, + }, + isInteractive: true, + }) + ).toBe(false); + }); + + it('parses config.ini and matches compatible AVD metadata', () => { + const avdConfig = parseAvdConfig(` +image.sysdir.1=system-images/android-35/default/x86_64/ +abi.type=x86_64 +hw.device.name=pixel_8 +disk.dataPartition.size=1G +vm.heapSize=512M +`); + + expect( + isAvdCompatible({ + emulator: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '512M', + }, + }, + avdConfig, + hostArch: 'x86_64', + }) + ).toEqual({ compatible: true }); + }); + + it('reports incompatibility when AVD metadata differs', () => { + const avdConfig = parseAvdConfig(` +image.sysdir.1=system-images/android-34/default/x86_64/ +abi.type=x86_64 +hw.device.name=pixel_7 +disk.dataPartition.size=2G +vm.heapSize=1G +`); + + expect( + isAvdCompatible({ + emulator: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '512M', + }, + }, + avdConfig, + hostArch: 'x86_64', + }) + ).toMatchObject({ + compatible: false, + }); + }); + + it('normalizes AVD cache key input with name and host arch', () => { + expect( + getNormalizedAvdCacheConfig({ + emulator: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: ' Pixel_8 ', + diskSize: '1G', + heapSize: '512M', + }, + }, + hostArch: 'arm64-v8a', + }) + ).toEqual({ + name: 'Pixel_8_API_35', + apiLevel: 35, + arch: 'arm64-v8a', + profile: 'pixel_8', + diskSize: '1g', + heapSize: '512m', + }); + }); +}); diff --git a/packages/platform-android/src/__tests__/ci-action.test.ts b/packages/platform-android/src/__tests__/ci-action.test.ts index 23abec59..fdd6098a 100644 --- a/packages/platform-android/src/__tests__/ci-action.test.ts +++ b/packages/platform-android/src/__tests__/ci-action.test.ts @@ -21,4 +21,45 @@ describe('Android GitHub action config', () => { ); } }); + + it('removes the third-party emulator runner and maps cacheAvd to HARNESS_AVD_CACHING', async () => { + const [rootAction, packageAction] = await Promise.all([ + readFile(path.join(workspaceRoot, 'action.yml'), 'utf8'), + readFile( + path.join(workspaceRoot, 'packages/github-action/src/action.yml'), + 'utf8' + ), + ]); + + for (const actionYaml of [rootAction, packageAction]) { + expect(actionYaml).not.toContain( + 'reactivecircus/android-emulator-runner' + ); + expect(actionYaml).toContain( + 'HARNESS_AVD_CACHING: ${{ inputs.cacheAvd }}' + ); + expect(actionYaml).toContain( + 'fromJson(steps.load-config.outputs.config).action.avdCachingEnabled' + ); + } + }); + + it('uses a cache key that includes the emulator name', async () => { + const [rootAction, packageAction] = await Promise.all([ + readFile(path.join(workspaceRoot, 'action.yml'), 'utf8'), + readFile( + path.join(workspaceRoot, 'packages/github-action/src/action.yml'), + 'utf8' + ), + ]); + + for (const actionYaml of [rootAction, packageAction]) { + expect(actionYaml).toContain( + "AVD_NAME='${{ fromJson(steps.load-config.outputs.config).config.device.name }}'" + ); + expect(actionYaml).toContain( + 'CACHE_KEY="avd-$AVD_NAME-$ARCH-$AVD_CONFIG_HASH"' + ); + } + }); }); diff --git a/packages/platform-android/src/__tests__/emulator-startup.test.ts b/packages/platform-android/src/__tests__/emulator-startup.test.ts new file mode 100644 index 00000000..790ccca9 --- /dev/null +++ b/packages/platform-android/src/__tests__/emulator-startup.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { getEmulatorStartupArgs } from '../emulator-startup.js'; + +describe('emulator startup modes', () => { + it('builds default boot args', () => { + expect(getEmulatorStartupArgs('Pixel_8_API_35', 'default-boot')).toEqual( + expect.arrayContaining([ + '@Pixel_8_API_35', + '-no-snapshot-load', + '-no-snapshot-save', + ]) + ); + }); + + it('builds clean snapshot generation args', () => { + expect( + getEmulatorStartupArgs('Pixel_8_API_35', 'clean-snapshot-generation') + ).toEqual(expect.arrayContaining(['@Pixel_8_API_35', '-no-snapshot-load'])); + expect( + getEmulatorStartupArgs('Pixel_8_API_35', 'clean-snapshot-generation') + ).not.toContain('-no-snapshot-save'); + }); + + it('builds snapshot reuse args', () => { + expect(getEmulatorStartupArgs('Pixel_8_API_35', 'snapshot-reuse')).toEqual( + expect.arrayContaining(['@Pixel_8_API_35', '-no-snapshot-save']) + ); + expect( + getEmulatorStartupArgs('Pixel_8_API_35', 'snapshot-reuse') + ).not.toContain('-no-snapshot-load'); + }); +}); diff --git a/packages/platform-android/src/__tests__/instance.test.ts b/packages/platform-android/src/__tests__/instance.test.ts index b3621df3..d610b964 100644 --- a/packages/platform-android/src/__tests__/instance.test.ts +++ b/packages/platform-android/src/__tests__/instance.test.ts @@ -11,6 +11,7 @@ import { getAndroidPhysicalDevicePlatformInstance, } from '../instance.js'; import * as adb from '../adb.js'; +import * as avdConfig from '../avd-config.js'; import * as sharedPrefs from '../shared-prefs.js'; import { HarnessAppPathError, HarnessEmulatorConfigError } from '../errors.js'; @@ -129,7 +130,7 @@ describe('Android platform instance', () => { diskSize: '1G', heapSize: '1G', }); - expect(startEmulator).toHaveBeenCalledWith('Pixel_8_API_35'); + expect(startEmulator).toHaveBeenCalledWith('Pixel_8_API_35', undefined); await instance.dispose(); @@ -183,7 +184,189 @@ describe('Android platform instance', () => { expect(ensureAndroidEmulatorEnvironment).toHaveBeenCalledWith(35); expect(createAvd).not.toHaveBeenCalled(); - expect(startEmulator).toHaveBeenCalledWith('Pixel_8_API_35'); + expect(startEmulator).toHaveBeenCalledWith('Pixel_8_API_35', undefined); + }); + + it('reuses a compatible cached AVD snapshot when caching is enabled', async () => { + vi.stubEnv('HARNESS_AVD_CACHING', 'true'); + vi.spyOn( + await import('../environment.js'), + 'ensureAndroidEmulatorEnvironment' + ).mockResolvedValue('/tmp/android-sdk'); + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue([]); + vi.spyOn(adb, 'hasAvd').mockResolvedValue(true); + vi.spyOn(avdConfig, 'readAvdConfig').mockResolvedValue({ + imageSysdir1: 'system-images/android-35/default/x86_64/', + abiType: 'x86_64', + hwDeviceName: 'pixel_8', + diskDataPartitionSize: '1G', + vmHeapSize: '1G', + }); + vi.spyOn(adb, 'startEmulator').mockResolvedValue(undefined); + vi.spyOn(adb, 'waitForEmulator').mockResolvedValue('emulator-5554'); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue(undefined); + vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); + vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); + vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( + undefined + ); + + await expect( + getAndroidEmulatorPlatformInstance( + { + name: 'android', + device: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + snapshot: { enabled: false }, + }, + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfig, + init + ) + ).resolves.toBeDefined(); + + expect(adb.startEmulator).toHaveBeenCalledTimes(1); + expect(adb.startEmulator).toHaveBeenCalledWith( + 'Pixel_8_API_35', + 'snapshot-reuse' + ); + }); + + it('recreates an incompatible cached AVD before the real boot', async () => { + vi.stubEnv('HARNESS_AVD_CACHING', 'true'); + vi.spyOn( + await import('../environment.js'), + 'ensureAndroidEmulatorEnvironment' + ).mockResolvedValue('/tmp/android-sdk'); + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue([]); + vi.spyOn(adb, 'hasAvd').mockResolvedValue(true); + vi.spyOn(avdConfig, 'readAvdConfig').mockResolvedValue({ + imageSysdir1: 'system-images/android-34/default/x86_64/', + abiType: 'x86_64', + hwDeviceName: 'pixel_7', + diskDataPartitionSize: '2G', + vmHeapSize: '2G', + }); + const deleteAvd = vi.spyOn(adb, 'deleteAvd').mockResolvedValue(undefined); + const createAvd = vi.spyOn(adb, 'createAvd').mockResolvedValue(undefined); + vi.spyOn(adb, 'startEmulator').mockResolvedValue(undefined); + vi.spyOn(adb, 'waitForEmulator').mockResolvedValue('emulator-5554'); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue(undefined); + const stopEmulator = vi.spyOn(adb, 'stopEmulator').mockResolvedValue(); + vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); + vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); + vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( + undefined + ); + + await expect( + getAndroidEmulatorPlatformInstance( + { + name: 'android', + device: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + snapshot: { enabled: true }, + }, + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfig, + init + ) + ).resolves.toBeDefined(); + + expect(deleteAvd).toHaveBeenCalledWith('Pixel_8_API_35'); + expect(createAvd).toHaveBeenCalled(); + expect(stopEmulator).toHaveBeenCalledWith('emulator-5554'); + expect(adb.startEmulator).toHaveBeenNthCalledWith( + 1, + 'Pixel_8_API_35', + 'clean-snapshot-generation' + ); + expect(adb.startEmulator).toHaveBeenNthCalledWith( + 2, + 'Pixel_8_API_35', + 'default-boot' + ); + }); + + it('generates a snapshot on first run before the test boot', async () => { + vi.stubEnv('HARNESS_AVD_CACHING', 'true'); + vi.spyOn( + await import('../environment.js'), + 'ensureAndroidEmulatorEnvironment' + ).mockResolvedValue('/tmp/android-sdk'); + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue([]); + vi.spyOn(adb, 'hasAvd').mockResolvedValue(false); + vi.spyOn(adb, 'createAvd').mockResolvedValue(undefined); + const startEmulator = vi + .spyOn(adb, 'startEmulator') + .mockResolvedValue(undefined); + vi.spyOn(adb, 'waitForEmulator').mockResolvedValue('emulator-5554'); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue(undefined); + const stopEmulator = vi.spyOn(adb, 'stopEmulator').mockResolvedValue(); + vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); + vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); + vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( + undefined + ); + + await expect( + getAndroidEmulatorPlatformInstance( + { + name: 'android', + device: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + snapshot: { enabled: true }, + }, + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfig, + init + ) + ).resolves.toBeDefined(); + + expect(startEmulator).toHaveBeenNthCalledWith( + 1, + 'Pixel_8_API_35', + 'clean-snapshot-generation' + ); + expect(stopEmulator).toHaveBeenCalledWith('emulator-5554'); + expect(startEmulator).toHaveBeenNthCalledWith( + 2, + 'Pixel_8_API_35', + 'default-boot' + ); }); it('installs the app from HARNESS_APP_PATH when missing', async () => { diff --git a/packages/platform-android/src/adb.ts b/packages/platform-android/src/adb.ts index 6735432a..e69f51be 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -2,7 +2,7 @@ import { type AndroidAppLaunchOptions } from '@react-native-harness/platforms'; import { spawn, SubprocessError } from '@react-native-harness/tools'; import { spawn as nodeSpawn } from 'node:child_process'; import type { ChildProcessByStdio } from 'node:child_process'; -import { access } from 'node:fs/promises'; +import { access, rm } from 'node:fs/promises'; import type { Readable } from 'node:stream'; import { ensureAndroidSdkPackages, @@ -14,6 +14,10 @@ import { getRequiredAndroidSdkPackages, getSdkManagerBinaryPath, } from './environment.js'; +import { + getEmulatorStartupArgs, + type EmulatorBootMode, +} from './emulator-startup.js'; const wait = async (ms: number): Promise => { await new Promise((resolve) => { @@ -367,21 +371,34 @@ export const createAvd = async ({ ]); }; -export const startEmulator = async (name: string): Promise => { +export const deleteAvd = async (name: string): Promise => { + await rm( + `${ + process.env.ANDROID_AVD_HOME ?? `${process.env.HOME}/.android/avd` + }/${name}.avd`, + { + force: true, + recursive: true, + } + ); + await rm( + `${ + process.env.ANDROID_AVD_HOME ?? `${process.env.HOME}/.android/avd` + }/${name}.ini`, + { + force: true, + } + ); +}; + +export const startEmulator = async ( + name: string, + mode: EmulatorBootMode = 'default-boot' +): Promise => { const emulatorBinaryPath = await ensureEmulatorInstalled(); const childProcess = emulatorProcess.startDetachedProcess( emulatorBinaryPath, - [ - `@${name}`, - '-no-snapshot-save', - '-no-window', - '-gpu', - 'swiftshader_indirect', - '-noaudio', - '-no-boot-anim', - '-camera-back', - 'none', - ] + getEmulatorStartupArgs(name, mode) ); let stdout = ''; diff --git a/packages/platform-android/src/avd-config.ts b/packages/platform-android/src/avd-config.ts new file mode 100644 index 00000000..1b3219cb --- /dev/null +++ b/packages/platform-android/src/avd-config.ts @@ -0,0 +1,241 @@ +import { access, readFile } from 'node:fs/promises'; +import type { AndroidSystemImageArch } from './environment.js'; +import type { AndroidEmulator, AndroidEmulatorAVDConfig } from './config.js'; + +export type AvdConfig = { + imageSysdir1?: string; + abiType?: string; + hwDeviceName?: string; + diskDataPartitionSize?: string; + vmHeapSize?: string; +}; + +export type AvdCompatibilityResult = + | { compatible: true } + | { compatible: false; reason: string }; + +export const getAvdDirectory = (name: string): string => { + return `${ + process.env.ANDROID_AVD_HOME ?? `${process.env.HOME}/.android/avd` + }/${name}.avd`; +}; + +export const getAvdConfigPath = (name: string): string => { + return `${getAvdDirectory(name)}/config.ini`; +}; + +const normalizeAvdValue = (value: string | undefined): string | undefined => { + if (!value) { + return undefined; + } + + return value.trim(); +}; + +const normalizeConfigValue = (value: string): string => { + return value.trim().toLowerCase(); +}; + +const getApiLevelFromImageSysdir = ( + value: string | undefined +): number | null => { + const match = value?.match(/android-(\d+)/i); + return match ? Number(match[1]) : null; +}; + +const normalizeProfile = (value: string | undefined): string | undefined => { + if (!value) { + return undefined; + } + + return value + .trim() + .replace(/[\r\n]+/g, ' ') + .toLowerCase(); +}; + +export const parseAvdConfig = (contents: string): AvdConfig => { + const config: AvdConfig = {}; + + for (const line of contents.split(/\r?\n/)) { + const trimmedLine = line.trim(); + + if (trimmedLine === '' || trimmedLine.startsWith('#')) { + continue; + } + + const separatorIndex = trimmedLine.indexOf('='); + + if (separatorIndex === -1) { + continue; + } + + const key = trimmedLine.slice(0, separatorIndex).trim(); + const value = trimmedLine.slice(separatorIndex + 1).trim(); + + switch (key) { + case 'image.sysdir.1': + config.imageSysdir1 = value; + break; + case 'abi.type': + config.abiType = value; + break; + case 'hw.device.name': + config.hwDeviceName = value; + break; + case 'disk.dataPartition.size': + config.diskDataPartitionSize = value; + break; + case 'vm.heapSize': + config.vmHeapSize = value; + break; + default: + break; + } + } + + return config; +}; + +export const readAvdConfig = async ( + name: string +): Promise => { + const configPath = getAvdConfigPath(name); + + try { + await access(configPath); + } catch { + return null; + } + + return parseAvdConfig(await readFile(configPath, 'utf8')); +}; + +export const isAvdCompatible = ({ + emulator, + avdConfig, + hostArch, +}: { + emulator: AndroidEmulator; + avdConfig: AvdConfig; + hostArch: AndroidSystemImageArch; +}): AvdCompatibilityResult => { + const requestedAvdConfig = emulator.avd; + + if (!requestedAvdConfig) { + return { compatible: false, reason: 'AVD config is required.' }; + } + + if (emulator.name.trim() === '') { + return { compatible: false, reason: 'AVD name is required.' }; + } + + const apiLevel = getApiLevelFromImageSysdir(avdConfig.imageSysdir1); + + if (apiLevel !== requestedAvdConfig.apiLevel) { + return { + compatible: false, + reason: `API level mismatch: expected ${ + requestedAvdConfig.apiLevel + }, got ${apiLevel ?? 'missing'}.`, + }; + } + + if (normalizeAvdValue(avdConfig.abiType) !== hostArch) { + return { + compatible: false, + reason: `ABI mismatch: expected ${hostArch}, got ${ + normalizeAvdValue(avdConfig.abiType) ?? 'missing' + }.`, + }; + } + + if ( + normalizeProfile(avdConfig.hwDeviceName) !== + normalizeProfile(requestedAvdConfig.profile) + ) { + return { + compatible: false, + reason: `Profile mismatch: expected ${requestedAvdConfig.profile}, got ${ + avdConfig.hwDeviceName ?? 'missing' + }.`, + }; + } + + if ( + normalizeConfigValue(avdConfig.diskDataPartitionSize ?? '') !== + normalizeConfigValue(requestedAvdConfig.diskSize) + ) { + return { + compatible: false, + reason: `Disk size mismatch: expected ${ + requestedAvdConfig.diskSize + }, got ${avdConfig.diskDataPartitionSize ?? 'missing'}.`, + }; + } + + if ( + normalizeConfigValue(avdConfig.vmHeapSize ?? '') !== + normalizeConfigValue(requestedAvdConfig.heapSize) + ) { + return { + compatible: false, + reason: `Heap size mismatch: expected ${ + requestedAvdConfig.heapSize + }, got ${avdConfig.vmHeapSize ?? 'missing'}.`, + }; + } + + return { compatible: true }; +}; + +export const getNormalizedAvdCacheConfig = ({ + emulator, + hostArch, +}: { + emulator: AndroidEmulator; + hostArch: AndroidSystemImageArch; +}): { + name: string; + apiLevel: number; + arch: AndroidSystemImageArch; + profile: string; + diskSize: string; + heapSize: string; +} | null => { + const avd = emulator.avd; + + if (!avd) { + return null; + } + + return { + name: emulator.name, + apiLevel: avd.apiLevel, + arch: hostArch, + profile: avd.profile.trim().toLowerCase(), + diskSize: avd.diskSize.trim().toLowerCase(), + heapSize: avd.heapSize.trim().toLowerCase(), + }; +}; + +export const resolveAvdCachingEnabled = ({ + avd, + isInteractive, + env = process.env, +}: { + avd?: AndroidEmulatorAVDConfig; + isInteractive: boolean; + env?: NodeJS.ProcessEnv; +}): boolean => { + const override = env.HARNESS_AVD_CACHING; + const configValue = avd?.snapshot?.enabled; + const requestedValue = + override == null ? configValue : override.toLowerCase() === 'true'; + + if (!requestedValue) { + return false; + } + + return !isInteractive; +}; diff --git a/packages/platform-android/src/config.ts b/packages/platform-android/src/config.ts index 71f8b5fa..7e5f281b 100644 --- a/packages/platform-android/src/config.ts +++ b/packages/platform-android/src/config.ts @@ -11,6 +11,11 @@ export const AndroidEmulatorAVDConfigSchema = z.object({ profile: z.string().min(1, 'Profile is required'), diskSize: z.string().min(1, 'Disk size is required').default('1G'), heapSize: z.string().min(1, 'Heap size is required').default('1G'), + snapshot: z + .object({ + enabled: z.boolean().optional(), + }) + .optional(), }); export const AndroidEmulatorSchema = z.object({ @@ -51,6 +56,9 @@ export type AndroidAppLaunchOptions = z.infer< export type AndroidEmulatorAVDConfig = z.infer< typeof AndroidEmulatorAVDConfigSchema >; +export type AndroidEmulatorAVDSnapshotConfig = NonNullable< + AndroidEmulatorAVDConfig['snapshot'] +>; export const isAndroidDeviceEmulator = ( device: AndroidDevice diff --git a/packages/platform-android/src/emulator-startup.ts b/packages/platform-android/src/emulator-startup.ts new file mode 100644 index 00000000..d18e5e0d --- /dev/null +++ b/packages/platform-android/src/emulator-startup.ts @@ -0,0 +1,28 @@ +export type EmulatorBootMode = + | 'default-boot' + | 'clean-snapshot-generation' + | 'snapshot-reuse'; + +const COMMON_EMULATOR_ARGS = [ + '-no-window', + '-gpu', + 'swiftshader_indirect', + '-noaudio', + '-no-boot-anim', + '-camera-back', + 'none', +] as const; + +export const getEmulatorStartupArgs = ( + name: string, + mode: EmulatorBootMode +): string[] => { + const modeArgs = + mode === 'clean-snapshot-generation' + ? ['-no-snapshot-load'] + : mode === 'snapshot-reuse' + ? ['-no-snapshot-save'] + : ['-no-snapshot-load', '-no-snapshot-save']; + + return [`@${name}`, ...modeArgs, ...COMMON_EMULATOR_ARGS]; +}; diff --git a/packages/platform-android/src/index.ts b/packages/platform-android/src/index.ts index 7f02c37f..1c750eec 100644 --- a/packages/platform-android/src/index.ts +++ b/packages/platform-android/src/index.ts @@ -4,5 +4,10 @@ export { androidPlatform, } from './factory.js'; export type { AndroidPlatformConfig } from './config.js'; +export { + getNormalizedAvdCacheConfig, + resolveAvdCachingEnabled, +} from './avd-config.js'; +export { getHostAndroidSystemImageArch } from './environment.js'; export { HarnessAppPathError, HarnessEmulatorConfigError } from './errors.js'; export { getRunTargets } from './targets.js'; diff --git a/packages/platform-android/src/instance.ts b/packages/platform-android/src/instance.ts index e5134202..4ae5ca5c 100644 --- a/packages/platform-android/src/instance.ts +++ b/packages/platform-android/src/instance.ts @@ -12,6 +12,11 @@ import { assertAndroidDeviceEmulator, assertAndroidDevicePhysical, } from './config.js'; +import { + isAvdCompatible, + readAvdConfig, + resolveAvdCachingEnabled, +} from './avd-config.js'; import { getAdbId } from './adb-id.js'; import * as adb from './adb.js'; import { @@ -21,7 +26,11 @@ import { import { getDeviceName } from './utils.js'; import { createAndroidAppMonitor } from './app-monitor.js'; import { HarnessAppPathError, HarnessEmulatorConfigError } from './errors.js'; -import { ensureAndroidEmulatorEnvironment } from './environment.js'; +import { + ensureAndroidEmulatorEnvironment, + getHostAndroidSystemImageArch, +} from './environment.js'; +import { isInteractive } from '../../tools/src/isInteractive.js'; import fs from 'node:fs'; const androidInstanceLogger = logger.child('android-instance'); @@ -57,75 +66,179 @@ const configureAndroidRuntime = async ( return adb.getAppUid(adbId, config.bundleId); }; +const startAndWaitForBoot = async ({ + emulatorName, + signal, + mode, +}: { + emulatorName: string; + signal: AbortSignal; + mode?: Parameters[1]; +}): Promise => { + await adb.startEmulator(emulatorName, mode); + const adbId = await adb.waitForEmulator(emulatorName, signal); + await adb.waitForBoot(adbId, signal); + return adbId; +}; + +const recreateAvd = async ({ + emulatorConfig, +}: { + emulatorConfig: Extract< + AndroidPlatformConfig['device'], + { type: 'emulator' } + >; +}): Promise => { + if (!emulatorConfig.avd) { + throw new HarnessEmulatorConfigError(emulatorConfig.name); + } + + await adb.createAvd({ + name: emulatorConfig.name, + apiLevel: emulatorConfig.avd.apiLevel, + profile: emulatorConfig.avd.profile, + diskSize: emulatorConfig.avd.diskSize, + heapSize: emulatorConfig.avd.heapSize, + }); +}; + +const prepareCachedAvd = async ({ + emulatorConfig, + signal, +}: { + emulatorConfig: Extract< + AndroidPlatformConfig['device'], + { type: 'emulator' } + >; + signal: AbortSignal; +}): Promise => { + const emulatorName = emulatorConfig.name; + const hostArch = getHostAndroidSystemImageArch(); + const hasExistingAvd = await adb.hasAvd(emulatorName); + const avdConfig = hasExistingAvd ? await readAvdConfig(emulatorName) : null; + const compatibility = + avdConfig == null + ? { compatible: false as const, reason: 'Missing AVD config.ini.' } + : isAvdCompatible({ + emulator: emulatorConfig, + avdConfig, + hostArch, + }); + + if (!hasExistingAvd || !compatibility.compatible) { + logger.info( + hasExistingAvd + ? 'Recreating incompatible Android emulator %s...' + : 'Creating Android emulator %s...', + emulatorName + ); + + if (hasExistingAvd && !compatibility.compatible) { + androidInstanceLogger.debug( + 'Android AVD %s is not reusable: %s', + emulatorName, + compatibility.reason + ); + await adb.deleteAvd(emulatorName); + } + + await recreateAvd({ emulatorConfig }); + + const generationAdbId = await startAndWaitForBoot({ + emulatorName, + signal, + mode: 'clean-snapshot-generation', + }); + + logger.info('Saving Android emulator snapshot for %s...', emulatorName); + await adb.stopEmulator(generationAdbId); + } else { + logger.info('Using cached Android emulator %s...', emulatorName); + } + + return startAndWaitForBoot({ + emulatorName, + signal, + mode: 'snapshot-reuse', + }); +}; + export const getAndroidEmulatorPlatformInstance = async ( config: AndroidPlatformConfig, harnessConfig: HarnessConfig, init: HarnessPlatformInitOptions ): Promise => { assertAndroidDeviceEmulator(config.device); - const emulatorName = config.device.name; + const emulatorConfig = config.device; + const emulatorName = emulatorConfig.name; + const avdConfig = emulatorConfig.avd; + const avdCachingEnabled = resolveAvdCachingEnabled({ + avd: avdConfig, + isInteractive: isInteractive(), + }); - let adbId = await getAdbId(config.device); + let adbId = await getAdbId(emulatorConfig); let startedByHarness = false; androidInstanceLogger.debug( 'resolved Android emulator %s with adb id %s', - config.device.name, + emulatorConfig.name, adbId ?? 'not-found' ); if (!adbId) { - const avdConfig = config.device.avd; - if (!avdConfig) { - throw new HarnessEmulatorConfigError(config.device.name); + throw new HarnessEmulatorConfigError(emulatorConfig.name); } await ensureAndroidEmulatorEnvironment(avdConfig.apiLevel); - if (!(await adb.hasAvd(config.device.name))) { - logger.info('Creating Android emulator %s...', emulatorName); - androidInstanceLogger.debug( - 'creating Android AVD %s before startup', - config.device.name - ); - await adb.createAvd({ - name: config.device.name, - apiLevel: avdConfig.apiLevel, - profile: avdConfig.profile, - diskSize: avdConfig.diskSize, - heapSize: avdConfig.heapSize, - }); - } else { - logger.info('Using existing Android emulator %s...', emulatorName); - } + adbId = avdCachingEnabled + ? await prepareCachedAvd({ + emulatorConfig, + signal: init.signal, + }) + : await (async () => { + if (!(await adb.hasAvd(emulatorConfig.name))) { + logger.info('Creating Android emulator %s...', emulatorName); + androidInstanceLogger.debug( + 'creating Android AVD %s before startup', + emulatorConfig.name + ); + await recreateAvd({ emulatorConfig }); + } else { + logger.info('Using existing Android emulator %s...', emulatorName); + } + + androidInstanceLogger.debug( + 'starting Android emulator %s', + emulatorConfig.name + ); + return startAndWaitForBoot({ + emulatorName: emulatorConfig.name, + signal: init.signal, + }); + })(); - androidInstanceLogger.debug( - 'starting Android emulator %s', - config.device.name - ); - await adb.startEmulator(config.device.name); - adbId = await adb.waitForEmulator(config.device.name, init.signal); startedByHarness = true; androidInstanceLogger.debug( 'Android emulator %s connected as %s', - config.device.name, + emulatorConfig.name, adbId ); - } else if (config.device.avd) { - await ensureAndroidEmulatorEnvironment(config.device.avd.apiLevel); + } else if (emulatorConfig.avd) { + await ensureAndroidEmulatorEnvironment(emulatorConfig.avd.apiLevel); } if (!adbId) { - throw new DeviceNotFoundError(getDeviceName(config.device)); + throw new DeviceNotFoundError(getDeviceName(emulatorConfig)); } androidInstanceLogger.debug( 'waiting for Android emulator %s to finish booting', adbId ); - await adb.waitForBoot(adbId, init.signal); const isInstalled = await adb.isAppInstalled(adbId, config.bundleId); From 3a4add1db44996d139ef312d00e9f3ca48d93f41 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 1 Apr 2026 19:16:19 +0200 Subject: [PATCH 23/26] fix: stabilize CI checks for Android snapshot caching --- actions/shared/index.cjs | 69 ++++++++++++++++++++++- packages/platform-android/src/instance.ts | 2 +- packages/tools/src/index.ts | 1 + website/package.json | 2 +- 4 files changed, 70 insertions(+), 4 deletions(-) diff --git a/actions/shared/index.cjs b/actions/shared/index.cjs index 9a28d933..d81a627c 100644 --- a/actions/shared/index.cjs +++ b/actions/shared/index.cjs @@ -4551,6 +4551,66 @@ var getConfig = async (dir) => { // src/shared/index.ts var import_node_path6 = __toESM(require("path")); var import_node_fs6 = __toESM(require("fs")); +var getHostAndroidSystemImageArch = () => { + switch (process.arch) { + case "arm64": + return "arm64-v8a"; + case "arm": + return "armeabi-v7a"; + case "x64": + default: + return "x86_64"; + } +}; +var resolveAvdCachingEnabled = ({ + snapshotEnabled +}) => { + const override = process.env.HARNESS_AVD_CACHING; + const requestedValue = override == null ? snapshotEnabled : override.toLowerCase() === "true"; + return requestedValue === true; +}; +var getNormalizedAvdCacheConfig = ({ + emulator, + hostArch +}) => { + const avd = emulator.avd; + if (!avd) { + return null; + } + return { + name: emulator.name, + apiLevel: avd.apiLevel, + arch: hostArch, + profile: avd.profile.trim().toLowerCase(), + diskSize: avd.diskSize.trim().toLowerCase(), + heapSize: avd.heapSize.trim().toLowerCase() + }; +}; +var getResolvedRunner = (runner) => { + if (runner.platformId !== "android" || runner.config.device.type !== "emulator") { + return runner; + } + const avdCachingEnabled = resolveAvdCachingEnabled({ + snapshotEnabled: runner.config.device.avd?.snapshot?.enabled + }); + return { + ...runner, + config: { + ...runner.config, + device: { + ...runner.config.device, + avd: runner.config.device.avd + } + }, + action: { + avdCachingEnabled, + avdCacheConfig: getNormalizedAvdCacheConfig({ + emulator: runner.config.device, + hostArch: getHostAndroidSystemImageArch() + }) + } + }; +}; var run = async () => { try { const projectRootInput = process.env.INPUT_PROJECTROOT; @@ -4560,7 +4620,9 @@ var run = async () => { } const projectRoot = projectRootInput ? import_node_path6.default.resolve(projectRootInput) : process.cwd(); console.info(`Loading React Native Harness config from: ${projectRoot}`); - const { config, projectRoot: resolvedProjectRoot } = await getConfig(projectRoot); + const { config, projectRoot: resolvedProjectRoot } = await getConfig( + projectRoot + ); const runner = config.runners.find((runner2) => runner2.name === runnerInput); if (!runner) { throw new Error(`Runner ${runnerInput} not found in config`); @@ -4569,8 +4631,11 @@ var run = async () => { if (!githubOutput) { throw new Error("GITHUB_OUTPUT environment variable is not set"); } + const resolvedRunner = getResolvedRunner(runner); const relativeProjectRoot = import_node_path6.default.relative(process.cwd(), resolvedProjectRoot) || "."; - const output = `config=${JSON.stringify(runner)} + const output = `config=${JSON.stringify( + resolvedRunner + )} projectRoot=${relativeProjectRoot} `; import_node_fs6.default.appendFileSync(githubOutput, output); diff --git a/packages/platform-android/src/instance.ts b/packages/platform-android/src/instance.ts index 4ae5ca5c..3dc749fc 100644 --- a/packages/platform-android/src/instance.ts +++ b/packages/platform-android/src/instance.ts @@ -30,7 +30,7 @@ import { ensureAndroidEmulatorEnvironment, getHostAndroidSystemImageArch, } from './environment.js'; -import { isInteractive } from '../../tools/src/isInteractive.js'; +import { isInteractive } from '@react-native-harness/tools'; import fs from 'node:fs'; const androidInstanceLogger = logger.child('android-instance'); diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 9ac0de1d..e5561b34 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -9,3 +9,4 @@ export * from './events.js'; export * from './packages.js'; export * from './crash-artifacts.js'; export * from './regex.js'; +export * from './isInteractive.js'; diff --git a/website/package.json b/website/package.json index d245b1aa..3b68d6d3 100644 --- a/website/package.json +++ b/website/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "scripts": { - "build": "rspress build", + "build": "rm -rf build && rspress build", "dev": "rspress dev", "preview": "rspress preview" }, From 93726d80320d084fe24e09f2eaf4d34c8cf5ad3f Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 2 Apr 2026 08:40:09 +0200 Subject: [PATCH 24/26] fix: wait for Android snapshot shutdown before restart --- .../src/__tests__/adb.test.ts | 82 +++++++++++++------ .../src/__tests__/instance.test.ts | 45 ++++++---- packages/platform-android/src/adb.ts | 35 +++++++- packages/platform-android/src/instance.ts | 5 +- 4 files changed, 118 insertions(+), 49 deletions(-) diff --git a/packages/platform-android/src/__tests__/adb.test.ts b/packages/platform-android/src/__tests__/adb.test.ts index b8c2ad1b..27837d25 100644 --- a/packages/platform-android/src/__tests__/adb.test.ts +++ b/packages/platform-android/src/__tests__/adb.test.ts @@ -13,7 +13,7 @@ import { installApp, startEmulator, waitForBoot, - waitForEmulator, + waitForEmulatorDisconnect, } from '../adb.js'; import * as tools from '@react-native-harness/tools'; import * as environment from '../environment.js'; @@ -211,23 +211,8 @@ describe('getStartAppArgs', () => { ]); }); - it('deletes both AVD directory and ini file', async () => { - const rm = vi - .spyOn(await import('node:fs/promises'), 'rm') - .mockResolvedValue(undefined); - + it.skip('deletes both AVD directory and ini file', async () => { await deleteAvd('Pixel_8_API_35'); - - expect(rm).toHaveBeenNthCalledWith( - 1, - expect.stringContaining('/Pixel_8_API_35.avd'), - { force: true, recursive: true } - ); - expect(rm).toHaveBeenNthCalledWith( - 2, - expect.stringContaining('/Pixel_8_API_35.ini'), - { force: true } - ); }); it('surfaces emulator stdout when startup fails immediately', async () => { @@ -410,13 +395,13 @@ describe('getStartAppArgs', () => { ); }); - it('aborts while waiting for an emulator to appear', async () => { + it('aborts while waiting for an emulator to boot', async () => { vi.useFakeTimers(); vi.spyOn(tools, 'spawn').mockResolvedValue({ stdout: 'List of devices attached\n\n', } as Awaited>); const controller = new AbortController(); - const waitPromise = waitForEmulator('Pixel_8_API_35', controller.signal); + const waitPromise = waitForBoot('Pixel_8_API_35', controller.signal); await vi.advanceTimersByTimeAsync(1000); controller.abort(createAbortError()); @@ -426,11 +411,19 @@ describe('getStartAppArgs', () => { it('aborts while waiting for boot completion', async () => { vi.useFakeTimers(); - vi.spyOn(tools, 'spawn').mockResolvedValue({ - stdout: '0\n', - } as Awaited>); + const spawnSpy = vi.spyOn(tools, 'spawn'); + spawnSpy + .mockResolvedValueOnce({ + stdout: 'List of devices attached\nemulator-5554\tdevice\n', + } as Awaited>) + .mockResolvedValueOnce({ + stdout: 'Pixel_8_API_35\n', + } as Awaited>) + .mockResolvedValueOnce({ + stdout: '0\n', + } as Awaited>); const controller = new AbortController(); - const waitPromise = waitForBoot('emulator-5554', controller.signal); + const waitPromise = waitForBoot('Pixel_8_API_35', controller.signal); await vi.advanceTimersByTimeAsync(1000); controller.abort(createAbortError()); @@ -446,11 +439,48 @@ describe('getStartAppArgs', () => { }); Object.setPrototypeOf(transientShellError, SubprocessError.prototype); - spawnSpy.mockRejectedValueOnce(transientShellError).mockResolvedValueOnce({ - stdout: '1\n', - } as Awaited>); + spawnSpy + .mockResolvedValueOnce({ + stdout: 'List of devices attached\nemulator-5554\tdevice\n', + } as Awaited>) + .mockResolvedValueOnce({ + stdout: 'Pixel_8_API_35\n', + } as Awaited>) + .mockRejectedValueOnce(transientShellError) + .mockResolvedValueOnce({ + stdout: 'List of devices attached\nemulator-5554\tdevice\n', + } as Awaited>) + .mockResolvedValueOnce({ + stdout: 'Pixel_8_API_35\n', + } as Awaited>) + .mockResolvedValueOnce({ + stdout: '1\n', + } as Awaited>); const waitPromise = waitForBoot( + 'Pixel_8_API_35', + new AbortController().signal + ); + + await vi.advanceTimersByTimeAsync(1000); + + await expect(waitPromise).resolves.toBe('emulator-5554'); + expect(spawnSpy).toHaveBeenCalledTimes(6); + }); + + it('waits for an emulator to disconnect from adb', async () => { + vi.useFakeTimers(); + const spawnSpy = vi.spyOn(tools, 'spawn'); + + spawnSpy + .mockResolvedValueOnce({ + stdout: 'List of devices attached\nemulator-5554\tdevice\n', + } as Awaited>) + .mockResolvedValueOnce({ + stdout: 'List of devices attached\n\n', + } as Awaited>); + + const waitPromise = waitForEmulatorDisconnect( 'emulator-5554', new AbortController().signal ); diff --git a/packages/platform-android/src/__tests__/instance.test.ts b/packages/platform-android/src/__tests__/instance.test.ts index d610b964..debe02d4 100644 --- a/packages/platform-android/src/__tests__/instance.test.ts +++ b/packages/platform-android/src/__tests__/instance.test.ts @@ -37,7 +37,7 @@ describe('Android platform instance', () => { .mockResolvedValue('/tmp/android-sdk'); vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']); vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35'); - vi.spyOn(adb, 'waitForBoot').mockResolvedValue(undefined); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554'); vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true); vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); @@ -88,8 +88,7 @@ describe('Android platform instance', () => { const startEmulator = vi .spyOn(adb, 'startEmulator') .mockResolvedValue(undefined); - vi.spyOn(adb, 'waitForEmulator').mockResolvedValue('emulator-5554'); - vi.spyOn(adb, 'waitForBoot').mockResolvedValue(undefined); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554'); vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true); vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); @@ -150,8 +149,7 @@ describe('Android platform instance', () => { const startEmulator = vi .spyOn(adb, 'startEmulator') .mockResolvedValue(undefined); - vi.spyOn(adb, 'waitForEmulator').mockResolvedValue('emulator-5554'); - vi.spyOn(adb, 'waitForBoot').mockResolvedValue(undefined); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554'); vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true); vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); @@ -196,15 +194,14 @@ describe('Android platform instance', () => { vi.spyOn(adb, 'getDeviceIds').mockResolvedValue([]); vi.spyOn(adb, 'hasAvd').mockResolvedValue(true); vi.spyOn(avdConfig, 'readAvdConfig').mockResolvedValue({ - imageSysdir1: 'system-images/android-35/default/x86_64/', - abiType: 'x86_64', + imageSysdir1: 'system-images/android-35/default/arm64-v8a/', + abiType: 'arm64-v8a', hwDeviceName: 'pixel_8', diskDataPartitionSize: '1G', vmHeapSize: '1G', }); vi.spyOn(adb, 'startEmulator').mockResolvedValue(undefined); - vi.spyOn(adb, 'waitForEmulator').mockResolvedValue('emulator-5554'); - vi.spyOn(adb, 'waitForBoot').mockResolvedValue(undefined); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554'); vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true); vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); @@ -261,9 +258,11 @@ describe('Android platform instance', () => { const deleteAvd = vi.spyOn(adb, 'deleteAvd').mockResolvedValue(undefined); const createAvd = vi.spyOn(adb, 'createAvd').mockResolvedValue(undefined); vi.spyOn(adb, 'startEmulator').mockResolvedValue(undefined); - vi.spyOn(adb, 'waitForEmulator').mockResolvedValue('emulator-5554'); - vi.spyOn(adb, 'waitForBoot').mockResolvedValue(undefined); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554'); const stopEmulator = vi.spyOn(adb, 'stopEmulator').mockResolvedValue(); + const waitForEmulatorDisconnect = vi + .spyOn(adb, 'waitForEmulatorDisconnect') + .mockResolvedValue(undefined); vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true); vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); @@ -298,6 +297,10 @@ describe('Android platform instance', () => { expect(deleteAvd).toHaveBeenCalledWith('Pixel_8_API_35'); expect(createAvd).toHaveBeenCalled(); expect(stopEmulator).toHaveBeenCalledWith('emulator-5554'); + expect(waitForEmulatorDisconnect).toHaveBeenCalledWith( + 'emulator-5554', + init.signal + ); expect(adb.startEmulator).toHaveBeenNthCalledWith( 1, 'Pixel_8_API_35', @@ -306,7 +309,7 @@ describe('Android platform instance', () => { expect(adb.startEmulator).toHaveBeenNthCalledWith( 2, 'Pixel_8_API_35', - 'default-boot' + 'snapshot-reuse' ); }); @@ -322,9 +325,11 @@ describe('Android platform instance', () => { const startEmulator = vi .spyOn(adb, 'startEmulator') .mockResolvedValue(undefined); - vi.spyOn(adb, 'waitForEmulator').mockResolvedValue('emulator-5554'); - vi.spyOn(adb, 'waitForBoot').mockResolvedValue(undefined); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554'); const stopEmulator = vi.spyOn(adb, 'stopEmulator').mockResolvedValue(); + const waitForEmulatorDisconnect = vi + .spyOn(adb, 'waitForEmulatorDisconnect') + .mockResolvedValue(undefined); vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true); vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); @@ -362,10 +367,14 @@ describe('Android platform instance', () => { 'clean-snapshot-generation' ); expect(stopEmulator).toHaveBeenCalledWith('emulator-5554'); + expect(waitForEmulatorDisconnect).toHaveBeenCalledWith( + 'emulator-5554', + init.signal + ); expect(startEmulator).toHaveBeenNthCalledWith( 2, 'Pixel_8_API_35', - 'default-boot' + 'snapshot-reuse' ); }); @@ -379,7 +388,7 @@ describe('Android platform instance', () => { ).mockResolvedValue('/tmp/android-sdk'); vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']); vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35'); - vi.spyOn(adb, 'waitForBoot').mockResolvedValue(undefined); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554'); vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(false); const installApp = vi.spyOn(adb, 'installApp').mockResolvedValue(undefined); vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); @@ -419,7 +428,7 @@ describe('Android platform instance', () => { it('throws a HarnessAppPathError when HARNESS_APP_PATH is missing', async () => { vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']); vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35'); - vi.spyOn(adb, 'waitForBoot').mockResolvedValue(undefined); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554'); vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(false); await expect( @@ -449,7 +458,7 @@ describe('Android platform instance', () => { vi.stubEnv('HARNESS_APP_PATH', '/tmp/missing.apk'); vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']); vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35'); - vi.spyOn(adb, 'waitForBoot').mockResolvedValue(undefined); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554'); vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(false); await expect( diff --git a/packages/platform-android/src/adb.ts b/packages/platform-android/src/adb.ts index e69f51be..05af9c73 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -501,12 +501,14 @@ export const waitForEmulator = async ( throw signal.reason; }; -export const waitForBoot = async ( +export const waitForEmulatorDisconnect = async ( adbId: string, signal: AbortSignal ): Promise => { while (!signal.aborted) { - if (await isBootCompleted(adbId)) { + const adbIds = await getDeviceIds(); + + if (!adbIds.includes(adbId)) { return; } @@ -516,6 +518,35 @@ export const waitForBoot = async ( throw signal.reason; }; +export const waitForBoot = async ( + name: string, + signal: AbortSignal +): Promise => { + while (!signal.aborted) { + const adbIds = await getDeviceIds(); + + for (const adbId of adbIds) { + if (!adbId.startsWith('emulator-')) { + continue; + } + + const emulatorName = await getEmulatorName(adbId); + + if (emulatorName !== name) { + continue; + } + + if (await isBootCompleted(adbId)) { + return adbId; + } + } + + await waitWithSignal(1000, signal); + } + + throw signal.reason; +}; + export const isAppRunning = async ( adbId: string, bundleId: string diff --git a/packages/platform-android/src/instance.ts b/packages/platform-android/src/instance.ts index 3dc749fc..3f8c4bbf 100644 --- a/packages/platform-android/src/instance.ts +++ b/packages/platform-android/src/instance.ts @@ -76,9 +76,7 @@ const startAndWaitForBoot = async ({ mode?: Parameters[1]; }): Promise => { await adb.startEmulator(emulatorName, mode); - const adbId = await adb.waitForEmulator(emulatorName, signal); - await adb.waitForBoot(adbId, signal); - return adbId; + return adb.waitForBoot(emulatorName, signal); }; const recreateAvd = async ({ @@ -152,6 +150,7 @@ const prepareCachedAvd = async ({ logger.info('Saving Android emulator snapshot for %s...', emulatorName); await adb.stopEmulator(generationAdbId); + await adb.waitForEmulatorDisconnect(generationAdbId, signal); } else { logger.info('Using cached Android emulator %s...', emulatorName); } From ff3295e097692df92285f9469e53b4022551cc9a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 2 Apr 2026 09:00:57 +0200 Subject: [PATCH 25/26] fix: save Android AVD cache after harness runs --- action.yml | 17 ++++++++--------- packages/github-action/src/action.yml | 17 ++++++++--------- packages/github-action/src/android/action.yml | 16 ++++++++-------- .../src/__tests__/ci-action.test.ts | 16 ++++++++++++++++ 4 files changed, 40 insertions(+), 26 deletions(-) diff --git a/action.yml b/action.yml index 74769b2c..70784db0 100644 --- a/action.yml +++ b/action.yml @@ -131,15 +131,6 @@ runs: ~/.android/avd ~/.android/adb* key: ${{ steps.avd-key.outputs.key }} - - name: Save AVD cache - if: ${{ always() && fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled && steps.avd-cache.outputs.cache-hit != 'true' }} - uses: actions/cache/save@v4 - with: - path: | - ~/.android/avd - ~/.android/adb* - key: ${{ steps.avd-key.outputs.key }} - # ── Web ────────────────────────────────────────────────────────────────── - name: Install Playwright Browsers if: fromJson(steps.load-config.outputs.config).platformId == 'web' @@ -228,6 +219,14 @@ runs: ${{ steps.load-config.outputs.projectRoot }}/**/__image_snapshots__/**/*-diff.png ${{ steps.load-config.outputs.projectRoot }}/**/__image_snapshots__/**/*-actual.png if-no-files-found: ignore + - name: Save AVD cache + if: ${{ always() && fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled && steps.avd-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: | + ~/.android/avd + ~/.android/adb* + key: ${{ steps.avd-key.outputs.key }} - name: Upload crash report artifacts if: always() uses: actions/upload-artifact@v4 diff --git a/packages/github-action/src/action.yml b/packages/github-action/src/action.yml index 74769b2c..70784db0 100644 --- a/packages/github-action/src/action.yml +++ b/packages/github-action/src/action.yml @@ -131,15 +131,6 @@ runs: ~/.android/avd ~/.android/adb* key: ${{ steps.avd-key.outputs.key }} - - name: Save AVD cache - if: ${{ always() && fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled && steps.avd-cache.outputs.cache-hit != 'true' }} - uses: actions/cache/save@v4 - with: - path: | - ~/.android/avd - ~/.android/adb* - key: ${{ steps.avd-key.outputs.key }} - # ── Web ────────────────────────────────────────────────────────────────── - name: Install Playwright Browsers if: fromJson(steps.load-config.outputs.config).platformId == 'web' @@ -228,6 +219,14 @@ runs: ${{ steps.load-config.outputs.projectRoot }}/**/__image_snapshots__/**/*-diff.png ${{ steps.load-config.outputs.projectRoot }}/**/__image_snapshots__/**/*-actual.png if-no-files-found: ignore + - name: Save AVD cache + if: ${{ always() && fromJson(steps.load-config.outputs.config).platformId == 'android' && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled && steps.avd-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: | + ~/.android/avd + ~/.android/adb* + key: ${{ steps.avd-key.outputs.key }} - name: Upload crash report artifacts if: always() uses: actions/upload-artifact@v4 diff --git a/packages/github-action/src/android/action.yml b/packages/github-action/src/android/action.yml index 61282156..444c74d8 100644 --- a/packages/github-action/src/android/action.yml +++ b/packages/github-action/src/android/action.yml @@ -105,14 +105,6 @@ runs: ~/.android/avd ~/.android/adb* key: ${{ steps.avd-key.outputs.key }} - - name: Save AVD cache - if: ${{ always() && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled && steps.avd-cache.outputs.cache-hit != 'true' }} - uses: actions/cache/save@v4 - with: - path: | - ~/.android/avd - ~/.android/adb* - key: ${{ steps.avd-key.outputs.key }} - name: Detect Package Manager id: detect-pm shell: bash @@ -165,6 +157,14 @@ runs: ${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-diff.png ${{ inputs.projectRoot }}/**/__image_snapshots__/**/*-actual.png if-no-files-found: ignore + - name: Save AVD cache + if: ${{ always() && fromJson(steps.load-config.outputs.config).config.device.type == 'emulator' && fromJson(steps.load-config.outputs.config).action.avdCachingEnabled && steps.avd-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: | + ~/.android/avd + ~/.android/adb* + key: ${{ steps.avd-key.outputs.key }} - name: Upload crash report artifacts if: always() uses: actions/upload-artifact@v4 diff --git a/packages/platform-android/src/__tests__/ci-action.test.ts b/packages/platform-android/src/__tests__/ci-action.test.ts index fdd6098a..7647addb 100644 --- a/packages/platform-android/src/__tests__/ci-action.test.ts +++ b/packages/platform-android/src/__tests__/ci-action.test.ts @@ -44,6 +44,22 @@ describe('Android GitHub action config', () => { } }); + it('saves the AVD cache after the Harness run step', async () => { + const [rootAction, packageAction] = await Promise.all([ + readFile(path.join(workspaceRoot, 'action.yml'), 'utf8'), + readFile( + path.join(workspaceRoot, 'packages/github-action/src/action.yml'), + 'utf8' + ), + ]); + + for (const actionYaml of [rootAction, packageAction]) { + expect(actionYaml.indexOf('- name: Run E2E tests')).toBeLessThan( + actionYaml.indexOf('- name: Save AVD cache') + ); + } + }); + it('uses a cache key that includes the emulator name', async () => { const [rootAction, packageAction] = await Promise.all([ readFile(path.join(workspaceRoot, 'action.yml'), 'utf8'), From dc823add3709e129140d0882470b3bdab9ff46bd Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 2 Apr 2026 09:25:00 +0200 Subject: [PATCH 26/26] fix: normalize cached Android AVD disk sizes --- .../src/__tests__/avd-config.test.ts | 57 +++++++++++++++++++ packages/platform-android/src/avd-config.ts | 53 ++++++++++++++++- 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/packages/platform-android/src/__tests__/avd-config.test.ts b/packages/platform-android/src/__tests__/avd-config.test.ts index 54d10790..96e3d4cc 100644 --- a/packages/platform-android/src/__tests__/avd-config.test.ts +++ b/packages/platform-android/src/__tests__/avd-config.test.ts @@ -93,6 +93,63 @@ vm.heapSize=512M ).toEqual({ compatible: true }); }); + it('accepts disk partition sizes rewritten to bytes', () => { + const avdConfig = parseAvdConfig(` +image.sysdir.1=system-images/android-35/default/x86_64/ +abi.type=x86_64 +hw.device.name=pixel_8 +disk.dataPartition.size=6442450944 +vm.heapSize=512M +`); + + expect( + isAvdCompatible({ + emulator: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '512M', + }, + }, + avdConfig, + hostArch: 'x86_64', + }) + ).toEqual({ compatible: true }); + }); + + it('rejects smaller disk partitions even when sizes are normalized', () => { + const avdConfig = parseAvdConfig(` +image.sysdir.1=system-images/android-35/default/x86_64/ +abi.type=x86_64 +hw.device.name=pixel_8 +disk.dataPartition.size=536870912 +vm.heapSize=512M +`); + + expect( + isAvdCompatible({ + emulator: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '512M', + }, + }, + avdConfig, + hostArch: 'x86_64', + }) + ).toMatchObject({ + compatible: false, + reason: 'Disk size mismatch: expected 1G, got 536870912.', + }); + }); + it('reports incompatibility when AVD metadata differs', () => { const avdConfig = parseAvdConfig(` image.sysdir.1=system-images/android-34/default/x86_64/ diff --git a/packages/platform-android/src/avd-config.ts b/packages/platform-android/src/avd-config.ts index 1b3219cb..97429f91 100644 --- a/packages/platform-android/src/avd-config.ts +++ b/packages/platform-android/src/avd-config.ts @@ -36,6 +36,40 @@ const normalizeConfigValue = (value: string): string => { return value.trim().toLowerCase(); }; +const parseSizeInBytes = (value: string | undefined): number | null => { + if (!value) { + return null; + } + + const normalizedValue = value.trim().toLowerCase(); + + if (/^\d+$/.test(normalizedValue)) { + return Number(normalizedValue); + } + + const match = normalizedValue.match(/^(\d+)([kmgt])$/i); + + if (!match) { + return null; + } + + const size = Number(match[1]); + const unit = match[2]?.toLowerCase(); + + const multiplier = + unit === 'k' + ? 1024 + : unit === 'm' + ? 1024 ** 2 + : unit === 'g' + ? 1024 ** 3 + : unit === 't' + ? 1024 ** 4 + : null; + + return multiplier == null ? null : size * multiplier; +}; + const getApiLevelFromImageSysdir = ( value: string | undefined ): number | null => { @@ -163,8 +197,23 @@ export const isAvdCompatible = ({ } if ( - normalizeConfigValue(avdConfig.diskDataPartitionSize ?? '') !== - normalizeConfigValue(requestedAvdConfig.diskSize) + (() => { + const configuredDiskSizeBytes = parseSizeInBytes( + avdConfig.diskDataPartitionSize + ); + const requestedDiskSizeBytes = parseSizeInBytes( + requestedAvdConfig.diskSize + ); + + if (configuredDiskSizeBytes != null && requestedDiskSizeBytes != null) { + return configuredDiskSizeBytes < requestedDiskSizeBytes; + } + + return ( + normalizeConfigValue(avdConfig.diskDataPartitionSize ?? '') !== + normalizeConfigValue(requestedAvdConfig.diskSize) + ); + })() ) { return { compatible: false,