diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 7b0076bf..d4a5f360 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -13,6 +13,7 @@ on: - android - ios - web + - vega pull_request: types: [ready_for_review] branches: @@ -559,3 +560,51 @@ jobs: name: harness-diagnostics-crash-validate-ios path: apps/playground/.harness/diagnostics if-no-files-found: ignore + + e2e-vega: + name: E2E Vega + # Needs a self-hosted runner with the Vega SDK installed and the "vega" + # label; GitHub-hosted images have no SDK, so this job is dispatch only. + runs-on: [self-hosted, vega] + timeout-minutes: 45 + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.platform == 'vega' }} + env: + HARNESS_DEBUG: true + RN_HARNESS_DIAGNOSTICS: true + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.ref }} + fetch-depth: 0 + + - name: Install pnpm + uses: pnpm/action-setup@eae0cfeb286e66ffb5155f1a79b90583a127a68b # v2.4.1 + with: + version: latest + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '24.10.0' + cache: 'pnpm' + + - name: Install dependencies + run: | + pnpm install + + - name: Build packages + run: | + pnpm nx run-many -t build --projects="packages/*" + + - name: Build the Vega app + run: | + source ~/vega/env + pnpm --filter @react-native-harness/playground-vega build:debug + + - name: Run React Native Harness + uses: ./ + with: + runner: vega + projectRoot: apps/playground-vega + app: apps/playground-vega/build/${{ runner.arch == 'ARM64' && 'aarch64' || 'x86_64' }}-debug/playground-vega_${{ runner.arch == 'ARM64' && 'aarch64' || 'x86_64' }}.vpkg diff --git a/.gitignore b/.gitignore index d4eef0ab..2ecdf12f 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,6 @@ npm-debug.* *.mobileprovision *.orig.* web-build/ + +# Vega build metadata +apps/playground-vega/buildinfo.json diff --git a/.nx/version-plans/version-plan-1790754023000.md b/.nx/version-plans/version-plan-1790754023000.md new file mode 100644 index 00000000..0acc2ebb --- /dev/null +++ b/.nx/version-plans/version-plan-1790754023000.md @@ -0,0 +1,5 @@ +--- +__default__: minor +--- + +The Vega runner now works with current Vega SDKs (0.24 and newer): it drives the `vega` CLI instead of the retired `kepler` binary, starts the Vega Virtual Device when it is not running, supports Fire TV devices in developer mode through `vegaPhysicalDevice(serial)`, installs the app package from `HARNESS_APP_PATH`, forwards Metro's port to the device, captures device logs for failed runs, and configures Metro for React Native for Vega (`kepler` platform, the `@amazon-devices/react-native-kepler` redirect, and Amazon's system bundles so React Native comes from the OS copy). Platforms can also declare a `bundlePlatform` when the app requests Metro bundles under a different platform name than the harness platform id, the runtime no longer depends on a URL polyfill that reads the legacy native module bridge, so it initializes on bridgeless runtimes such as Vega, and test bundles may use string module ids from custom Metro id factories. diff --git a/action.yml b/action.yml index 30c75df6..9f587ba6 100644 --- a/action.yml +++ b/action.yml @@ -1,5 +1,5 @@ name: React Native Harness -description: Run React Native Harness tests on iOS, Android, Web or Windows +description: Run React Native Harness tests on iOS, Android, Web, Windows or Vega inputs: runner: description: The runner to use (must match a runner name defined in your harness config) @@ -7,8 +7,8 @@ inputs: type: string app: description: >- - The path to the built app (.app for iOS, .apk for Android). Not required - for web, or for Windows (deploy the app with `react-native run-windows` + The path to the built app (.app for iOS, .apk for Android, .vpkg for + Vega). Not required for web, or for Windows (deploy the app with `react-native run-windows` before this action runs). required: false type: string diff --git a/apps/playground-vega/README.md b/apps/playground-vega/README.md new file mode 100644 index 00000000..0bf0a311 --- /dev/null +++ b/apps/playground-vega/README.md @@ -0,0 +1,32 @@ +# Playground for Vega + +A React Native for Vega app used to run React Native Harness on the Vega Virtual Device and on Fire TV devices running Vega OS. It mirrors the platform-agnostic test suites of `apps/playground`; the `ui/` suites are left out because `@react-native-harness/ui` has no Vega native module yet. + +## Prerequisites + +- The [Vega SDK](https://developer.amazon.com/docs/vega/0.24/install-vega-sdk) with the `vega` CLI on your `PATH` (`source ~/vega/env`). +- For Fire TV devices: developer mode enabled on the device (`vega devmode login`, then `vega devmode enable-device --code `), and the device serial from `vega device list` exported as `VEGA_DEVICE_SERIAL`. + +## How to run React Native Harness? + +Build a debug package first. Debug builds load the JavaScript bundle from Metro, which is what Harness needs: + +```sh +pnpm build:debug +``` + +The package lands in `build/-debug/playground-vega_.vpkg`. Use `aarch64` for the virtual device on Apple Silicon, `x86_64` on Intel, and `armv7` for Fire TV. + +Then run the tests. Harness starts the virtual device when it is not running, installs the package from `HARNESS_APP_PATH`, and forwards Metro's port to the device: + +```sh +HARNESS_APP_PATH=$PWD/build/aarch64-debug/playground-vega_aarch64.vpkg pnpm test:harness --harnessRunner vega +``` + +For a Fire TV: + +```sh +VEGA_DEVICE_SERIAL= HARNESS_APP_PATH=$PWD/build/armv7-debug/playground-vega_armv7.vpkg pnpm test:harness --harnessRunner fire-tv +``` + +See [the React Native Harness documentation](https://www.react-native-harness.dev/docs/platforms/vega) for details. diff --git a/apps/playground-vega/app.json b/apps/playground-vega/app.json new file mode 100644 index 00000000..ab390257 --- /dev/null +++ b/apps/playground-vega/app.json @@ -0,0 +1,5 @@ +{ + "//": "The Vega runtime launches the app by its interactive component id, so the registered name must match manifest.toml", + "name": "com.harnessplayground.vega.main", + "displayName": "HarnessPlaygroundVega" +} diff --git a/apps/playground-vega/babel.config.js b/apps/playground-vega/babel.config.js new file mode 100644 index 00000000..f7b3da3b --- /dev/null +++ b/apps/playground-vega/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: ['module:@react-native/babel-preset'], +}; diff --git a/apps/playground-vega/index.js b/apps/playground-vega/index.js new file mode 100644 index 00000000..13a21056 --- /dev/null +++ b/apps/playground-vega/index.js @@ -0,0 +1,9 @@ +/** + * @format + */ + +import { AppRegistry } from 'react-native'; +import App from './src/app/App'; +import { name as appName } from './app.json'; + +AppRegistry.registerComponent(appName, () => App); diff --git a/apps/playground-vega/jest.harness.config.mjs b/apps/playground-vega/jest.harness.config.mjs new file mode 100644 index 00000000..b95f3f69 --- /dev/null +++ b/apps/playground-vega/jest.harness.config.mjs @@ -0,0 +1,12 @@ +export default { + preset: 'react-native-harness', + // The playground's ui/ suites are not copied here: @react-native-harness/ui + // has no Vega native module yet. + testMatch: ['/src/__tests__/**/*.harness.[jt]s?(x)'], + setupFiles: ['./src/setupFile.ts'], + setupFilesAfterEnv: ['./src/setupFileAfterEnv.ts'], + // This is necessary to prevent Jest from transforming the workspace packages. + // Not needed in users projects, as they will have the packages installed in their node_modules. + transformIgnorePatterns: ['/packages/', '/node_modules/'], + collectCoverageFrom: ['./src/**/*.(ts|tsx)'], +}; diff --git a/apps/playground-vega/manifest.toml b/apps/playground-vega/manifest.toml new file mode 100644 index 00000000..169f1906 --- /dev/null +++ b/apps/playground-vega/manifest.toml @@ -0,0 +1,25 @@ +schema-version = 1 + +[package] +title = "Harness Playground for Vega" +version = "0.0.1" +id = "com.harnessplayground.vega" + +[os.version] +min = "1.2" +target = "1.2" + +[needs] +[[needs.module]] +id = "/com.amazon.vega.os@IVega_1_2" + +[[needs.module]] +id = "/com.amazon.kepler.keplerscript_4@IKeplerScript_0" + + +[components] +[[components.interactive]] +id = "com.harnessplayground.vega.main" +runtime-module = "/com.amazon.kepler.runtime.react_native_kepler_4@IReactNativeKepler_0" +launch-type = "singleton" +categories = ["com.amazon.category.main"] diff --git a/apps/playground-vega/metro.config.js b/apps/playground-vega/metro.config.js new file mode 100644 index 00000000..f091e2d8 --- /dev/null +++ b/apps/playground-vega/metro.config.js @@ -0,0 +1,35 @@ +const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); +const exclusionList = + require('metro-config/private/defaults/exclusionList').default; +const path = require('path'); + +const projectRoot = __dirname; +const monorepoRoot = path.resolve(projectRoot, '../..'); + +/** + * Metro configuration + * https://reactnative.dev/docs/metro + * + * The `kepler` platform and the `react-native` redirect to + * `@amazon-devices/react-native-kepler` are added by the Vega CLI plugin for + * `react-native start` / `build-vega`, and by the Harness Vega platform for + * harness runs, so they are not configured here. + * + * @type {import('metro-config').MetroConfig} + */ +const config = { + cacheVersion: '@react-native-harness/playground-vega', + watchFolders: [monorepoRoot], + resolver: { + unstable_enablePackageExports: true, + nodeModulesPaths: [ + path.resolve(projectRoot, 'node_modules'), + path.resolve(monorepoRoot, 'node_modules'), + ], + // The monorepo root is a watch folder, so keep Nx's local cache and + // workspace data out of Metro's file map. + blockList: exclusionList([/\.nx\/.*/]), + }, +}; + +module.exports = mergeConfig(getDefaultConfig(projectRoot), config); diff --git a/apps/playground-vega/package.json b/apps/playground-vega/package.json new file mode 100644 index 00000000..bc98b399 --- /dev/null +++ b/apps/playground-vega/package.json @@ -0,0 +1,34 @@ +{ + "name": "@react-native-harness/playground-vega", + "version": "0.0.1", + "private": true, + "scripts": { + "build:debug": "react-native build-vega --build-type Debug", + "build:release": "react-native build-vega --build-type Release", + "test:harness": "node ../../packages/react-native-harness/bin.js" + }, + "kepler": { + "projectType": "application", + "appName": "HarnessPlaygroundVega", + "targets": [ + "tv" + ] + }, + "dependencies": { + "@amazon-devices/react-native-kepler": "~4.0.0", + "react": "19.2.3", + "react-native": "0.83.0" + }, + "devDependencies": { + "@amazon-devices/kepler-cli-platform": "^0.22.14", + "@react-native-community/cli": "20.0.0", + "@react-native-harness/jest": "workspace:*", + "@react-native-harness/platform-vega": "workspace:*", + "@react-native-harness/runtime": "workspace:*", + "@react-native/babel-preset": "0.83.0", + "@react-native/metro-config": "0.83.0", + "@react-native/typescript-config": "0.83.0", + "jest": "^30.2.0", + "react-native-harness": "workspace:*" + } +} diff --git a/apps/playground-vega/project.json b/apps/playground-vega/project.json new file mode 100644 index 00000000..e0379bba --- /dev/null +++ b/apps/playground-vega/project.json @@ -0,0 +1,13 @@ +{ + "name": "@react-native-harness/playground-vega", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "targets": { + "start": { + "executor": "nx:run-commands", + "options": { + "command": "node ../../packages/react-native-harness/bin.js", + "cwd": "apps/playground-vega" + } + } + } +} diff --git a/apps/playground-vega/rn-harness.config.mjs b/apps/playground-vega/rn-harness.config.mjs new file mode 100644 index 00000000..a2ffa799 --- /dev/null +++ b/apps/playground-vega/rn-harness.config.mjs @@ -0,0 +1,35 @@ +import { + vegaPlatform, + vegaVirtualDevice, + vegaPhysicalDevice, +} from '@react-native-harness/platform-vega'; + +// The [package] id from manifest.toml. +const bundleId = 'com.harnessplayground.vega'; + +export default { + entryPoint: './index.js', + // The Vega runtime runs the component registered under the interactive + // component id from manifest.toml. + appRegistryComponentName: 'com.harnessplayground.vega.main', + runners: [ + vegaPlatform({ + name: 'vega', + device: vegaVirtualDevice(), + bundleId, + }), + vegaPlatform({ + name: 'fire-tv', + // Replace with the serial number printed by `vega device list`. + device: vegaPhysicalDevice(process.env.VEGA_DEVICE_SERIAL ?? 'FIRE_TV_SERIAL'), + bundleId, + }), + ], + defaultRunner: 'vega', + // The virtual device can take a few minutes to boot on first start. + platformReadyTimeout: 600000, + bridgeTimeout: 120000, + testTimeout: 10000, + resetEnvironmentBetweenTestFiles: 'runtime', + forwardClientLogs: true, +}; diff --git a/apps/playground-vega/src/__tests__/normal/hooks.harness.ts b/apps/playground-vega/src/__tests__/normal/hooks.harness.ts new file mode 100644 index 00000000..9f894127 --- /dev/null +++ b/apps/playground-vega/src/__tests__/normal/hooks.harness.ts @@ -0,0 +1,265 @@ +import { + describe, + test, + beforeEach, + afterEach, + beforeAll, + afterAll, + expect, + waitFor, +} from 'react-native-harness'; + +// Global state to track hook execution +const executionLog: string[] = []; +let globalCounter = 0; +let suiteCounter = 0; + +describe('Hooks', () => { + // Test suite-level variables to track state + let suiteSetupComplete = false; + let testCounter = 0; + let beforeEachCounter = 0; + + beforeAll(() => { + // This should run once before all tests in this suite + executionLog.push('beforeAll: Main suite setup'); + globalCounter = 100; + suiteSetupComplete = true; + }); + + afterAll(() => { + // This should run once after all tests in this suite + executionLog.push('afterAll: Main suite cleanup'); + }); + + beforeEach(() => { + // This should run before each individual test + beforeEachCounter++; + testCounter++; + executionLog.push(`beforeEach: Test ${testCounter} setup`); + }); + + afterEach(() => { + // This should run after each individual test + executionLog.push(`afterEach: Test ${testCounter} cleanup`); + }); + + describe('Basic', () => { + test('should have run beforeAll hook', () => { + expect(suiteSetupComplete).toBe(true); + expect(globalCounter).toBe(100); + expect(executionLog).toContain('beforeAll: Main suite setup'); + }); + + test('should run beforeEach before each test', () => { + expect(beforeEachCounter).toBeGreaterThan(0); + expect(executionLog).toContain(`beforeEach: Test ${testCounter} setup`); + }); + + test('should increment counters correctly', () => { + expect(testCounter).toBeGreaterThan(0); + expect(beforeEachCounter).toBe(testCounter); + }); + }); + + describe('Nested suites', () => { + let nestedSetupComplete = false; + let nestedCounter = 0; + + beforeAll(() => { + executionLog.push('beforeAll: Nested suite setup'); + nestedSetupComplete = true; + suiteCounter = 50; + }); + + afterAll(() => { + executionLog.push('afterAll: Nested suite cleanup'); + }); + + beforeEach(() => { + nestedCounter++; + executionLog.push(`beforeEach: Nested test ${nestedCounter} setup`); + }); + + afterEach(() => { + executionLog.push(`afterEach: Nested test ${nestedCounter} cleanup`); + }); + + test('should have both parent and nested beforeAll executed', () => { + expect(suiteSetupComplete).toBe(true); + expect(nestedSetupComplete).toBe(true); + expect(globalCounter).toBe(100); + expect(suiteCounter).toBe(50); + }); + + test('should execute nested beforeEach hooks', () => { + expect(nestedCounter).toBeGreaterThan(0); + expect(executionLog).toContain( + `beforeEach: Nested test ${nestedCounter} setup` + ); + }); + + test('should maintain hook execution order', () => { + // Check that parent beforeAll comes before nested beforeAll + const parentBeforeAllIndex = executionLog.indexOf( + 'beforeAll: Main suite setup' + ); + const nestedBeforeAllIndex = executionLog.indexOf( + 'beforeAll: Nested suite setup' + ); + expect(parentBeforeAllIndex).toBeLessThan(nestedBeforeAllIndex); + }); + }); + + describe('State management', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let localState: Record = {}; + + beforeEach(() => { + // Setup fresh state for each test + localState = { + initialized: true, + timestamp: Date.now(), + testData: { value: 42 }, + }; + executionLog.push('beforeEach: State initialized'); + }); + + afterEach(() => { + // Cleanup state after each test + localState = {}; + executionLog.push('afterEach: State cleaned up'); + }); + + test('should have fresh state from beforeEach', () => { + expect(localState.initialized).toBe(true); + expect(localState.testData.value).toBe(42); + expect(typeof localState.timestamp).toBe('number'); + }); + + test('should have independent state between tests', () => { + // Modify state in this test + localState.testData.value = 99; + localState.modified = true; + + expect(localState.testData.value).toBe(99); + expect(localState.modified).toBe(true); + }); + + test('should have fresh state again (previous test modifications should not persist)', () => { + // State should be fresh from beforeEach, not modified from previous test + expect(localState.testData.value).toBe(42); + expect(localState.modified).toBeUndefined(); + }); + }); + + describe('Async operations', () => { + let asyncSetupComplete = false; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let asyncData: any = null; + + beforeAll(async () => { + executionLog.push('beforeAll: Starting async setup'); + + // Simulate async setup (e.g., database connection, API calls) + await new Promise((resolve) => setTimeout(resolve, 50)); + + asyncData = { + connection: 'established', + config: { timeout: 5000 }, + ready: true, + }; + asyncSetupComplete = true; + + executionLog.push('beforeAll: Async setup complete'); + }); + + afterAll(async () => { + executionLog.push('afterAll: Starting async cleanup'); + + // Simulate async cleanup + await new Promise((resolve) => setTimeout(resolve, 30)); + + asyncData = null; + asyncSetupComplete = false; + + executionLog.push('afterAll: Async cleanup complete'); + }); + + beforeEach(async () => { + // Async setup before each test + await waitFor(() => { + expect(asyncSetupComplete).toBe(true); + }); + + executionLog.push('beforeEach: Async test setup'); + }); + + test('should wait for async beforeAll to complete', async () => { + expect(asyncSetupComplete).toBe(true); + expect(asyncData).not.toBeNull(); + expect(asyncData.ready).toBe(true); + }); + + test('should handle async operations in tests', async () => { + const result = await waitFor(() => { + expect(asyncData.connection).toBe('established'); + return asyncData.config.timeout; + }); + + expect(result).toBe(5000); + }); + }); + + describe('Error handling', () => { + let errorHandled = false; + + beforeEach(() => { + errorHandled = false; + executionLog.push('beforeEach: Error handling test setup'); + }); + + test('should handle errors gracefully in hooks', () => { + try { + // Simulate an error condition + throw new Error('Test error for demonstration'); + } catch (error) { + errorHandled = true; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('Test error for demonstration'); + } + + expect(errorHandled).toBe(true); + }); + + test('should continue running tests after error in previous test', () => { + // This test should run normally even if previous test had errors + expect(errorHandled).toBe(false); // Fresh state from beforeEach + expect(executionLog).toContain('beforeEach: Error handling test setup'); + }); + }); + + describe('Order of execution', () => { + test('should verify complete hook execution order', () => { + // Main suite beforeAll should be first + expect(executionLog[0]).toBe('beforeAll: Main suite setup'); + + // Should contain nested suite setup + expect(executionLog).toContain('beforeAll: Nested suite setup'); + + // Should contain async setup + expect(executionLog).toContain('beforeAll: Async setup complete'); + + // Should have multiple beforeEach/afterEach pairs + const beforeEachCount = executionLog.filter((entry) => + entry.includes('beforeEach') + ).length; + const afterEachCount = executionLog.filter((entry) => + entry.includes('afterEach') + ).length; + + expect(beforeEachCount).toBeGreaterThan(5); + expect(afterEachCount).toBeGreaterThan(0); + }); + }); +}); diff --git a/apps/playground-vega/src/__tests__/normal/jest-throw.harness.ts b/apps/playground-vega/src/__tests__/normal/jest-throw.harness.ts new file mode 100644 index 00000000..70d1d1a6 --- /dev/null +++ b/apps/playground-vega/src/__tests__/normal/jest-throw.harness.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'react-native-harness'; + +describe('Jest globals warning', () => { + it('should throw when accessing jest global', () => { + expect(() => { + // @ts-expect-error - jest is not available in Harness tests + jest.fn(); + }).toThrow('Jest globals are not available in Harness tests'); + }); + + it('should throw when importing @jest/globals', () => { + expect(() => require('@jest/globals')).toThrow( + "Importing '@jest/globals' is not supported in Harness tests" + ); + }); +}); diff --git a/apps/playground-vega/src/__tests__/normal/mocking/modules.harness.ts b/apps/playground-vega/src/__tests__/normal/mocking/modules.harness.ts new file mode 100644 index 00000000..96deaea3 --- /dev/null +++ b/apps/playground-vega/src/__tests__/normal/mocking/modules.harness.ts @@ -0,0 +1,141 @@ +import { + describe, + it, + expect, + afterEach, + mock, + unmock, + requireActual, + fn, + resetModules, +} from 'react-native-harness'; + +describe('Module mocking', () => { + afterEach(() => { + resetModules(); + }); + + it('should not interfere with modules that are not mocked', () => { + const moduleA = require('react-native'); + const moduleB = require('react-native'); + expect(moduleA === moduleB).toBe(true); + }); + + it('should completely mock a module and return mock implementation', () => { + // Create a mock factory for a hypothetical utility module + const mockUtilsFactory = () => ({ + formatString: fn().mockReturnValue('mocked string'), + calculateSum: fn().mockImplementation( + (a: number, b: number) => a + b + 1000 + ), + constants: { + VERSION: '999.0.0', + DEBUG: true, + }, + }); + + mock('react-native', mockUtilsFactory); + + // Require the mocked module + const mockedModule = require('react-native'); + + // Verify the mock implementation is returned + expect(mockedModule.formatString()).toBe('mocked string'); + expect(mockedModule.calculateSum(5, 10)).toBe(1015); + expect(mockedModule.constants.VERSION).toBe('999.0.0'); + expect(mockedModule.constants.DEBUG).toBe(true); + + // Verify the mock functions were called + expect(mockedModule.formatString).toHaveBeenCalledTimes(1); + expect(mockedModule.calculateSum).toHaveBeenCalledWith(5, 10); + }); + + it('should partially mock react-native, replacing only Platform while keeping other exports', () => { + const mockFactory = () => { + // Get the actual react-native module + const actualRN = requireActual('react-native'); + + // Copy without invoking getters to avoid triggering lazy initialization + const proto = Object.getPrototypeOf(actualRN); + const descriptors = Object.getOwnPropertyDescriptors(actualRN); + + const mockedRN = Object.create(proto, descriptors); + const mockedPlatform = { + OS: 'mockOS', + Version: 999, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + select: fn().mockImplementation((options: Record) => { + return options.mockOS || options.default; + }), + isPad: false, + isTesting: true, + }; + Object.defineProperty(mockedRN, 'Platform', { + get() { + return mockedPlatform; + }, + }); + return mockedRN; + }; + + mock('react-native', mockFactory); + + const mockedRN = require('react-native'); + + // Verify Platform is mocked + expect(mockedRN.Platform.OS).toBe('mockOS'); + expect(mockedRN.Platform.Version).toBe(999); + expect(mockedRN.Platform.isTesting).toBe(true); + + // Test Platform.select mock + const result = mockedRN.Platform.select({ + ios: 'iOS value', + android: 'Android value', + mockOS: 'Mock OS value', + default: 'Default value', + }); + expect(result).toBe('Mock OS value'); + expect(mockedRN.Platform.select).toHaveBeenCalledTimes(1); + + // Verify other React Native exports are preserved (actual implementation) + expect(mockedRN).toHaveProperty('View'); + expect(mockedRN).toHaveProperty('Text'); + expect(mockedRN).toHaveProperty('StyleSheet'); + expect(typeof mockedRN.View).toBe('object'); + }); + + it('should unmock a previously mocked module', () => { + // Mock a module + const mockFactory = () => ({ mockProperty: 'mocked' }); + mock('react-native', mockFactory); + + // Verify it's mocked + let module = require('react-native'); + expect(module.mockProperty).toBe('mocked'); + + // Unmock it + unmock('react-native'); + + // Verify it's back to actual + module = require('react-native'); + expect(module).not.toHaveProperty('mockProperty'); + expect(module).toHaveProperty('Platform'); // Should have actual RN properties + }); + + it('should reinitialize module after resetModules', () => { + // Mock multiple modules (using the same module for simplicity) + const mockFactory = () => ({ now: Math.random() }); + + mock('react-native', mockFactory); + + // Verify mock is active + const oldNow = require('react-native').now; + + // Reset all modules + resetModules(); + + // Require again, should reinitialize the module + const newNow = require('react-native').now; + expect(newNow).not.toBe(oldNow); + }); +}); diff --git a/apps/playground-vega/src/__tests__/normal/mocking/spies.harness.ts b/apps/playground-vega/src/__tests__/normal/mocking/spies.harness.ts new file mode 100644 index 00000000..a4fc2afe --- /dev/null +++ b/apps/playground-vega/src/__tests__/normal/mocking/spies.harness.ts @@ -0,0 +1,90 @@ +import { + describe, + it, + expect, + afterEach, + fn, + spyOn, + clearAllMocks, +} from 'react-native-harness'; + +afterEach(() => { + clearAllMocks(); +}); + +describe('fn()', () => { + it('should create a mock function', () => { + const mockFn = fn(); + + expect(typeof mockFn).toBe('function'); + expect(mockFn.mock).toBeDefined(); + }); + + it('should track function calls', () => { + const mockFn = fn(); + + mockFn('arg1', 'arg2'); + mockFn('arg3'); + + expect(mockFn).toHaveBeenCalledTimes(2); + expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2'); + expect(mockFn).toHaveBeenLastCalledWith('arg3'); + }); + + it('should support mockReturnValue', () => { + const mockFn = fn(); + mockFn.mockReturnValue('mocked value'); + + expect(mockFn()).toBe('mocked value'); + }); + + it('should support mockImplementation', () => { + const mockFn = fn(); + mockFn.mockImplementation((x: number) => x * 2); + + expect(mockFn(5)).toBe(10); + }); + + it('should clear all mocks', () => { + const mockFn = fn(); + mockFn('test'); + + expect(mockFn).toHaveBeenCalledTimes(1); + + clearAllMocks(); + + expect(mockFn).toHaveBeenCalledTimes(0); + }); +}); + +describe('spyOn()', () => { + it('should spy on existing method', () => { + const obj = { method: (x: number) => x * 2 }; + const spy = spyOn(obj, 'method'); + + const result = obj.method(5); + + expect(result).toBe(10); + expect(spy).toHaveBeenCalledWith(5); + }); + + it('should allow mocking spied method', () => { + const obj = { method: () => 'original' }; + const spy = spyOn(obj, 'method'); + spy.mockReturnValue('mocked'); + + expect(obj.method()).toBe('mocked'); + }); + + it('should restore original method', () => { + const obj = { method: () => 'original' }; + const spy = spyOn(obj, 'method'); + spy.mockReturnValue('mocked'); + + expect(obj.method()).toBe('mocked'); + + spy.mockRestore(); + + expect(obj.method()).toBe('original'); + }); +}); diff --git a/apps/playground-vega/src/__tests__/normal/modifiers/describe-only.harness.ts b/apps/playground-vega/src/__tests__/normal/modifiers/describe-only.harness.ts new file mode 100644 index 00000000..5bec8538 --- /dev/null +++ b/apps/playground-vega/src/__tests__/normal/modifiers/describe-only.harness.ts @@ -0,0 +1,21 @@ +import { describe, test, expect } from 'react-native-harness'; + +describe('Describe modifiers', () => { + describe.only('Only this describe block should run', () => { + test('test inside only describe block', () => { + expect(true).toBe(true); + }); + + test('another test in only describe block', () => { + expect(true).toBe(true); + }); + }); + + describe('This describe should not run due to only above', () => { + test('should throw if executed', () => { + throw new Error( + 'This describe block should not run when another has .only' + ); + }); + }); +}); diff --git a/apps/playground-vega/src/__tests__/normal/modifiers/skip.harness.ts b/apps/playground-vega/src/__tests__/normal/modifiers/skip.harness.ts new file mode 100644 index 00000000..af46ee12 --- /dev/null +++ b/apps/playground-vega/src/__tests__/normal/modifiers/skip.harness.ts @@ -0,0 +1,45 @@ +import { describe, test, expect } from 'react-native-harness'; + +describe('Test modifiers', () => { + describe('test.skip', () => { + test('should run normally when not skipped', () => { + expect(true).toBe(true); + }); + + test.skip('should throw if executed (but it should not execute)', () => { + throw new Error('This test should not run because it is skipped'); + }); + + test('should also run normally', () => { + expect(true).toBe(true); + }); + }); +}); + +describe('Describe modifiers', () => { + describe.skip('Skipped describe block', () => { + test('should throw if executed (in skipped describe)', () => { + throw new Error('This test should not run because describe is skipped'); + }); + + test('should also throw if executed (in skipped describe)', () => { + throw new Error( + 'This test should also not run because describe is skipped' + ); + }); + }); +}); + +describe('Mixed modifiers', () => { + describe.skip('Skipped describe with mixed tests', () => { + test('should throw if executed (normal test in skipped describe)', () => { + throw new Error('This should not run because describe is skipped'); + }); + + test.skip('should throw if executed (skipped test in skipped describe)', () => { + throw new Error( + 'This should not run because both test and describe are skipped' + ); + }); + }); +}); diff --git a/apps/playground-vega/src/__tests__/normal/modifiers/test-only.harness.ts b/apps/playground-vega/src/__tests__/normal/modifiers/test-only.harness.ts new file mode 100644 index 00000000..51259111 --- /dev/null +++ b/apps/playground-vega/src/__tests__/normal/modifiers/test-only.harness.ts @@ -0,0 +1,19 @@ +import { describe, test, expect } from 'react-native-harness'; + +describe('Test modifiers', () => { + describe('test.only', () => { + test('should throw if executed when another test has only', () => { + throw new Error('This test should not run when another test has .only'); + }); + + test.only('should be the only test to run', () => { + expect(true).toBe(true); + }); + + test('should also throw if executed when another test has only', () => { + throw new Error( + 'This test should also not run when another test has .only' + ); + }); + }); +}); diff --git a/apps/playground-vega/src/__tests__/normal/modifiers/todo.harness.ts b/apps/playground-vega/src/__tests__/normal/modifiers/todo.harness.ts new file mode 100644 index 00000000..05fcc9c8 --- /dev/null +++ b/apps/playground-vega/src/__tests__/normal/modifiers/todo.harness.ts @@ -0,0 +1,7 @@ +import { describe, test } from 'react-native-harness'; + +describe('Test modifiers', () => { + describe('test.todo', () => { + test.todo('should be marked as todo'); + }); +}); diff --git a/apps/playground-vega/src/__tests__/normal/render-jsx.harness.tsx b/apps/playground-vega/src/__tests__/normal/render-jsx.harness.tsx new file mode 100644 index 00000000..b896c487 --- /dev/null +++ b/apps/playground-vega/src/__tests__/normal/render-jsx.harness.tsx @@ -0,0 +1,151 @@ +import { + describe, + it, + expect, + render, + fn, + waitFor, +} from 'react-native-harness'; +import React, { useEffect } from 'react'; +import { View, Text } from 'react-native'; + +type TestComponentProps = { + children?: React.ReactNode; + onMount?: () => void; + onUnmount?: () => void; +}; + +const TestComponent = ({ + children, + onMount, + onUnmount, +}: TestComponentProps) => { + useEffect(() => { + onMount?.(); + return () => { + onUnmount?.(); + }; + }, [onMount, onUnmount]); + + return {children}; +}; + +describe('render', () => { + it('should mount component when render is called', async () => { + const onMount = fn(); + const onUnmount = fn(); + + const { unmount } = await render( + + Test + + ); + + expect(onMount).toHaveBeenCalledTimes(1); + expect(onUnmount).toHaveBeenCalledTimes(0); + + unmount(); + }); + + it('should unmount component when unmount is called', async () => { + const onMount = fn(); + const onUnmount = fn(); + + const { unmount } = await render( + + Test + + ); + + expect(onMount).toHaveBeenCalledTimes(1); + expect(onUnmount).toHaveBeenCalledTimes(0); + + unmount(); + + await waitFor(() => { + expect(onUnmount).toHaveBeenCalledTimes(1); + }); + }); + + it('should not remount component when rerender is called', async () => { + const onMount = fn(); + const onUnmount = fn(); + + const { rerender } = await render( + + Initial + + ); + + expect(onMount).toHaveBeenCalledTimes(1); + expect(onUnmount).toHaveBeenCalledTimes(0); + + await rerender( + + Updated + + ); + + expect(onMount).toHaveBeenCalledTimes(1); + expect(onUnmount).toHaveBeenCalledTimes(0); + }); + + it('should mount wrapper when render with wrapper is called', async () => { + const onWrapperMount = fn(); + const onWrapperUnmount = fn(); + + const { unmount } = await render(Child, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(onWrapperMount).toHaveBeenCalledTimes(1); + expect(onWrapperUnmount).toHaveBeenCalledTimes(0); + + unmount(); + }); + + it('should unmount wrapper when unmount is called', async () => { + const onWrapperMount = fn(); + const onWrapperUnmount = fn(); + + const { unmount } = await render(Child, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(onWrapperMount).toHaveBeenCalledTimes(1); + + unmount(); + + await waitFor(() => { + expect(onWrapperUnmount).toHaveBeenCalledTimes(1); + }); + }); + + it('should not remount wrapper when component is rerendered', async () => { + const onWrapperMount = fn(); + const onWrapperUnmount = fn(); + + const { rerender } = await render(Initial, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(onWrapperMount).toHaveBeenCalledTimes(1); + + await rerender(Updated); + + expect(onWrapperMount).toHaveBeenCalledTimes(1); + expect(onWrapperUnmount).toHaveBeenCalledTimes(0); + }); +}); diff --git a/apps/playground-vega/src/__tests__/normal/setup-files.harness.ts b/apps/playground-vega/src/__tests__/normal/setup-files.harness.ts new file mode 100644 index 00000000..ffb765a3 --- /dev/null +++ b/apps/playground-vega/src/__tests__/normal/setup-files.harness.ts @@ -0,0 +1,11 @@ +import { describe, test, expect } from 'react-native-harness'; + +describe('Setup files', () => { + test('should execute setup file', () => { + expect(global.SETUP_FILE_EXECUTED).toBe(true); + }); + + test('should execute setup file after env', () => { + expect(global.SETUP_FILE_EXECUTED_AFTER_ENV).toBe(true); + }); +}); diff --git a/apps/playground-vega/src/__tests__/normal/smoke.harness.ts b/apps/playground-vega/src/__tests__/normal/smoke.harness.ts new file mode 100644 index 00000000..af8fe99c --- /dev/null +++ b/apps/playground-vega/src/__tests__/normal/smoke.harness.ts @@ -0,0 +1,20 @@ +import { describe, test, expect } from 'react-native-harness'; + +describe('Smoke test', () => { + test('should run a simple test', () => { + expect(1 + 1).toBe(2); + }); + + test('should expose task context to tests', (context) => { + expect(context).toBeDefined(); + expect(context.task.type).toBe('test'); + expect(context.task.mode).toBe('run'); + expect(context.task.file.name).toBe('src/__tests__/normal/smoke.harness.ts'); + expect(context.task.suite.name).toBe('Smoke test'); + expect(context.task.name).toBe('should expose task context to tests'); + }); + + test('should report dynamic skips as skipped', (context) => { + context.skip('skip from test context'); + }); +}); diff --git a/apps/playground-vega/src/__tests__/normal/wait.harness.ts b/apps/playground-vega/src/__tests__/normal/wait.harness.ts new file mode 100644 index 00000000..a36e9753 --- /dev/null +++ b/apps/playground-vega/src/__tests__/normal/wait.harness.ts @@ -0,0 +1,193 @@ +import { + describe, + test, + waitFor, + waitUntil, + expect, +} from 'react-native-harness'; + +describe('waitFor', () => { + test('should resolve immediately when callback returns a value', async () => { + const result = await waitFor(() => 'test value'); + expect(result).toBe('test value'); + }); + + test('should wait for async callback to resolve', async () => { + const result = await waitFor(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return 'async value'; + }); + expect(result).toBe('async value'); + }); + + test('should retry until callback succeeds', async () => { + let attempts = 0; + const result = await waitFor(() => { + attempts++; + if (attempts < 3) { + throw new Error('Not ready yet'); + } + return 'success'; + }); + expect(result).toBe('success'); + expect(attempts).toBe(3); + }); + + test('should timeout when callback never succeeds', async () => { + await expect( + waitFor( + () => { + throw new Error('Always fails'); + }, + { timeout: 100 } + ) + ).rejects.toThrow('Always fails'); + }); + + test('should use custom interval and timeout options', async () => { + const start = Date.now(); + let attempts = 0; + + const result = await waitFor( + () => { + attempts++; + if (attempts < 3) { + throw new Error('Not ready'); + } + return 'done'; + }, + { interval: 20, timeout: 200 } + ); + + const elapsed = Date.now() - start; + expect(result).toBe('done'); + expect(elapsed).toBeGreaterThanOrEqual(40); // At least 2 intervals + expect(elapsed).toBeLessThan(200); // Should not timeout + }); + + test('should accept timeout as number shorthand', async () => { + await expect( + waitFor(() => { + throw new Error('Always fails'); + }, 50) // timeout as number + ).rejects.toThrow('Always fails'); + }); +}); + +describe('waitUntil', () => { + test('should resolve when callback returns truthy value', async () => { + const result = await waitUntil(() => 'truthy value'); + expect(result).toBe('truthy value'); + }); + + test('should wait until callback returns truthy value', async () => { + let attempts = 0; + const result = await waitUntil(() => { + attempts++; + return attempts >= 3 ? 'success' : false; + }); + expect(result).toBe('success'); + expect(attempts).toBe(3); + }); + + test('should handle async callback returning truthy value', async () => { + let attempts = 0; + const result = await waitUntil(async () => { + attempts++; + await new Promise((resolve) => setTimeout(resolve, 10)); + return attempts >= 2 ? 'async success' : null; + }); + expect(result).toBe('async success'); + expect(attempts).toBe(2); + }); + + test('should timeout when callback never returns truthy value', async () => { + await expect(waitUntil(() => false, { timeout: 100 })).rejects.toThrow( + 'Timed out in waitUntil!' + ); + }); + + test('should timeout when callback always returns falsy values', async () => { + let attempts = 0; + await expect( + waitUntil( + () => { + attempts++; + return attempts % 2 === 0 ? null : undefined; // Always falsy + }, + { timeout: 100, interval: 20 } + ) + ).rejects.toThrow('Timed out in waitUntil!'); + }); + + test('should use custom interval and timeout options', async () => { + const start = Date.now(); + let attempts = 0; + + const result = await waitUntil( + () => { + attempts++; + return attempts >= 3 ? 'done' : ''; + }, + { interval: 25, timeout: 200 } + ); + + const elapsed = Date.now() - start; + expect(result).toBe('done'); + expect(elapsed).toBeGreaterThanOrEqual(50); // At least 2 intervals + expect(elapsed).toBeLessThan(200); // Should not timeout + }); + + test('should accept timeout as number shorthand', async () => { + await expect( + waitUntil(() => false, 50) // timeout as number + ).rejects.toThrow('Timed out in waitUntil!'); + }); + + test('should handle errors in callback', async () => { + let attempts = 0; + await expect( + waitUntil( + () => { + attempts++; + if (attempts < 5) { + throw new Error('Callback error'); + } + return true; + }, + { timeout: 100, interval: 10 } + ) + ).rejects.toThrow('Callback error'); + }); + + test('should handle promise rejection in async callback', async () => { + let attempts = 0; + await expect( + waitUntil( + async () => { + attempts++; + if (attempts < 5) { + throw new Error('Async callback error'); + } + return true; + }, + { timeout: 100, interval: 10 } + ) + ).rejects.toThrow('Async callback error'); + }); + + test('should distinguish between falsy values correctly', async () => { + const falsyValues = [false, 0, '', null, undefined]; + let index = 0; + + const result = await waitUntil(() => { + if (index < falsyValues.length) { + return falsyValues[index++]; + } + return 'finally truthy'; + }); + + expect(result).toBe('finally truthy'); + expect(index).toBe(falsyValues.length); + }); +}); diff --git a/apps/playground-vega/src/__tests__/only-vega.vega.harness.ts b/apps/playground-vega/src/__tests__/only-vega.vega.harness.ts new file mode 100644 index 00000000..fd2ec058 --- /dev/null +++ b/apps/playground-vega/src/__tests__/only-vega.vega.harness.ts @@ -0,0 +1,10 @@ +import { Platform } from 'react-native'; +import { describe, expect, test } from 'react-native-harness'; + +describe('Vega-only harness test', () => { + test('reports the kepler platform', () => { + // React Native for Vega reports "kepler", which is not part of the + // upstream Platform.OS union. + expect(Platform.OS as string).toBe('kepler'); + }); +}); diff --git a/apps/playground-vega/src/app/App.tsx b/apps/playground-vega/src/app/App.tsx new file mode 100644 index 00000000..ad568cd7 --- /dev/null +++ b/apps/playground-vega/src/app/App.tsx @@ -0,0 +1,36 @@ +import { StyleSheet, Text, View } from 'react-native'; + +export const App = () => { + return ( + + Harness Playground for Vega + + React Native Harness replaces this screen with its own entry point + while tests run. + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#111827', + gap: 12, + padding: 40, + }, + title: { + color: '#f9fafb', + fontSize: 36, + fontWeight: '700', + }, + subtitle: { + color: '#d1d5db', + fontSize: 20, + textAlign: 'center', + }, +}); + +export default App; diff --git a/apps/playground-vega/src/setupFile.ts b/apps/playground-vega/src/setupFile.ts new file mode 100644 index 00000000..729348bd --- /dev/null +++ b/apps/playground-vega/src/setupFile.ts @@ -0,0 +1,6 @@ +declare global { + var SETUP_FILE_EXECUTED: boolean; +} + +global.SETUP_FILE_EXECUTED = true; +export {}; diff --git a/apps/playground-vega/src/setupFileAfterEnv.ts b/apps/playground-vega/src/setupFileAfterEnv.ts new file mode 100644 index 00000000..0a83d255 --- /dev/null +++ b/apps/playground-vega/src/setupFileAfterEnv.ts @@ -0,0 +1,6 @@ +declare global { + var SETUP_FILE_EXECUTED_AFTER_ENV: boolean; +} + +global.SETUP_FILE_EXECUTED_AFTER_ENV = true; +export {}; diff --git a/apps/playground-vega/tsconfig.app.json b/apps/playground-vega/tsconfig.app.json new file mode 100644 index 00000000..bc7ce951 --- /dev/null +++ b/apps/playground-vega/tsconfig.app.json @@ -0,0 +1,37 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "types": ["node"], + "rootDir": "src", + "jsx": "react-native", + "module": "esnext", + "moduleResolution": "bundler", + "noUnusedLocals": false, + "lib": ["dom"], + "tsBuildInfoFile": "dist/tsconfig.app.tsbuildinfo" + }, + "exclude": [ + "out-tsc", + "dist", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx" + ], + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.js", "src/**/*.jsx"], + "references": [ + { + "path": "../../packages/platform-vega/tsconfig.lib.json" + }, + { + "path": "../../packages/jest/tsconfig.lib.json" + }, + { + "path": "../../packages/runtime/tsconfig.lib.json" + }, + { + "path": "../../packages/react-native-harness/tsconfig.lib.json" + } + ] +} diff --git a/apps/playground-vega/tsconfig.json b/apps/playground-vega/tsconfig.json new file mode 100644 index 00000000..782e88f5 --- /dev/null +++ b/apps/playground-vega/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "../../packages/platform-vega" + }, + { + "path": "../../packages/jest" + }, + { + "path": "../../packages/runtime" + }, + { + "path": "../../packages/react-native-harness" + }, + { + "path": "./tsconfig.app.json" + } + ] +} diff --git a/apps/playground/package.json b/apps/playground/package.json index 5af8713c..bedd7ef8 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -34,7 +34,6 @@ "@react-native-harness/jest": "workspace:*", "@react-native-harness/platform-android": "workspace:*", "@react-native-harness/platform-apple": "workspace:*", - "@react-native-harness/platform-vega": "workspace:*", "@react-native-harness/platform-web": "workspace:*", "@react-native-harness/runtime": "workspace:*", "@react-native-harness/ui": "workspace:*", diff --git a/apps/playground/rn-harness.config.mjs b/apps/playground/rn-harness.config.mjs index 47eb1274..f3c51676 100644 --- a/apps/playground/rn-harness.config.mjs +++ b/apps/playground/rn-harness.config.mjs @@ -8,10 +8,6 @@ import { applePhysicalDevice, appleSimulator, } from '@react-native-harness/platform-apple'; -import { - vegaPlatform, - vegaEmulator, -} from '@react-native-harness/platform-vega'; import { webPlatform, chromium, @@ -99,11 +95,6 @@ export default { }, }, }), - vegaPlatform({ - name: 'vega', - device: vegaEmulator('VegaTV_1'), - bundleId: 'com.playground', - }), webPlatform({ name: 'web', browser: chrome('http://localhost:8081/index.html', { headless: false }), diff --git a/apps/playground/tsconfig.app.json b/apps/playground/tsconfig.app.json index 422740eb..51ba7db9 100644 --- a/apps/playground/tsconfig.app.json +++ b/apps/playground/tsconfig.app.json @@ -30,9 +30,6 @@ { "path": "../../packages/platform-web/tsconfig.lib.json" }, - { - "path": "../../packages/platform-vega/tsconfig.lib.json" - }, { "path": "../../packages/platform-ios/tsconfig.lib.json" }, diff --git a/apps/playground/tsconfig.json b/apps/playground/tsconfig.json index 8c369515..8dbc4545 100644 --- a/apps/playground/tsconfig.json +++ b/apps/playground/tsconfig.json @@ -6,9 +6,6 @@ { "path": "../../packages/platform-web" }, - { - "path": "../../packages/platform-vega" - }, { "path": "../../packages/platform-ios" }, diff --git a/packages/bundler-metro/src/__tests__/getHarnessSerializer.test.ts b/packages/bundler-metro/src/__tests__/getHarnessSerializer.test.ts index 02f21865..a7e7bc23 100644 --- a/packages/bundler-metro/src/__tests__/getHarnessSerializer.test.ts +++ b/packages/bundler-metro/src/__tests__/getHarnessSerializer.test.ts @@ -184,6 +184,35 @@ describe('getHarnessSerializer', () => { expect((result as { code: string }).code).toContain('test("x", () => {}'); }); + it('assigns ids to every graph module before the first processModuleFilter call, like Metro does', async () => { + const serializer = getHarnessSerializer({ + harnessEntryPointPath: HARNESS_ENTRY, + }); + const appModule = makeModule(APP_MODULE, 'console.log("app");\n'); + const preModule = makeModule('/repo/node_modules/metro-runtime/require.js', '// prelude\n'); + const mainGraph = makeGraph([appModule]); + mainGraph.entryPoints.add(HARNESS_ENTRY); + + const seenByIdFactory = new Set(); + const createModuleId = makeCreateModuleId(); + const filterCalls: Array<{ path: string; appSeen: boolean }> = []; + const bundleOptions = makeBundleOptions({ + createModuleId: (path) => { + seenByIdFactory.add(path); + return createModuleId(path); + }, + processModuleFilter: (mod) => { + filterCalls.push({ path: mod.path, appSeen: seenByIdFactory.has(APP_MODULE) }); + return true; + }, + }); + + await serializer(HARNESS_ENTRY, [preModule], mainGraph, bundleOptions); + + const firstPreModuleCall = filterCalls.find((call) => call.path === preModule.path); + expect(firstPreModuleCall?.appSeen).toBe(true); + }); + it('does not let a non-matching full bundle serialization clobber the captured set', async () => { const serializer = getHarnessSerializer({ harnessEntryPointPath: HARNESS_ENTRY, diff --git a/packages/bundler-metro/src/getHarnessSerializer.ts b/packages/bundler-metro/src/getHarnessSerializer.ts index df6d7277..b91b5bc3 100644 --- a/packages/bundler-metro/src/getHarnessSerializer.ts +++ b/packages/bundler-metro/src/getHarnessSerializer.ts @@ -246,6 +246,15 @@ export const getHarnessSerializer = ( // dev-mode serving doesn't tree-shake -- but it is the boundary of // what this capture can guarantee. const graphKey = getGraphKey(graph); + // Assign ids to the whole graph before the first filter call, in the + // same order Metro's baseJSBundle does. Config-level filters may + // only learn which modules are external once their id factory has + // seen them (Vega system bundles do this), and a filter answer + // for a pre-module is cached, so asking too early keeps a module in + // the bundle that Metro itself would have dropped. + for (const mod of graph.dependencies.values()) { + bundleOptions.createModuleId(mod.path); + } const capturedModules = collectIncludedModulePaths( preModules, graph, diff --git a/packages/config/src/__tests__/runner-schema.test.ts b/packages/config/src/__tests__/runner-schema.test.ts index 1d068a9e..8a6d0ed1 100644 --- a/packages/config/src/__tests__/runner-schema.test.ts +++ b/packages/config/src/__tests__/runner-schema.test.ts @@ -85,4 +85,19 @@ describe('ConfigSchema runner', () => { }) ).toThrow(); }); + + it('preserves a platform-provided bundlePlatform', () => { + const parsed = ConfigSchema.parse({ + ...baseConfig, + runners: [{ ...runner, bundlePlatform: 'kepler' }], + }); + + expect(parsed.runners[0]?.bundlePlatform).toBe('kepler'); + }); + + it('leaves bundlePlatform undefined when a runner does not set one', () => { + const parsed = ConfigSchema.parse({ ...baseConfig, runners: [runner] }); + + expect(parsed.runners[0]?.bundlePlatform).toBeUndefined(); + }); }); diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 965c6f53..b55ed142 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -22,6 +22,9 @@ const RunnerSchema = z.object({ // runner. Set by a platform factory (`HarnessPlatform.metroConfigEnhancer`); // imported and run by the bundler while it composes the config. metroConfigEnhancer: z.string().optional(), + // The Metro platform the app requests when it differs from platformId + // (e.g. Vega apps request "kepler"). Set by the platform factory. + bundlePlatform: z.string().optional(), platformId: z.string(), // Set by the platform factories (`HarnessPlatform.getResourceLockKey`) to // scope the run's resource lock — e.g. per emulator/simulator/device rather diff --git a/packages/jest/src/harness-session.ts b/packages/jest/src/harness-session.ts index 528e7dcc..0764c1e4 100644 --- a/packages/jest/src/harness-session.ts +++ b/packages/jest/src/harness-session.ts @@ -484,6 +484,9 @@ export const createHarnessSession = async ( const setupStartedAt = Date.now(); const { harnessConfig, platform, projectRoot, configProjectRoot } = await loadConfig(globalConfig); + // Metro platform for prewarm and startup detection. Vega apps request + // "kepler" bundles while the harness platform id stays "vega". + const bundlePlatform = platform.bundlePlatform ?? platform.platformId; applyEnvVars(harnessConfig, globalConfig); const diagnostics = createDiagnostics({ @@ -649,7 +652,7 @@ export const createHarnessSession = async ( const prewarmSpan = diagnostics.start('metro.prewarm'); instance .prewarm({ - platform: platform.platformId, + platform: bundlePlatform, signal: sessionController.signal, }) .then((completed) => prewarmSpan.end({ completed })) @@ -731,7 +734,7 @@ export const createHarnessSession = async ( const appReadyBaseOptions: AppReadyOptions = { metroInstance, bridge, - platformId: platform.platformId, + platformId: bundlePlatform, bundleStartTimeout: runtimeConfig.bundleStartTimeout ?? 60000, readyTimeout: runtimeConfig.bridgeTimeout, maxAppRestarts: runtimeConfig.maxAppRestarts ?? 2, diff --git a/packages/platform-vega/README.md b/packages/platform-vega/README.md index bc232c49..428dbd26 100644 --- a/packages/platform-vega/README.md +++ b/packages/platform-vega/README.md @@ -5,67 +5,88 @@ [![Chat][chat-badge]][chat] [![PRs Welcome][prs-welcome-badge]][prs-welcome] -Vega platform for React Native Harness - enables testing on Vega TV devices and emulators. +Vega platform for React Native Harness — runs your harness tests on the Vega Virtual Device and on Fire TV devices running Vega OS. ## Installation ```bash -npm install @react-native-harness/platform-vega +npm install --save-dev @react-native-harness/platform-vega # or -pnpm add @react-native-harness/platform-vega +pnpm add -D @react-native-harness/platform-vega # or -yarn add @react-native-harness/platform-vega +yarn add -D @react-native-harness/platform-vega ``` ## Usage -Import the Vega platform functions in your `rn-harness.config.mjs`: +Add the Vega platform to your `rn-harness.config.mjs`: ```javascript import { vegaPlatform, - vegaEmulator, + vegaVirtualDevice, + vegaPhysicalDevice, } from '@react-native-harness/platform-vega'; -const config = { +export default { + entryPoint: './index.js', + // React Native for Vega runs the component registered under the interactive + // component id from manifest.toml, so app.json "name" and this value must + // both be ".main". + appRegistryComponentName: 'com.example.myapp.main', runners: [ vegaPlatform({ name: 'vega', - device: vegaEmulator('VegaTV_1'), - bundleId: 'com.your.app', + device: vegaVirtualDevice(), + // The [package] id from manifest.toml + bundleId: 'com.example.myapp', + }), + vegaPlatform({ + name: 'fire-tv', + // The serial number printed by `vega device list` + device: vegaPhysicalDevice('G0X1AB1234567890'), + bundleId: 'com.example.myapp', }), ], - // ... other config }; +``` + +Build a Debug package, then point `HARNESS_APP_PATH` at it. The runner +starts the virtual device when needed, installs the package, forwards Metro's +port to the device and launches the app: -export default config; +```bash +npx react-native build-vega --build-type Debug +HARNESS_APP_PATH=build/aarch64-debug/MyApp_aarch64.vpkg npx react-native-harness --harnessRunner vega ``` +Use the `armv7` package for Fire TV devices. + ## API ### `vegaPlatform(config)` -Creates a Vega platform runner configuration. - **Parameters:** -- `config.name` - Unique name for the runner -- `config.device` - Vega device configuration (emulator) -- `config.bundleId` - Vega application bundle ID +- `config.name` — unique name for the runner. +- `config.device` — `vegaVirtualDevice(deviceId?)` or `vegaPhysicalDevice(serial)`. +- `config.bundleId` — the `[package] id` from `manifest.toml`. The component id defaults to `.main`. +- `config.componentId` — the `[[components.interactive]] id` from `manifest.toml`. The Vega CLI identifies the app by it for every install and launch step. Defaults to `.main`. +- `config.buildType` — build variant of the package in `HARNESS_APP_PATH`, passed to `vega device install-app --buildType`. Defaults to `Debug`, which Harness needs so the app loads its bundle from Metro. -### `vegaEmulator(deviceName)` +### `vegaVirtualDevice(deviceId?)` -Creates a Vega emulator device configuration. +Targets the Vega Virtual Device. `deviceId` defaults to `VirtualDevice`, the name the SDK uses in `vega device list`. -**Parameters:** +### `vegaPhysicalDevice(serial)` -- `deviceName` - Name of the Vega emulator (e.g., 'VegaTV_1') +Targets a Fire TV device in developer mode by the serial number printed by `vega device list`. ## Requirements -- Vega SDK installed -- Vega emulator running -- React Native project configured for Vega platform +- Vega SDK 0.24 or newer with the `vega` CLI on `PATH` (`source ~/vega/env`), or `KEPLER_SDK_PATH` / `VEGA_CLI_PATH` set. +- An app built with `@amazon-devices/react-native-kepler` 4.x (React Native 0.83) as a Debug `.vpkg`. +- Metro reachable from the device on port 8081. Harness forwards the port itself; if it has to run Metro on another port it maps it with the SDK's `vda` tool. ## Made with ❤️ at Callstack diff --git a/packages/platform-vega/package.json b/packages/platform-vega/package.json index ee978635..13480a14 100644 --- a/packages/platform-vega/package.json +++ b/packages/platform-vega/package.json @@ -16,11 +16,20 @@ } }, "dependencies": { + "@react-native-harness/bundler-metro": "workspace:*", "@react-native-harness/config": "workspace:*", "@react-native-harness/platforms": "workspace:*", "@react-native-harness/tools": "workspace:*", "zod": "^3.25.67", "tslib": "^2.3.0" }, + "peerDependencies": { + "metro-config": "*", + "metro-resolver": "*" + }, + "devDependencies": { + "metro-config": "*", + "metro-resolver": "*" + }, "license": "MIT" } diff --git a/packages/platform-vega/src/__tests__/app-session.test.ts b/packages/platform-vega/src/__tests__/app-session.test.ts new file mode 100644 index 00000000..e0d9c791 --- /dev/null +++ b/packages/platform-vega/src/__tests__/app-session.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { OwnedProcess } from '@react-native-harness/tools'; +import { createVegaAppSession } from '../app-session.js'; + +const fakeOwnedProcess = (lines: string[] = []): OwnedProcess => { + const subprocess = Object.assign(Promise.resolve({ stdout: '', stderr: '' }), { + [Symbol.asyncIterator]: async function* () { + yield* lines; + }, + }); + + return { + subprocess: subprocess as unknown as OwnedProcess['subprocess'], + dispose: vi.fn().mockResolvedValue(undefined), + }; +}; + +const createOptions = () => ({ + launchApp: vi.fn().mockResolvedValue(undefined), + terminateApp: vi.fn().mockResolvedValue(undefined), + getAppRunState: vi.fn().mockResolvedValue('running'), + startLogStream: vi.fn(() => fakeOwnedProcess()), + stopLogStream: vi.fn().mockResolvedValue(undefined), +}); + +describe('createVegaAppSession', () => { + it('emits app_exited and reports the exited state once the app is gone', async () => { + const options = createOptions(); + options.getAppRunState.mockResolvedValue('stopped'); + const listener = vi.fn(); + + const session = await createVegaAppSession({ ...options, startGraceMs: 0 }); + session.addListener(listener); + + // Two confirming polls are needed, one poll interval apart. + await vi.waitFor( + () => expect(listener).toHaveBeenCalledWith({ type: 'app_exited' }), + { timeout: 3000 } + ); + expect((await session.getState()).status).toBe('exited'); + }); + + it('ignores failed probes and needs consecutive stopped answers before declaring an exit', async () => { + vi.useFakeTimers(); + try { + const options = createOptions(); + options.getAppRunState + .mockResolvedValueOnce('running') + .mockResolvedValueOnce('unknown') + .mockResolvedValueOnce('stopped') + .mockResolvedValueOnce('unknown') + .mockResolvedValueOnce('stopped') + .mockResolvedValue('stopped'); + + const session = await createVegaAppSession({ ...options, startGraceMs: 0 }); + + // running, unknown, stopped, unknown: still alive after four polls. + await vi.advanceTimersByTimeAsync(3 * 1000 + 10); + expect((await session.getState()).status).toBe('running'); + + // stopped, stopped: now confirmed. + await vi.advanceTimersByTimeAsync(2 * 1000 + 10); + expect((await session.getState()).status).toBe('exited'); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps polling during the start grace window', async () => { + vi.useFakeTimers(); + try { + const options = createOptions(); + options.getAppRunState.mockResolvedValue('stopped'); + + const session = await createVegaAppSession({ + ...options, + startGraceMs: 5000, + }); + + await vi.advanceTimersByTimeAsync(2500); + expect((await session.getState()).status).toBe('running'); + + await vi.advanceTimersByTimeAsync(3000); + expect((await session.getState()).status).toBe('exited'); + } finally { + vi.useRealTimers(); + } + }); + + it('stops the log stream and terminates the app on dispose', async () => { + const options = createOptions(); + const logStream = fakeOwnedProcess(); + options.startLogStream.mockReturnValue(logStream); + + const session = await createVegaAppSession(options); + await session.dispose(); + + expect(logStream.dispose).toHaveBeenCalledOnce(); + expect(options.stopLogStream).toHaveBeenCalledOnce(); + expect(options.terminateApp).toHaveBeenCalledOnce(); + expect((await session.getState()).status).toBe('disposed'); + }); + + it('cleans up the log stream when the launch fails', async () => { + const options = createOptions(); + const logStream = fakeOwnedProcess(); + options.startLogStream.mockReturnValue(logStream); + options.launchApp.mockRejectedValue(new Error('launch failed')); + + await expect(createVegaAppSession(options)).rejects.toThrow('launch failed'); + expect(logStream.dispose).toHaveBeenCalledOnce(); + expect(options.stopLogStream).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/platform-vega/src/__tests__/metro-config-enhancer.test.ts b/packages/platform-vega/src/__tests__/metro-config-enhancer.test.ts new file mode 100644 index 00000000..c6a4ac68 --- /dev/null +++ b/packages/platform-vega/src/__tests__/metro-config-enhancer.test.ts @@ -0,0 +1,217 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CustomResolutionContext } from 'metro-resolver'; +import type { MetroConfig } from 'metro-config'; + +const resolve = vi.hoisted(() => vi.fn<(specifier: string) => string>()); +const requireModule = vi.hoisted(() => vi.fn<(specifier: string) => unknown>()); + +vi.mock('node:module', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createRequire: () => Object.assign(requireModule, { resolve }), + }; +}); + +const { default: enhanceMetroConfig } = await import( + '../metro-config-enhancer.js' +); + +beforeEach(() => { + resolve.mockImplementation(() => { + throw new Error('not resolvable'); + }); + // No package.json and no Vega CLI plugin unless a test says otherwise. + requireModule.mockImplementation(() => { + throw new Error('not found'); + }); +}); + +const context = { + resolveRequest: vi.fn(), +} as unknown as CustomResolutionContext; + +const enhancerContext = { + projectRoot: '/tmp/app', + platformId: 'vega', + platformConfig: {}, +}; + +const baseConfig = (overrides?: Partial): MetroConfig => + ({ + resolver: { platforms: ['ios', 'android', 'native'] }, + serializer: {}, + ...overrides, + }) as MetroConfig; + +describe('vega metroConfigEnhancer', () => { + it('defaults resolver.platforms to kepler first, then ios, android and native', () => { + const enhanced = enhanceMetroConfig( + { resolver: {}, serializer: {} } as MetroConfig, + enhancerContext + ) as MetroConfig; + + expect(enhanced.resolver?.platforms).toEqual([ + 'kepler', + 'ios', + 'android', + 'native', + ]); + }); + + it('keeps a project-defined resolver.platforms list and only ensures native', () => { + const enhanced = enhanceMetroConfig( + baseConfig({ resolver: { platforms: ['kepler', 'ios'] } }), + enhancerContext + ) as MetroConfig; + + expect(enhanced.resolver?.platforms).toEqual(['kepler', 'ios', 'native']); + }); + + it('redirects react-native imports to the kepler package for the kepler platform', () => { + const next = vi.fn(); + + const enhanced = enhanceMetroConfig( + baseConfig({ resolver: { resolveRequest: next } }), + enhancerContext + ) as MetroConfig; + const resolveRequest = enhanced.resolver?.resolveRequest; + + resolveRequest?.(context, 'react-native', 'kepler'); + expect(next).toHaveBeenLastCalledWith( + context, + '@amazon-devices/react-native-kepler', + 'kepler' + ); + + resolveRequest?.(context, 'react-native/Libraries/Core/InitializeCore', 'kepler'); + expect(next).toHaveBeenLastCalledWith( + context, + '@amazon-devices/react-native-kepler/Libraries/Core/InitializeCore', + 'kepler' + ); + }); + + it('leaves imports untouched for other platforms and unrelated modules', () => { + const next = vi.fn(); + + const resolveRequest = ( + enhanceMetroConfig( + baseConfig({ resolver: { resolveRequest: next } }), + enhancerContext + ) as MetroConfig + ).resolver?.resolveRequest; + + resolveRequest?.(context, 'react-native', 'ios'); + expect(next).toHaveBeenLastCalledWith(context, 'react-native', 'ios'); + + resolveRequest?.(context, 'react-native-svg', 'kepler'); + expect(next).toHaveBeenLastCalledWith(context, 'react-native-svg', 'kepler'); + }); + + it('falls back to context.resolveRequest when the project sets no resolveRequest', () => { + const resolveRequest = ( + enhanceMetroConfig(baseConfig(), enhancerContext) as MetroConfig + ).resolver?.resolveRequest; + + resolveRequest?.(context, 'react-native', 'kepler'); + expect(context.resolveRequest).toHaveBeenLastCalledWith( + context, + '@amazon-devices/react-native-kepler', + 'kepler' + ); + }); + + it('appends the kepler InitializeCore to getModulesRunBeforeMainModule', () => { + resolve.mockImplementation( + (specifier) => `/tmp/app/node_modules/${specifier}.js` + ); + + const enhanced = enhanceMetroConfig( + baseConfig({ + serializer: { + getModulesRunBeforeMainModule: () => ['/rn/InitializeCore.js'], + }, + }), + enhancerContext + ) as MetroConfig; + + expect( + enhanced.serializer?.getModulesRunBeforeMainModule?.('index.js') + ).toEqual([ + '/rn/InitializeCore.js', + '/tmp/app/node_modules/@amazon-devices/react-native-kepler/Libraries/Core/InitializeCore.js', + ]); + }); + + it('leaves getModulesRunBeforeMainModule alone when the kepler InitializeCore is not resolvable', () => { + const runBeforeMain = () => ['/rn/InitializeCore.js']; + const enhanced = enhanceMetroConfig( + baseConfig({ + serializer: { getModulesRunBeforeMainModule: runBeforeMain }, + }), + enhancerContext + ) as MetroConfig; + + expect(enhanced.serializer?.getModulesRunBeforeMainModule).toBe( + runBeforeMain + ); + }); + + describe('system bundles', () => { + const utilsPath = '/tmp/app/node_modules/@amazon-devices/kepler-cli-platform/dist/common/systemBundleUtils.js'; + const applySystemBundleConfig = vi.fn(); + + const withPlugin = (pkg: object) => { + resolve.mockImplementation((specifier) => { + if (specifier.endsWith('systemBundleUtils')) return utilsPath; + throw new Error('not resolvable'); + }); + requireModule.mockImplementation((specifier) => { + if (specifier === '/tmp/app/package.json') return pkg; + if (specifier === utilsPath) return { applySystemBundleConfig }; + throw new Error('not found'); + }); + }; + + it('applies the Vega CLI system bundle config for the configured build type', () => { + withPlugin({ kepler: { projectType: 'application' } }); + const merged = { serializer: { processModuleFilter: () => true } } as MetroConfig; + applySystemBundleConfig.mockReturnValue({ config: merged, applied: true }); + + const enhanced = enhanceMetroConfig(baseConfig(), { + ...enhancerContext, + platformConfig: { buildType: 'Release' }, + }); + + expect(applySystemBundleConfig).toHaveBeenCalledWith( + '/tmp/app', + 'Release', + 'index.js', + expect.objectContaining({ resolver: expect.anything() }) + ); + expect(enhanced).toBe(merged); + }); + + it('skips system bundles when the app opts out in package.json', () => { + withPlugin({ kepler: { useSystemJsBundles: false } }); + applySystemBundleConfig.mockClear(); + + const enhanced = enhanceMetroConfig(baseConfig(), enhancerContext) as MetroConfig; + + expect(applySystemBundleConfig).not.toHaveBeenCalled(); + expect(enhanced.resolver?.resolveRequest).toBeTypeOf('function'); + }); + + it('falls back to a regular bundle when the Vega CLI plugin is not installed', () => { + requireModule.mockImplementation((specifier) => { + if (specifier === '/tmp/app/package.json') return { kepler: {} }; + throw new Error('not found'); + }); + + const enhanced = enhanceMetroConfig(baseConfig(), enhancerContext) as MetroConfig; + + expect(enhanced.resolver?.resolveRequest).toBeTypeOf('function'); + }); + }); +}); diff --git a/packages/platform-vega/src/__tests__/runner.test.ts b/packages/platform-vega/src/__tests__/runner.test.ts index 465c64d0..1bcb285a 100644 --- a/packages/platform-vega/src/__tests__/runner.test.ts +++ b/packages/platform-vega/src/__tests__/runner.test.ts @@ -1,59 +1,318 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fileURLToPath } from 'node:url'; import { DEFAULT_METRO_PORT, type Config as HarnessConfig, } from '@react-native-harness/config'; -import type { VegaPlatformConfig } from '../config.js'; +import { + DependencyNotFoundError, + DeviceNotFoundError, +} from '@react-native-harness/platforms'; +import type { OwnedProcess } from '@react-native-harness/tools'; +import type { VegaPlatformConfigInput } from '../config.js'; +import { VegaAppPathError, VegaMetroPortError } from '../errors.js'; -const stopAppMock = vi.fn().mockResolvedValue(undefined); +const fakeOwnedProcess = (lines: string[] = []): OwnedProcess => { + const subprocess = Object.assign(Promise.resolve({ stdout: '', stderr: '' }), { + [Symbol.asyncIterator]: async function* () { + yield* lines; + }, + }); -vi.mock('../kepler.js', () => ({ - getVegaDeviceStatus: vi.fn().mockResolvedValue('running'), - isAppInstalled: vi.fn().mockResolvedValue(true), - isAppRunning: vi.fn().mockResolvedValue(false), - startApp: vi.fn().mockResolvedValue(undefined), - stopApp: stopAppMock, + return { + subprocess: subprocess as unknown as OwnedProcess['subprocess'], + dispose: vi.fn().mockResolvedValue(undefined), + }; +}; + +const vega = vi.hoisted(() => ({ + ensureVegaCliAvailable: vi.fn<() => Promise>(), + findConnectedVirtualDevice: vi.fn<() => Promise>(), + startVirtualDevice: vi.fn<() => Promise>(), + waitForVirtualDevice: vi.fn<() => Promise>(), + stopVirtualDevice: vi.fn<() => Promise>(), + isDeviceConnected: vi.fn<() => Promise>(), + isAppInstalled: vi.fn<() => Promise>(), + installApp: vi.fn<() => Promise>(), + getAppRunState: vi.fn<() => Promise<'running' | 'stopped' | 'unknown'>>(), + launchApp: vi.fn<() => Promise>(), + terminateApp: vi.fn<() => Promise>(), + startPortForwarding: vi.fn<() => OwnedProcess>(), + stopPortForwarding: vi.fn<() => Promise>(), + getVdaBinaryPath: vi.fn<() => Promise>(), + resolveVdaSerial: vi.fn<() => Promise>(), + vdaReverse: vi.fn<() => Promise>(), + vdaRemoveReverse: vi.fn<() => Promise>(), + startLogStream: vi.fn<() => OwnedProcess>(), + stopLogStream: vi.fn<() => Promise>(), })); +vi.mock('../vega.js', () => vega); + const harnessConfig = { metroPort: DEFAULT_METRO_PORT } as HarnessConfig; -const config: VegaPlatformConfig = { +const virtualConfig: VegaPlatformConfigInput = { name: 'vega', - device: { type: 'emulator', deviceId: 'VegaTV_1' }, + device: { type: 'emulator', deviceId: 'VirtualDevice' }, bundleId: 'com.example.app', }; +const physicalConfig: VegaPlatformConfigInput = { + name: 'fire-tv', + device: { type: 'physical', serial: 'G0X1AB1234567890' }, + bundleId: 'com.example.app', +}; + +const init = () => ({ signal: new AbortController().signal }); + +const loadRunner = async () => (await import('../runner.js')).default; + +beforeEach(() => { + vi.unstubAllEnvs(); + vi.stubEnv('HARNESS_APP_PATH', ''); + vega.ensureVegaCliAvailable.mockResolvedValue(undefined); + vega.findConnectedVirtualDevice.mockResolvedValue('VirtualDevice'); + vega.waitForVirtualDevice.mockResolvedValue('VirtualDevice'); + vega.isDeviceConnected.mockResolvedValue(true); + vega.isAppInstalled.mockResolvedValue(true); + vega.getAppRunState.mockResolvedValue('running'); + vega.launchApp.mockResolvedValue(undefined); + vega.terminateApp.mockResolvedValue(undefined); + vega.startPortForwarding.mockImplementation(() => fakeOwnedProcess()); + vega.stopPortForwarding.mockResolvedValue(undefined); + vega.getVdaBinaryPath.mockResolvedValue(null); + vega.startLogStream.mockImplementation(() => fakeOwnedProcess()); + vega.stopLogStream.mockResolvedValue(undefined); +}); + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); +}); + describe('getVegaRunner', () => { it('is invoked with the (config, harnessConfig, init) shape the harness session uses', async () => { - // Regression test for the bug where the session called - // module.default(config, runtimeConfig, init), but this runner declared - // only (config, init?) — so runtimeConfig landed in the init slot and - // init.signal.aborted threw. Asserting the arity here catches an - // optional-parameter regression, since optional params drop out of - // Function.length. - const { default: getVegaRunner } = await import('../runner.js'); + // The session calls module.default(config, runtimeConfig, init); an + // optional `init` param would drop out of Function.length and silently + // break `init.signal`. + const getVegaRunner = await loadRunner(); expect(getVegaRunner.length).toBe(3); + }); + + it('throws DependencyNotFoundError when the Vega CLI is missing', async () => { + vega.ensureVegaCliAvailable.mockRejectedValue( + new DependencyNotFoundError('vega') + ); + const getVegaRunner = await loadRunner(); + + await expect( + getVegaRunner(virtualConfig, harnessConfig, init()) + ).rejects.toBeInstanceOf(DependencyNotFoundError); + }); + + it('reuses a running virtual device and leaves it running on dispose', async () => { + const getVegaRunner = await loadRunner(); + + const runner = await getVegaRunner(virtualConfig, harnessConfig, init()); + await runner.dispose(); + + expect(vega.startVirtualDevice).not.toHaveBeenCalled(); + expect(vega.stopVirtualDevice).not.toHaveBeenCalled(); + }); + + it('starts the virtual device when it is not running and stops it on dispose', async () => { + vega.findConnectedVirtualDevice.mockResolvedValue(null); + const getVegaRunner = await loadRunner(); + + const runner = await getVegaRunner(virtualConfig, harnessConfig, init()); + + expect(vega.startVirtualDevice).toHaveBeenCalledOnce(); + expect(vega.waitForVirtualDevice).toHaveBeenCalledWith( + 'VirtualDevice', + expect.any(AbortSignal) + ); + + await runner.dispose(); + expect(vega.stopVirtualDevice).toHaveBeenCalledOnce(); + }); + + it('stops a virtual device it started when waiting for it fails', async () => { + vega.findConnectedVirtualDevice.mockResolvedValue(null); + vega.waitForVirtualDevice.mockRejectedValue(new Error('did not become ready')); + const getVegaRunner = await loadRunner(); + + await expect( + getVegaRunner(virtualConfig, harnessConfig, init()) + ).rejects.toThrow('did not become ready'); + expect(vega.stopVirtualDevice).toHaveBeenCalledOnce(); + }); + + it('stops a virtual device it started when the app setup fails', async () => { + vega.findConnectedVirtualDevice.mockResolvedValue(null); + vega.isAppInstalled.mockResolvedValue(false); + const getVegaRunner = await loadRunner(); + + await expect( + getVegaRunner(virtualConfig, harnessConfig, init()) + ).rejects.toBeInstanceOf(VegaAppPathError); + expect(vega.stopVirtualDevice).toHaveBeenCalledOnce(); + }); + + it('throws DeviceNotFoundError when the physical device is not connected', async () => { + vega.isDeviceConnected.mockResolvedValue(false); + const getVegaRunner = await loadRunner(); + + await expect( + getVegaRunner(physicalConfig, harnessConfig, init()) + ).rejects.toBeInstanceOf(DeviceNotFoundError); + expect(vega.isDeviceConnected).toHaveBeenCalledWith('G0X1AB1234567890'); + }); + + it('throws VegaAppPathError when the app is not installed and HARNESS_APP_PATH is unset', async () => { + vega.isAppInstalled.mockResolvedValue(false); + const getVegaRunner = await loadRunner(); + + await expect( + getVegaRunner(virtualConfig, harnessConfig, init()) + ).rejects.toBeInstanceOf(VegaAppPathError); + expect(vega.installApp).not.toHaveBeenCalled(); + }); + + it('installs the app from HARNESS_APP_PATH without checking the installed state', async () => { + const appPath = fileURLToPath(import.meta.url); + vi.stubEnv('HARNESS_APP_PATH', appPath); + const getVegaRunner = await loadRunner(); + + await getVegaRunner(virtualConfig, harnessConfig, init()); + + expect(vega.isAppInstalled).not.toHaveBeenCalled(); + expect(vega.installApp).toHaveBeenCalledWith('VirtualDevice', appPath, { + signal: expect.any(AbortSignal), + buildType: 'Debug', + }); + }); + + it('throws VegaAppPathError when HARNESS_APP_PATH points to a missing file', async () => { + vi.stubEnv('HARNESS_APP_PATH', '/definitely/missing/app.vpkg'); + const getVegaRunner = await loadRunner(); + + await expect( + getVegaRunner(virtualConfig, harnessConfig, init()) + ).rejects.toBeInstanceOf(VegaAppPathError); + }); + + it('launches and terminates the default main component', async () => { + const getVegaRunner = await loadRunner(); + + const runner = await getVegaRunner(virtualConfig, harnessConfig, init()); + const session = await runner.createAppSession(); + + expect(vega.launchApp).toHaveBeenCalledWith( + 'VirtualDevice', + 'com.example.app.main' + ); + expect((await session.getState()).status).toBe('running'); + + await session.dispose(); + expect(vega.terminateApp).toHaveBeenCalledWith( + 'VirtualDevice', + 'com.example.app.main' + ); + expect((await session.getState()).status).toBe('disposed'); + }); + + it('honours a custom componentId', async () => { + const getVegaRunner = await loadRunner(); + + const runner = await getVegaRunner( + { ...virtualConfig, componentId: 'com.example.app.tv' }, + harnessConfig, + init() + ); + await runner.createAppSession(); + + expect(vega.launchApp).toHaveBeenCalledWith( + 'VirtualDevice', + 'com.example.app.tv' + ); + }); + + it('holds reverse port forwarding for Metro on 8081 until dispose', async () => { + const forwarding = fakeOwnedProcess(); + vega.startPortForwarding.mockReturnValue(forwarding); + const getVegaRunner = await loadRunner(); + + const runner = await getVegaRunner(virtualConfig, harnessConfig, init()); + expect(vega.startPortForwarding).toHaveBeenCalledWith('VirtualDevice', 8081); + expect(forwarding.dispose).not.toHaveBeenCalled(); + + await runner.dispose(); + expect(forwarding.dispose).toHaveBeenCalledOnce(); + expect(vega.stopPortForwarding).toHaveBeenCalledWith('VirtualDevice', 8081); + }); + + it('throws VegaMetroPortError for a non-8081 Metro port without vda', async () => { + const getVegaRunner = await loadRunner(); + + await expect( + getVegaRunner(virtualConfig, { metroPort: 8082 } as HarnessConfig, init()) + ).rejects.toBeInstanceOf(VegaMetroPortError); + expect(vega.startPortForwarding).not.toHaveBeenCalled(); + }); + + it('maps device port 8081 to a non-8081 Metro port with vda when available', async () => { + vega.getVdaBinaryPath.mockResolvedValue('/opt/vega/bin/vda'); + vega.resolveVdaSerial.mockResolvedValue('emulator-5554'); + vega.vdaReverse.mockResolvedValue(undefined); + vega.vdaRemoveReverse.mockResolvedValue(undefined); + const getVegaRunner = await loadRunner(); + + const runner = await getVegaRunner( + virtualConfig, + { metroPort: 8082 } as HarnessConfig, + init() + ); + + expect(vega.resolveVdaSerial).toHaveBeenCalledWith('VirtualDevice', true); + expect(vega.vdaReverse).toHaveBeenCalledWith('emulator-5554', 8081, 8082); + expect(vega.startPortForwarding).not.toHaveBeenCalled(); + + await runner.dispose(); + expect(vega.vdaRemoveReverse).toHaveBeenCalledWith('emulator-5554', 8081); + }); + + it('exposes device log lines through getLogs', async () => { + vega.startLogStream.mockImplementation(() => + fakeOwnedProcess(['boot ok', 'app started']) + ); + const getVegaRunner = await loadRunner(); + + const runner = await getVegaRunner(virtualConfig, harnessConfig, init()); + const session = await runner.createAppSession(); - const init = { signal: new AbortController().signal }; - const runner = await getVegaRunner(config, harnessConfig, init); - expect(runner.createAppSession).toBeTypeOf('function'); - expect(runner.dispose).toBeTypeOf('function'); + await vi.waitFor(() => + expect(session.getLogs().map((entry) => entry.line)).toEqual([ + 'boot ok', + 'app started', + ]) + ); + await session.dispose(); }); it('does not stop the app when the init signal aborts after session creation', async () => { - const { default: getVegaRunner } = await import('../runner.js'); + const getVegaRunner = await loadRunner(); const controller = new AbortController(); - const runner = await getVegaRunner(config, harnessConfig, { + const runner = await getVegaRunner(virtualConfig, harnessConfig, { signal: controller.signal, }); await runner.createAppSession(); - stopAppMock.mockClear(); + vega.terminateApp.mockClear(); controller.abort(); - expect(stopAppMock).not.toHaveBeenCalled(); + expect(vega.terminateApp).not.toHaveBeenCalled(); await runner.dispose(); - expect(stopAppMock).toHaveBeenCalledOnce(); + expect(vega.terminateApp).toHaveBeenCalledOnce(); }); }); diff --git a/packages/platform-vega/src/__tests__/vega.test.ts b/packages/platform-vega/src/__tests__/vega.test.ts new file mode 100644 index 00000000..2fb72987 --- /dev/null +++ b/packages/platform-vega/src/__tests__/vega.test.ts @@ -0,0 +1,383 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import path from 'node:path'; +import { SubprocessError } from '@react-native-harness/tools'; + +const mocks = vi.hoisted(() => ({ + runCommand: + vi.fn< + ( + file: string, + args: readonly string[], + options?: unknown + ) => Promise<{ stdout: string; stderr: string }> + >(), + existsSync: vi.fn<(file: string) => boolean>(), + homedir: vi.fn<() => string>(), +})); + +vi.mock('@react-native-harness/tools', async (importOriginal) => ({ + ...(await importOriginal()), + runCommand: mocks.runCommand, +})); + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { ...actual, existsSync: mocks.existsSync }, + existsSync: mocks.existsSync, + }; +}); + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + default: { ...actual, homedir: mocks.homedir }, + homedir: mocks.homedir, + }; +}); + +const vega = await import('../vega.js'); + +beforeEach(() => { + vega.resetVegaBinaryCache(); + vi.stubEnv('PATH', '/usr/bin'); + vi.stubEnv('KEPLER_SDK_PATH', ''); + vi.stubEnv('VEGA_CLI_PATH', ''); + mocks.homedir.mockReturnValue('/home/dev'); + mocks.existsSync.mockReturnValue(false); + mocks.runCommand.mockResolvedValue({ stdout: '', stderr: '' }); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); +}); + +describe('parseDeviceList', () => { + it('parses virtual and physical device lines', () => { + const entries = vega.parseDeviceList( + [ + 'Connected devices:', + 'G0X1AB1234567890 : A1ZZ32RVTQ796E', + 'VirtualDevice : tv - x86_64 - OS - amazon-ab123456cd789012', + '', + ].join('\n') + ); + + expect(entries).toEqual([ + { + id: 'G0X1AB1234567890', + description: 'A1ZZ32RVTQ796E', + isVirtual: false, + }, + { + id: 'VirtualDevice', + description: 'tv - x86_64 - OS - amazon-ab123456cd789012', + isVirtual: true, + }, + ]); + }); + + it('recognises a virtual device listed under its serial', () => { + const entries = vega.parseDeviceList( + 'amazon-d82a77d000723901 : Vega Virtual Device - tv - aarch64' + ); + + expect(entries[0]?.isVirtual).toBe(true); + }); +}); + +describe('resolveVegaBinary', () => { + it('prefers VEGA_CLI_PATH', () => { + vi.stubEnv('VEGA_CLI_PATH', '/custom/vega'); + + expect(vega.resolveVegaBinary()).toBe('/custom/vega'); + expect(mocks.existsSync).not.toHaveBeenCalled(); + }); + + it('finds vega on PATH before the legacy kepler name', () => { + mocks.existsSync.mockImplementation( + (file) => file === path.join('/usr/bin', 'vega') || file === path.join('/usr/bin', 'kepler') + ); + + expect(vega.resolveVegaBinary()).toBe(path.join('/usr/bin', 'vega')); + }); + + it('falls back to KEPLER_SDK_PATH and the default SDK location', () => { + vi.stubEnv('KEPLER_SDK_PATH', '/opt/vega-sdk'); + mocks.existsSync.mockImplementation( + (file) => file === path.join('/opt/vega-sdk', 'bin', 'kepler') + ); + expect(vega.resolveVegaBinary()).toBe( + path.join('/opt/vega-sdk', 'bin', 'kepler') + ); + + vega.resetVegaBinaryCache(); + vi.stubEnv('KEPLER_SDK_PATH', ''); + mocks.existsSync.mockImplementation( + (file) => file === path.join('/home/dev', 'vega', 'bin', 'vega') + ); + expect(vega.resolveVegaBinary()).toBe( + path.join('/home/dev', 'vega', 'bin', 'vega') + ); + }); + + it('returns null when nothing is installed', () => { + expect(vega.resolveVegaBinary()).toBeNull(); + }); +}); + +describe('commands', () => { + beforeEach(() => { + vi.stubEnv('VEGA_CLI_PATH', '/opt/vega/bin/vega'); + }); + + it('rejects with DependencyNotFoundError when the CLI is missing', async () => { + vi.stubEnv('VEGA_CLI_PATH', ''); + + await expect(vega.ensureVegaCliAvailable()).rejects.toThrow( + /Dependency "vega" not found/ + ); + }); + + it('reports a connected device', async () => { + mocks.runCommand.mockResolvedValue({ + stdout: 'Device VirtualDevice is connected', + stderr: '', + }); + + await expect(vega.isDeviceConnected('VirtualDevice')).resolves.toBe(true); + expect(mocks.runCommand).toHaveBeenCalledWith( + '/opt/vega/bin/vega', + ['device', 'is-connected', '--device', 'VirtualDevice'], + expect.objectContaining({ timeoutMs: expect.any(Number) }) + ); + }); + + it('ignores stderr when reading the connection state', async () => { + mocks.runCommand.mockResolvedValueOnce({ + stdout: 'Device VirtualDevice is connected', + stderr: 'warn: previous device is not connected', + }); + + await expect(vega.isDeviceConnected('VirtualDevice')).resolves.toBe(true); + }); + + it('gives up waiting for the virtual device after the timeout', async () => { + mocks.runCommand.mockResolvedValue({ stdout: '', stderr: '' }); + + await expect( + vega.waitForVirtualDevice('VirtualDevice', new AbortController().signal, { + pollIntervalMs: 1, + timeoutMs: 20, + }) + ).rejects.toThrow(/did not become ready within 20 ms/); + }); + + it('parses vda device serials', () => { + expect( + vega.parseVdaDevices( + 'List of devices attached\nemulator-5554\tdevice\nG0X1AB1234567890\toffline\n\n' + ) + ).toEqual(['emulator-5554']); + }); + + it('resolves the vda serial for the target device', async () => { + const devices = (...serials: string[]) => ({ + stdout: ['List of devices attached', ...serials.map((s) => `${s}\tdevice`)].join('\n'), + stderr: '', + }); + mocks.existsSync.mockReturnValue(true); + mocks.runCommand.mockImplementation(async (_file, args) => { + if (args[0] === 'which') return { stdout: '/sdk/vda', stderr: '' }; + return devices('emulator-5554', 'G0X1AB1234567890'); + }); + + await expect(vega.resolveVdaSerial('G0X1AB1234567890', false)).resolves.toBe( + 'G0X1AB1234567890' + ); + await expect(vega.resolveVdaSerial('VirtualDevice', true)).resolves.toBe( + 'emulator-5554' + ); + await expect(vega.resolveVdaSerial('OTHER', false)).rejects.toThrow( + /Could not tell which vda device/ + ); + + mocks.runCommand.mockImplementation(async (_file, args) => { + if (args[0] === 'which') return { stdout: '/sdk/vda', stderr: '' }; + return devices('emulator-5554'); + }); + await expect(vega.resolveVdaSerial('G0X1AB1234567890', false)).resolves.toBe( + 'emulator-5554' + ); + }); + + it('reports a disconnected device from a non-zero exit or a negative message', async () => { + mocks.runCommand.mockRejectedValueOnce(new Error('exit 1')); + await expect(vega.isDeviceConnected('VirtualDevice')).resolves.toBe(false); + + mocks.runCommand.mockResolvedValueOnce({ + stdout: 'Device VirtualDevice is not connected', + stderr: '', + }); + await expect(vega.isDeviceConnected('VirtualDevice')).resolves.toBe(false); + }); + + it('reports unknown when the running probe fails instead of claiming the app stopped', async () => { + mocks.runCommand.mockRejectedValueOnce(new Error('exit 1')); + await expect( + vega.getAppRunState('VirtualDevice', 'com.example.app.main') + ).resolves.toBe('unknown'); + + mocks.runCommand.mockResolvedValueOnce({ + stdout: 'com.example.app.main is not running on VirtualDevice', + stderr: '', + }); + await expect( + vega.getAppRunState('VirtualDevice', 'com.example.app.main') + ).resolves.toBe('stopped'); + + mocks.runCommand.mockResolvedValueOnce({ + stdout: 'com.example.app.main is running on VirtualDevice', + stderr: '', + }); + await expect( + vega.getAppRunState('VirtualDevice', 'com.example.app.main') + ).resolves.toBe('running'); + }); + + it('treats a false answer from is-app-installed as not installed', async () => { + mocks.runCommand.mockResolvedValueOnce({ stdout: 'false', stderr: '' }); + await expect( + vega.isAppInstalled('VirtualDevice', 'com.example.app.main') + ).resolves.toBe(false); + + mocks.runCommand.mockResolvedValueOnce({ stdout: 'true', stderr: '' }); + await expect( + vega.isAppInstalled('VirtualDevice', 'com.example.app.main') + ).resolves.toBe(true); + }); + + it('installs a package with the device and package path flags', async () => { + await vega.installApp('VirtualDevice', '/tmp/app.vpkg', { + signal: new AbortController().signal, + buildType: 'Debug', + }); + + expect(mocks.runCommand).toHaveBeenCalledWith( + '/opt/vega/bin/vega', + [ + 'device', + 'install-app', + '--device', + 'VirtualDevice', + '--packagePath', + '/tmp/app.vpkg', + '--buildType', + 'Debug', + ], + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + }); + + it('locates vda through the CLI before scanning bin directories', async () => { + mocks.existsSync.mockImplementation((file) => file === '/sdk/workspace/vda'); + mocks.runCommand.mockResolvedValueOnce({ + stdout: 'Active SDK Version: 0.24\n/sdk/workspace/vda\n', + stderr: '', + }); + + await expect(vega.getVdaBinaryPath()).resolves.toBe('/sdk/workspace/vda'); + expect(mocks.runCommand).toHaveBeenCalledWith( + '/opt/vega/bin/vega', + ['which', 'vda'], + expect.anything() + ); + }); + + it('finds a connected virtual device by its preferred name or as the only virtual entry', async () => { + mocks.runCommand.mockResolvedValue({ + stdout: 'amazon-1234 : Vega Virtual Device - tv - aarch64', + stderr: '', + }); + + await expect(vega.findConnectedVirtualDevice('VirtualDevice')).resolves.toBe( + 'amazon-1234' + ); + }); + + it('waits for the virtual device until it accepts package manager commands repeatedly', async () => { + const installedAppsCalls = () => + mocks.runCommand.mock.calls.filter(([, args]) => + args.includes('installed-apps') + ).length; + mocks.runCommand.mockImplementation(async (_file, args) => { + const command = args.join(' '); + if (command.startsWith('device list')) { + return { stdout: 'VirtualDevice : tv - aarch64 - OS - amazon-1', stderr: '' }; + } + if (command.startsWith('device is-connected')) { + return { stdout: 'Device VirtualDevice is connected', stderr: '' }; + } + if (command.startsWith('device installed-apps')) { + // Two failures, then it must answer three times in a row. + if (installedAppsCalls() <= 2) { + throw new Error('package manager not ready'); + } + return { stdout: 'com.example.app.main', stderr: '' }; + } + return { stdout: '', stderr: '' }; + }); + + await expect( + vega.waitForVirtualDevice('VirtualDevice', new AbortController().signal, { + pollIntervalMs: 1, + }) + ).resolves.toBe('VirtualDevice'); + expect(installedAppsCalls()).toBe(5); + }); + + it('retries a failed install and reports the CLI error after the last attempt', async () => { + const failure = Object.assign(new SubprocessError('Command failed'), { + stderr: 'error: device offline (transport offline)', + }); + mocks.runCommand.mockRejectedValueOnce(failure).mockResolvedValueOnce({ + stdout: 'Installing ...success', + stderr: '', + }); + + await expect( + vega.installApp('VirtualDevice', '/tmp/app.vpkg', { + signal: new AbortController().signal, + buildType: 'Debug', + retryDelayMs: 1, + }) + ).resolves.toBeUndefined(); + expect(mocks.runCommand).toHaveBeenCalledTimes(2); + + mocks.runCommand.mockRejectedValue(failure); + await expect( + vega.installApp('VirtualDevice', '/tmp/app.vpkg', { + signal: new AbortController().signal, + buildType: 'Debug', + attempts: 2, + retryDelayMs: 1, + }) + ).rejects.toThrow(/failed after 2 attempt\(s\): error: device offline/); + }); + + it('waits for the virtual device until the signal aborts', async () => { + mocks.runCommand.mockResolvedValue({ stdout: '', stderr: '' }); + const controller = new AbortController(); + const waiting = vega.waitForVirtualDevice('VirtualDevice', controller.signal, { + pollIntervalMs: 1, + }); + const assertion = expect(waiting).rejects.toThrow(/aborted/i); + + await new Promise((resolve) => setTimeout(resolve, 5)); + controller.abort(new Error('aborted')); + await assertion; + }); +}); diff --git a/packages/platform-vega/src/app-path.ts b/packages/platform-vega/src/app-path.ts new file mode 100644 index 00000000..ae8d47d4 --- /dev/null +++ b/packages/platform-vega/src/app-path.ts @@ -0,0 +1,16 @@ +import fs from 'node:fs'; +import { VegaAppPathError } from './errors.js'; + +export const getOptionalHarnessAppPath = (): string | undefined => { + const appPath = process.env.HARNESS_APP_PATH; + + if (!appPath) { + return undefined; + } + + if (!fs.existsSync(appPath)) { + throw new VegaAppPathError('invalid', appPath); + } + + return appPath; +}; diff --git a/packages/platform-vega/src/app-session.ts b/packages/platform-vega/src/app-session.ts new file mode 100644 index 00000000..33c9e7b5 --- /dev/null +++ b/packages/platform-vega/src/app-session.ts @@ -0,0 +1,163 @@ +import { + createAppSessionEmitter, + createBoundedLogBuffer, + type AppSession, + type AppSessionState, +} from '@react-native-harness/platforms'; +import { logger, type OwnedProcess } from '@react-native-harness/tools'; +import type { VegaAppRunState } from './vega.js'; + +const log = logger.child('platform-vega'); + +const APP_EXIT_POLL_INTERVAL_MS = 1000; +// launch-app can return before the process shows up in is-app-running. +const APP_START_GRACE_MS = 10_000; +// A single "not running" answer can come from a half-broken transport, so +// the exit is only declared after consecutive confirmed answers. +const APP_EXIT_CONFIRMATIONS = 2; + +export type CreateVegaAppSessionOptions = { + launchApp: () => Promise; + terminateApp: () => Promise; + getAppRunState: () => Promise; + startLogStream: () => OwnedProcess; + stopLogStream: () => Promise; + startGraceMs?: number; +}; + +export const createVegaAppSession = async ({ + launchApp, + terminateApp, + getAppRunState, + startLogStream, + stopLogStream, + startGraceMs = APP_START_GRACE_MS, +}: CreateVegaAppSessionOptions): Promise => { + const emitter = createAppSessionEmitter(); + const logBuffer = createBoundedLogBuffer(); + let state: AppSessionState = { status: 'running' }; + let disposed = false; + let stopPolling = false; + let pollDelayTimeout: ReturnType | null = null; + let resolvePollDelay: (() => void) | null = null; + let exitNotification: ReturnType | null = null; + + // Deferred so a caller that attaches a listener right after + // createAppSession() resolves still receives the event. + const scheduleExitNotification = () => { + exitNotification = setTimeout(() => { + exitNotification = null; + + if (!disposed && state.status === 'exited') { + emitter.emit({ type: 'app_exited' }); + } + }, 0); + }; + + // This wait is awaited directly by the poll loop, so cancelling it must + // also resolve it or dispose() blocks for up to one poll interval. + const waitForNextPoll = () => + new Promise((resolve) => { + resolvePollDelay = () => { + resolvePollDelay = null; + pollDelayTimeout = null; + resolve(); + }; + + pollDelayTimeout = setTimeout(() => { + resolvePollDelay?.(); + }, APP_EXIT_POLL_INTERVAL_MS); + }); + + const cancelPendingPollDelay = () => { + if (pollDelayTimeout) { + clearTimeout(pollDelayTimeout); + pollDelayTimeout = null; + } + + resolvePollDelay?.(); + }; + + const ownedLogStream = startLogStream(); + const logTask = (async () => { + try { + for await (const line of ownedLogStream.subprocess) { + logBuffer.push(String(line)); + } + } catch (error) { + if (!disposed) { + log.debug('log stream ended: %s', error); + } + } + })(); + + try { + await launchApp(); + } catch (error) { + disposed = true; + await ownedLogStream.dispose(); + await stopLogStream(); + await logTask; + throw error; + } + + const launchedAt = Date.now(); + + let stoppedAnswers = 0; + + const pollTask = (async () => { + while (!stopPolling) { + const runState = await getAppRunState(); + // "unknown" means the probe itself failed; only a confirmed "stopped" + // counts, and it has to repeat before the app is declared gone. + stoppedAnswers = runState === 'stopped' ? stoppedAnswers + 1 : 0; + + if ( + stoppedAnswers >= APP_EXIT_CONFIRMATIONS && + Date.now() - launchedAt >= startGraceMs + ) { + if (!disposed && state.status === 'running') { + state = { + status: 'exited', + occurredAt: Date.now(), + reason: 'process-gone', + }; + scheduleExitNotification(); + } + return; + } + + if (stopPolling) { + return; + } + + await waitForNextPoll(); + } + })(); + + return { + dispose: async () => { + if (disposed) { + return; + } + + disposed = true; + stopPolling = true; + cancelPendingPollDelay(); + if (exitNotification) { + clearTimeout(exitNotification); + exitNotification = null; + } + state = { status: 'disposed', occurredAt: Date.now() }; + emitter.clear(); + await ownedLogStream.dispose(); + await stopLogStream(); + await terminateApp(); + await Promise.allSettled([logTask, pollTask]); + }, + getState: async () => state, + getLogs: () => logBuffer.getLogs(), + addListener: emitter.addListener, + removeListener: emitter.removeListener, + }; +}; diff --git a/packages/platform-vega/src/config.ts b/packages/platform-vega/src/config.ts index 5438c8dc..c346439d 100644 --- a/packages/platform-vega/src/config.ts +++ b/packages/platform-vega/src/config.ts @@ -2,41 +2,59 @@ import { z } from 'zod'; export const VegaAppLaunchOptionsSchema = z.object({}); -export const VegaEmulatorSchema = z.object({ +export const VegaVirtualDeviceSchema = z.object({ type: z.literal('emulator'), - deviceId: z - .string() - .min( - 1, - 'Virtual device instance name is required (e.g., "VegaTV_1", "VegaTV_Debug")' - ), + /** + * Device name as printed by `vega device list`. The Vega Virtual Device + * shows up as `VirtualDevice`, which is the default. + */ + deviceId: z.string().min(1).optional().default('VirtualDevice'), +}); + +export const VegaPhysicalDeviceSchema = z.object({ + type: z.literal('physical'), + /** + * Device serial number (DSN) as printed by `vega device list` once the + * Fire TV is in developer mode. + */ + serial: z.string().min(1, 'Device serial number is required'), }); export const VegaDeviceSchema = z.discriminatedUnion('type', [ - VegaEmulatorSchema, + VegaVirtualDeviceSchema, + VegaPhysicalDeviceSchema, ]); export const VegaPlatformConfigSchema = z.object({ name: z.string().min(1, 'Name is required'), device: VegaDeviceSchema, + /** The `[package] id` from `manifest.toml`. Only used to derive `componentId`. */ bundleId: z.string().min(1, 'Bundle ID is required'), + /** + * The interactive component id from `manifest.toml`. The Vega CLI identifies + * the app by it for install checks, launching and tracking. Defaults to `.main`, which is what the Vega templates + * generate. + */ + componentId: z.string().min(1).optional(), + /** + * Build variant of the package in `HARNESS_APP_PATH`. Harness loads the + * bundle from Metro, which needs a Debug package. + */ + buildType: z.enum(['Debug', 'Release']).optional().default('Debug'), appLaunchOptions: VegaAppLaunchOptionsSchema.optional(), }); -export type VegaEmulator = z.infer; +export type VegaVirtualDevice = z.infer; +export type VegaPhysicalDevice = z.infer; export type VegaDevice = z.infer; +export type VegaDeviceInput = z.input; export type VegaPlatformConfig = z.infer; -export const isVegaDeviceEmulator = ( - device: VegaDevice -): device is VegaEmulator => { - return device.type === 'emulator'; -}; - -export function assertVegaDeviceEmulator( - device: VegaDevice -): asserts device is VegaEmulator { - if (!isVegaDeviceEmulator(device)) { - throw new Error('Device is not an emulator'); - } -} +/** The `VegaPlatformConfig` before Zod applies defaults. */ +export type VegaPlatformConfigInput = z.input; + +/** @deprecated Use `VegaVirtualDevice`. */ +export type VegaEmulator = VegaVirtualDevice; + +export const getVegaComponentId = (config: VegaPlatformConfig): string => + config.componentId ?? `${config.bundleId}.main`; diff --git a/packages/platform-vega/src/errors.ts b/packages/platform-vega/src/errors.ts new file mode 100644 index 00000000..6c1809d6 --- /dev/null +++ b/packages/platform-vega/src/errors.ts @@ -0,0 +1,30 @@ +export class VegaAppPathError extends Error { + constructor(reason: 'missing' | 'invalid', appPath?: string) { + super( + reason === 'missing' + ? 'App is not installed on the Vega device and HARNESS_APP_PATH is not set. Build a debug package with "npx react-native build-vega --build-type Debug" and point HARNESS_APP_PATH at the resulting .vpkg file.' + : `HARNESS_APP_PATH points to a missing .vpkg file: ${appPath ?? ''}` + ); + this.name = 'VegaAppPathError'; + } +} + +export class VegaMetroPortError extends Error { + constructor(metroPort: number) { + super( + `Vega devices can only reach Metro through port 8081, but Metro is running on port ${metroPort} and the "vda" tool needed to map the ports was not found. Free port 8081 or set "metroPort: 8081" in the harness config.` + ); + this.name = 'VegaMetroPortError'; + } +} + +export class VegaVdaSerialError extends Error { + constructor(deviceId: string, serials: string[]) { + super( + `Could not tell which vda device corresponds to "${deviceId}" (vda lists: ${ + serials.join(', ') || 'none' + }). Mapping a Metro port other than 8081 needs a single attached device, or a physical device whose serial vda reports.` + ); + this.name = 'VegaVdaSerialError'; + } +} diff --git a/packages/platform-vega/src/factory.ts b/packages/platform-vega/src/factory.ts index e24c499c..a7776007 100644 --- a/packages/platform-vega/src/factory.ts +++ b/packages/platform-vega/src/factory.ts @@ -1,17 +1,44 @@ import { HarnessPlatform } from '@react-native-harness/platforms'; -import { type VegaPlatformConfig, type VegaEmulator } from './config.js'; +import type { VegaDeviceInput, VegaPlatformConfigInput } from './config.js'; -export const vegaEmulator = (deviceId: string): VegaEmulator => ({ +export const DEFAULT_VEGA_VIRTUAL_DEVICE_ID = 'VirtualDevice'; + +/** + * Targets the Vega Virtual Device. Harness starts it when it is not running. + */ +export const vegaVirtualDevice = ( + deviceId: string = DEFAULT_VEGA_VIRTUAL_DEVICE_ID +): VegaDeviceInput => ({ type: 'emulator', deviceId, }); +/** @deprecated Use `vegaVirtualDevice`. */ +export const vegaEmulator = vegaVirtualDevice; + +/** + * Targets a Fire TV device in developer mode. `serial` is the device serial + * number printed by `vega device list`. + */ +export const vegaPhysicalDevice = (serial: string): VegaDeviceInput => ({ + type: 'physical', + serial, +}); + +const getLockKey = (device: VegaDeviceInput): string => + device.type === 'physical' + ? `vega:physical:${device.serial}` + : `vega:virtual:${device.deviceId ?? DEFAULT_VEGA_VIRTUAL_DEVICE_ID}`; + export const vegaPlatform = ( - config: VegaPlatformConfig -): HarnessPlatform => ({ + config: VegaPlatformConfigInput +): HarnessPlatform => ({ name: config.name, config, runner: import.meta.resolve('./runner.js'), + metroConfigEnhancer: import.meta.resolve('./metro-config-enhancer.js'), platformId: 'vega', - getResourceLockKey: () => `vega:${config.device.deviceId}`, + // React Native for Vega requests "kepler" bundles from Metro. + bundlePlatform: 'kepler', + getResourceLockKey: () => getLockKey(config.device), }); diff --git a/packages/platform-vega/src/index.ts b/packages/platform-vega/src/index.ts index b69b730c..fda3e54a 100644 --- a/packages/platform-vega/src/index.ts +++ b/packages/platform-vega/src/index.ts @@ -1,2 +1,19 @@ -export { vegaPlatform, vegaEmulator } from './factory.js'; -export type { VegaPlatformConfig } from './config.js'; +export { + vegaPlatform, + vegaVirtualDevice, + vegaPhysicalDevice, + vegaEmulator, +} from './factory.js'; +export type { + VegaPlatformConfig, + VegaPlatformConfigInput, + VegaDevice, + VegaVirtualDevice, + VegaPhysicalDevice, + VegaEmulator, +} from './config.js'; +export { + VegaAppPathError, + VegaMetroPortError, + VegaVdaSerialError, +} from './errors.js'; diff --git a/packages/platform-vega/src/kepler.ts b/packages/platform-vega/src/kepler.ts deleted file mode 100644 index a024006d..00000000 --- a/packages/platform-vega/src/kepler.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { spawn } from '@react-native-harness/tools'; - -export type VegaVirtualDeviceStatus = 'running' | 'stopped'; - -/** - * List all available Vega virtual devices - * Returns array of device identifiers that can be used with kepler commands - */ -export const listVegaDevices = async (): Promise => { - try { - const { stdout } = await spawn('kepler', ['device', 'list']); - const lines = stdout.trim().split('\n'); - const devices: string[] = []; - - for (const line of lines) { - if (line.trim()) { - // Parse device line format: "VirtualDevice : tv - x86_64 - OS - hostname" - // or potentially "VegaTV_1 : tv - x86_64 - OS - hostname" for named instances - const deviceId = line.split(' : ')[0].trim(); - if ( - deviceId && - (deviceId === 'VirtualDevice' || deviceId.startsWith('Vega')) - ) { - devices.push(deviceId); - } - } - } - - return devices; - } catch { - return []; - } -}; - -/** - * Check if a specific Vega virtual device is connected/available - */ -export const isVegaDeviceConnected = async ( - deviceId: string -): Promise => { - try { - const { stdout } = await spawn('kepler', [ - 'device', - 'is-connected', - '--device', - deviceId, - ]); - return stdout.includes('is connected'); - } catch { - return false; - } -}; - -/** - * Launch an already installed app on specified Vega virtual device - */ -export const startApp = async ( - deviceId: string, - bundleId: string -): Promise => { - await spawn('kepler', [ - 'device', - 'launch-app', - '--device', - deviceId, - '--appName', - bundleId, - ]); -}; - -/** - * Check if an app is installed on the specified Vega virtual device - */ -export const isAppInstalled = async ( - deviceId: string, - bundleId: string -): Promise => { - try { - await spawn('kepler', [ - 'device', - 'is-app-installed', - '--device', - deviceId, - '--appName', - bundleId, - ]); - return true; - } catch { - return false; - } -}; - -/** - * Check if an app is currently running on the specified Vega virtual device - */ -export const isAppRunning = async ( - deviceId: string, - bundleId: string -): Promise => { - try { - await spawn('kepler', [ - 'device', - 'is-app-running', - '--device', - deviceId, - '--appName', - bundleId, - ]); - return true; - } catch { - return false; - } -}; - -export const stopApp = async ( - deviceId: string, - bundleId: string -): Promise => { - await spawn('kepler', [ - 'device', - 'terminate-app', - '--device', - deviceId, - '--appName', - bundleId, - ]); -}; - -/** - * Start port forwarding for debugging on specified Vega virtual device - */ -export const startPortForwarding = async ( - deviceId: string, - port: number, - forward = true -): Promise => { - await spawn('kepler', [ - 'device', - 'start-port-forwarding', - '--device', - deviceId, - '--port', - port.toString(), - '--forward', - forward.toString(), - ]); -}; - -/** - * Stop port forwarding on specified Vega virtual device - */ -export const stopPortForwarding = async ( - deviceId: string, - port: number, - forward = true -): Promise => { - await spawn('kepler', [ - 'device', - 'stop-port-forwarding', - '--device', - deviceId, - '--port', - port.toString(), - '--forward', - forward.toString(), - ]); -}; - -/** - * Get status of a specific Vega virtual device - * Note: Vega CLI might manage virtual devices globally, so this checks if the device is available - */ -export const getVegaDeviceStatus = async ( - deviceId: string -): Promise => { - try { - // First check if the device is connected/available - const isConnected = await isVegaDeviceConnected(deviceId); - if (isConnected) { - return 'running'; - } - - // Check general virtual device status - const { stdout } = await spawn('kepler', ['virtual-device', 'status']); - // Parse the status output to determine if VVD is running - return stdout.toLowerCase().includes('running') || - stdout.toLowerCase().includes('ready') - ? 'running' - : 'stopped'; - } catch { - return 'stopped'; - } -}; diff --git a/packages/platform-vega/src/metro-config-enhancer.ts b/packages/platform-vega/src/metro-config-enhancer.ts new file mode 100644 index 00000000..87586544 --- /dev/null +++ b/packages/platform-vega/src/metro-config-enhancer.ts @@ -0,0 +1,194 @@ +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { logger } from '@react-native-harness/tools'; +import type { + MetroConfigEnhancer, + MetroConfigEnhancerContext, +} from '@react-native-harness/bundler-metro'; +import type { MetroConfig } from 'metro-config'; +import type { CustomResolutionContext, Resolution } from 'metro-resolver'; + +const require = createRequire(import.meta.url); +const log = logger.child('platform-vega'); + +// Metro platform name used by React Native for Vega. +export const KEPLER_PLATFORM = 'kepler'; +const KEPLER_PACKAGE = '@amazon-devices/react-native-kepler'; +const SYSTEM_BUNDLE_UTILS = + '@amazon-devices/kepler-cli-platform/dist/common/systemBundleUtils'; + +type SystemBundleUtils = { + applySystemBundleConfig: ( + projectRoot: string, + buildType: 'Debug' | 'Release', + entryFile: string, + baseConfig: MetroConfig + ) => { config: MetroConfig; applied: boolean }; +}; + +type MetroResolveRequest = ( + context: CustomResolutionContext, + moduleName: string, + platform: string | null +) => Resolution; + +/** + * Redirects `react-native` / `react-native/*` imports to Amazon's React + * Native fork when Metro is bundling for the `kepler` platform. + * + * `react-native start` installs this via the platform's `npmPackageName` in + * `@amazon-devices/kepler-cli-platform`. The harness loads Metro's config + * directly and never runs that plugin, so the platform package supplies the + * redirect itself. + */ +const withKeplerPackageRedirect = ( + next: MetroResolveRequest | undefined +): MetroResolveRequest => { + const passThrough: MetroResolveRequest = + next ?? ((ctx, name, plat) => ctx.resolveRequest(ctx, name, plat)); + + return (context, moduleName, platform) => { + if (platform !== KEPLER_PLATFORM) { + return passThrough(context, moduleName, platform); + } + + let redirected = moduleName; + if (moduleName === 'react-native') { + redirected = KEPLER_PACKAGE; + } else if (moduleName.startsWith('react-native/')) { + redirected = `${KEPLER_PACKAGE}/${moduleName.slice('react-native/'.length)}`; + } + + return passThrough(context, redirected, platform); + }; +}; + +/** + * Resolves the Kepler fork's `InitializeCore` from the project. The default + * React Native Metro config lists upstream `react-native`'s InitializeCore as + * a run-before-main module, but the redirect keeps that file out of a `kepler` + * bundle, so Metro drops it and the runtime is never set up: the app never + * renders and Vega OS kills it as unresponsive. Metro only emits a `require()` + * for a run-before module that ends up in the graph, so this is inert for any + * non-Vega bundle. + */ +const resolveInitializeCore = (projectRoot: string): string | null => { + const specifier = `${KEPLER_PACKAGE}/Libraries/Core/InitializeCore`; + try { + return require.resolve(specifier, { paths: [projectRoot] }); + } catch { + log.warn( + 'could not resolve %s from %s; Vega bundles may fail to initialize', + specifier, + projectRoot + ); + return null; + } +}; + +/** + * Vega apps use "system bundles" by default: React Native and the other + * libraries that ship with the OS stay out of the app bundle, and the app + * bundle maps its requires onto the OS copy. `react-native start` sets this + * up through `@amazon-devices/kepler-cli-platform`, which Harness never runs, + * so the same helper is applied here. Apps that opt out with + * `kepler.useSystemJsBundles: false` get a regular bundle. + */ +const usesSystemBundles = (projectRoot: string): boolean => { + try { + const pkg = require(path.join(projectRoot, 'package.json')) as { + kepler?: { useSystemJsBundles?: boolean }; + }; + return pkg.kepler?.useSystemJsBundles !== false; + } catch { + return true; + } +}; + +const withSystemBundles = ( + metroConfig: MetroConfig, + projectRoot: string, + buildType: 'Debug' | 'Release' +): MetroConfig => { + if (!usesSystemBundles(projectRoot)) { + log.debug('system bundles are disabled for %s', projectRoot); + return metroConfig; + } + + let utils: SystemBundleUtils; + try { + utils = require( + require.resolve(SYSTEM_BUNDLE_UTILS, { paths: [projectRoot] }) + ) as SystemBundleUtils; + } catch { + log.warn( + 'could not load %s from %s; React Native will be bundled into the app bundle instead of coming from the Vega system bundles', + SYSTEM_BUNDLE_UTILS, + projectRoot + ); + return metroConfig; + } + + const { config, applied } = utils.applySystemBundleConfig( + projectRoot, + buildType, + 'index.js', + metroConfig + ); + + if (!applied) { + log.warn( + 'Vega system bundle config was not applied; build the %s package first so its system bundle artifacts exist', + buildType + ); + } + + return config; +}; + +const enhanceMetroConfig: MetroConfigEnhancer = ( + metroConfig: MetroConfig, + { projectRoot, platformConfig }: MetroConfigEnhancerContext +): MetroConfig => { + const initializeCore = resolveInitializeCore(projectRoot); + const existingRunBeforeMainModule = + metroConfig.serializer?.getModulesRunBeforeMainModule; + const buildType = + (platformConfig as { buildType?: 'Debug' | 'Release' } | undefined) + ?.buildType ?? 'Debug'; + + const enhanced: MetroConfig = { + ...metroConfig, + resolver: { + ...metroConfig.resolver, + platforms: [ + ...new Set([ + ...(metroConfig.resolver?.platforms ?? [ + KEPLER_PLATFORM, + 'ios', + 'android', + ]), + 'native', + ]), + ], + resolveRequest: withKeplerPackageRedirect( + metroConfig.resolver?.resolveRequest ?? undefined + ), + }, + serializer: { + ...metroConfig.serializer, + ...(initializeCore + ? { + getModulesRunBeforeMainModule: (entryPoint: string) => [ + ...(existingRunBeforeMainModule?.(entryPoint) ?? []), + initializeCore, + ], + } + : {}), + }, + }; + + return withSystemBundles(enhanced, projectRoot, buildType); +}; + +export default enhanceMetroConfig; diff --git a/packages/platform-vega/src/port-forwarding.ts b/packages/platform-vega/src/port-forwarding.ts new file mode 100644 index 00000000..74039f61 --- /dev/null +++ b/packages/platform-vega/src/port-forwarding.ts @@ -0,0 +1,64 @@ +import { logger } from '@react-native-harness/tools'; +import { VegaMetroPortError } from './errors.js'; +import type * as vegaModule from './vega.js'; + +type VegaCli = typeof vegaModule; + +const log = logger.child('platform-vega'); + +// Debug builds of React Native for Vega load the bundle from localhost:8081 +// on the device, and the Vega CLI only forwards that port. +export const VEGA_DEVICE_METRO_PORT = 8081; + +export type VegaMetroAccess = { + dispose: () => Promise; +}; + +/** + * Makes the harness Metro server reachable from the device on port 8081. + */ +export const configureVegaMetroAccess = async ( + vega: VegaCli, + { deviceId, isVirtual }: { deviceId: string; isVirtual: boolean }, + metroPort: number +): Promise => { + if (metroPort === VEGA_DEVICE_METRO_PORT) { + let disposing = false; + const forwarding = vega.startPortForwarding(deviceId, metroPort); + + void forwarding.subprocess.then( + () => log.debug('port forwarding for %s ended', deviceId), + (error) => { + if (!disposing) { + log.warn('port forwarding for %s failed: %s', deviceId, error); + } + } + ); + + return { + dispose: async () => { + disposing = true; + await forwarding.dispose(); + await vega.stopPortForwarding(deviceId, metroPort); + }, + }; + } + + if (!(await vega.getVdaBinaryPath())) { + throw new VegaMetroPortError(metroPort); + } + + log.info( + 'Mapping device port %d to Metro port %d with vda', + VEGA_DEVICE_METRO_PORT, + metroPort + ); + const serial = await vega.resolveVdaSerial(deviceId, isVirtual); + await vega.vdaReverse(serial, VEGA_DEVICE_METRO_PORT, metroPort); + + return { + dispose: async () => { + await vega.vdaRemoveReverse(serial, VEGA_DEVICE_METRO_PORT); + }, + }; +}; diff --git a/packages/platform-vega/src/runner.ts b/packages/platform-vega/src/runner.ts index 6c519631..a794fae1 100644 --- a/packages/platform-vega/src/runner.ts +++ b/packages/platform-vega/src/runner.ts @@ -1,119 +1,134 @@ import { - createAppSessionEmitter, - type AppSession, - type AppSessionState, DeviceNotFoundError, - AppNotInstalledError, type HarnessPlatformRunnerFactory, } from '@react-native-harness/platforms'; import type { Config as HarnessConfig } from '@react-native-harness/config'; -import { VegaPlatformConfigSchema, type VegaPlatformConfig } from './config.js'; -import * as kepler from './kepler.js'; - -const APP_EXIT_POLL_INTERVAL_MS = 1000; +import { + instrumented, + isInteractive, + logger, + noopDiagnostics, +} from '@react-native-harness/tools'; +import { + getVegaComponentId, + VegaPlatformConfigSchema, + type VegaPlatformConfigInput, +} from './config.js'; +import * as vegaModule from './vega.js'; +import { getOptionalHarnessAppPath } from './app-path.js'; +import { VegaAppPathError } from './errors.js'; +import { configureVegaMetroAccess } from './port-forwarding.js'; +import { createVegaAppSession } from './app-session.js'; + +const log = logger.child('platform-vega'); const getVegaRunner: HarnessPlatformRunnerFactory< - VegaPlatformConfig, + VegaPlatformConfigInput, HarnessConfig -> = async (config, _harnessConfig, _init) => { - void _harnessConfig; - void _init; +> = async (config, harnessConfig, init) => { const parsedConfig = VegaPlatformConfigSchema.parse(config); - const deviceId = parsedConfig.device.deviceId; - const bundleId = parsedConfig.bundleId; - const deviceStatus = await kepler.getVegaDeviceStatus(deviceId); + const vega = instrumented( + vegaModule, + 'platform.vega.cli', + init.diagnostics ?? noopDiagnostics + ); + const { bundleId } = parsedConfig; + const componentId = getVegaComponentId(parsedConfig); + + await vega.ensureVegaCliAvailable(); + + let deviceId: string; + let startedByHarness = false; + + const stopVirtualDeviceIfStarted = async () => { + if (startedByHarness) { + logger.info('Shutting down Vega virtual device...'); + await vega.stopVirtualDevice(); + } + }; - if (deviceStatus === 'stopped') { - throw new DeviceNotFoundError(deviceId); + // Nothing owns the device until the runner is returned, so a failure after + // starting the virtual device must shut it down again. + const cleanUpOnFailure = async (work: () => Promise): Promise => { + try { + return await work(); + } catch (error) { + await stopVirtualDeviceIfStarted(); + throw error; + } + }; + + const isVirtual = parsedConfig.device.type === 'emulator'; + + if (parsedConfig.device.type === 'emulator') { + const preferredId = parsedConfig.device.deviceId; + let resolvedId = await vega.findConnectedVirtualDevice(preferredId); + + if (!resolvedId) { + logger.info('Starting Vega virtual device...'); + await vega.startVirtualDevice({ + signal: init.signal, + gui: isInteractive(), + }); + startedByHarness = true; + resolvedId = await cleanUpOnFailure(() => + vega.waitForVirtualDevice(preferredId, init.signal) + ); + } + + deviceId = resolvedId; + } else { + deviceId = parsedConfig.device.serial; + + if (!(await vega.isDeviceConnected(deviceId))) { + log.error( + 'Fire TV %s is not connected. Enable developer mode on the device ("vega devmode login", then "vega devmode enable-device --code ") and check "vega device list".', + deviceId + ); + throw new DeviceNotFoundError(deviceId); + } } - const isInstalled = await kepler.isAppInstalled(deviceId, bundleId); + log.debug('using Vega device %s', deviceId); + + const prepareApp = async () => { + const appPath = getOptionalHarnessAppPath(); + + if (appPath) { + logger.info('Installing %s on %s...', bundleId, deviceId); + await vega.installApp(deviceId, appPath, { + signal: init.signal, + buildType: parsedConfig.buildType, + }); + } else if (!(await vega.isAppInstalled(deviceId, componentId))) { + throw new VegaAppPathError('missing'); + } + + return await configureVegaMetroAccess( + vega, + { deviceId, isVirtual }, + harnessConfig.metroPort + ); + }; - if (!isInstalled) { - throw new AppNotInstalledError(bundleId, deviceId); - } + const metroAccess = await cleanUpOnFailure(prepareApp); return { - createAppSession: async (): Promise => { - await kepler.stopApp(deviceId, bundleId); - await kepler.startApp(deviceId, bundleId); - - const emitter = createAppSessionEmitter(); - let state: AppSessionState = { status: 'running' }; - let disposed = false; - let stopPolling = false; - let pollDelayTimeout: ReturnType | null = null; - let resolvePollDelay: (() => void) | null = null; - - // Unlike a raced delay() loser (which is fine to just discard), this - // wait is directly `await`ed by pollTask with nothing else racing it, - // so cancelling it must also resolve the promise immediately — - // otherwise dispose() blocks on `await pollTask` for up to - // APP_EXIT_POLL_INTERVAL_MS instead of returning right away. - const waitForNextPoll = () => - new Promise((resolve) => { - resolvePollDelay = () => { - resolvePollDelay = null; - pollDelayTimeout = null; - resolve(); - }; - - pollDelayTimeout = setTimeout(() => { - resolvePollDelay?.(); - }, APP_EXIT_POLL_INTERVAL_MS); - }); - - const cancelPendingPollDelay = () => { - if (pollDelayTimeout) { - clearTimeout(pollDelayTimeout); - pollDelayTimeout = null; - } - - resolvePollDelay?.(); - }; - - const pollTask = (async () => { - while (!stopPolling) { - if (!(await kepler.isAppRunning(deviceId, bundleId))) { - if (!disposed && state.status === 'running') { - state = { status: 'exited', occurredAt: Date.now(), reason: 'process-gone' }; - emitter.emit({ type: 'app_exited' }); - } - return; - } - - if (stopPolling) { - return; - } - - await waitForNextPoll(); - } - })(); - - const session: AppSession = { - dispose: async () => { - if (disposed) { - return; - } - - disposed = true; - stopPolling = true; - cancelPendingPollDelay(); - state = { status: 'disposed', occurredAt: Date.now() }; - emitter.clear(); - await kepler.stopApp(deviceId, bundleId); - await pollTask; - }, - getState: async () => state, - getLogs: () => [], - addListener: emitter.addListener, - removeListener: emitter.removeListener, - }; - - return session; + createAppSession: async () => { + await vega.terminateApp(deviceId, componentId); + + return await createVegaAppSession({ + launchApp: () => vega.launchApp(deviceId, componentId), + terminateApp: () => vega.terminateApp(deviceId, componentId), + getAppRunState: () => vega.getAppRunState(deviceId, componentId), + startLogStream: () => vega.startLogStream(deviceId), + stopLogStream: () => vega.stopLogStream(deviceId), + }); }, dispose: async () => { - await kepler.stopApp(deviceId, bundleId); + await vega.terminateApp(deviceId, componentId); + await metroAccess.dispose(); + await stopVirtualDeviceIfStarted(); }, }; }; diff --git a/packages/platform-vega/src/vega.ts b/packages/platform-vega/src/vega.ts new file mode 100644 index 00000000..5edd5677 --- /dev/null +++ b/packages/platform-vega/src/vega.ts @@ -0,0 +1,610 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + delay, + logger, + runCommand, + spawnOwnedProcess, + SubprocessError, + waitForAbort, + withAbortTimeout, + type OwnedProcess, +} from '@react-native-harness/tools'; +import { DependencyNotFoundError } from '@react-native-harness/platforms'; +import { VegaVdaSerialError } from './errors.js'; + +const log = logger.child('platform-vega'); + +// The SDK ships the CLI as "vega"; older SDKs called it "kepler". +const VEGA_CLI_NAMES = ['vega', 'kepler'] as const; +const VDA_BINARY_NAME = 'vda'; +const SDK_PATH_ENV = 'KEPLER_SDK_PATH'; +const CLI_PATH_ENV = 'VEGA_CLI_PATH'; +const INSTALL_HINT = + 'Install the Vega SDK (https://developer.amazon.com/docs/vega/0.24/install-vega-sdk), then run "source ~/vega/env" or set KEPLER_SDK_PATH so the "vega" CLI can be found.'; + +const DEFAULT_COMMAND_TIMEOUT_MS = 30_000; +const INSTALL_TIMEOUT_MS = 5 * 60_000; +const VIRTUAL_DEVICE_START_TIMEOUT_MS = 5 * 60_000; +const VIRTUAL_DEVICE_POLL_INTERVAL_MS = 1000; +// The device transport flaps for a moment after boot, so readiness needs a +// few consecutive answers before the device is trusted. +const VIRTUAL_DEVICE_READY_PROBES = 3; +const INSTALL_ATTEMPTS = 3; +const INSTALL_RETRY_DELAY_MS = 2000; + +export type VegaDeviceEntry = { + id: string; + description: string; + isVirtual: boolean; +}; + +type CommandOptions = { + signal?: AbortSignal; + timeoutMs?: number; +}; + +let cachedVegaBinary: string | null | undefined; + +const getExecutableCandidates = (name: string, dir: string): string[] => + process.platform === 'win32' + ? [`${name}.exe`, `${name}.cmd`, name].map((file) => path.join(dir, file)) + : [path.join(dir, name)]; + +const findExecutable = (names: readonly string[], dirs: string[]) => { + for (const dir of dirs) { + for (const name of names) { + for (const candidate of getExecutableCandidates(name, dir)) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + } + } + + return null; +}; + +const getSearchDirs = (): string[] => { + const pathDirs = (process.env.PATH ?? '') + .split(path.delimiter) + .filter(Boolean); + const sdkPath = process.env[SDK_PATH_ENV]; + const home = os.homedir(); + + return [ + ...pathDirs, + ...(sdkPath ? [path.join(sdkPath, 'bin')] : []), + path.join(home, 'vega', 'bin'), + path.join(home, 'vega', 'sdk', 'bin'), + ]; +}; + +export const resolveVegaBinary = (): string | null => { + if (cachedVegaBinary !== undefined) { + return cachedVegaBinary; + } + + const explicit = process.env[CLI_PATH_ENV]; + cachedVegaBinary = explicit || findExecutable(VEGA_CLI_NAMES, getSearchDirs()); + + return cachedVegaBinary; +}; + +export const resetVegaBinaryCache = (): void => { + cachedVegaBinary = undefined; +}; + +const getVegaBinary = (): string => { + const binary = resolveVegaBinary(); + + if (!binary) { + throw new DependencyNotFoundError('vega', INSTALL_HINT); + } + + return binary; +}; + +/** + * Locates the Vega Device Adaptor. The SDK keeps it deep inside its workspace + * tree, so ask the CLI first and only then scan the usual bin directories. + */ +export const getVdaBinaryPath = async (): Promise => { + const { ok, stdout } = await runVegaQuietly(['which', VDA_BINARY_NAME]); + + if (ok) { + const reported = stdout + .split('\n') + .map((line) => line.trim()) + .filter((line) => path.isAbsolute(line) && fs.existsSync(line)) + .pop(); + + if (reported) { + return reported; + } + } + + const vegaBinary = resolveVegaBinary(); + const dirs = [ + ...(vegaBinary ? [path.dirname(vegaBinary)] : []), + ...getSearchDirs(), + ]; + + return findExecutable([VDA_BINARY_NAME], dirs); +}; + +export const ensureVegaCliAvailable = async (): Promise => { + const binary = getVegaBinary(); + + try { + const { stdout } = await runCommand(binary, ['--version'], { + signal: new AbortController().signal, + timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS, + }); + log.debug('using Vega CLI %s (%s)', binary, stdout.trim()); + } catch (error) { + log.debug('Vega CLI %s did not report a version: %s', binary, error); + } +}; + +const runVega = async ( + args: readonly string[], + { signal, timeoutMs }: CommandOptions = {} +) => + runCommand(getVegaBinary(), args, { + signal: signal ?? new AbortController().signal, + timeoutMs: timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS, + }); + +// Only stdout is inspected: the CLI logs progress to stderr. +const runVegaQuietly = async ( + args: readonly string[], + options?: CommandOptions +): Promise<{ ok: boolean; stdout: string }> => { + try { + const { stdout } = await runVega(args, options); + return { ok: true, stdout }; + } catch (error) { + log.debug('vega %s failed: %s', args.join(' '), error); + return { ok: false, stdout: '' }; + } +}; + +const deviceArgs = (deviceId: string) => ['--device', deviceId]; + +/** + * Parses `vega device list` output. Each device is printed as + * ` : `, e.g. `VirtualDevice : tv - x86_64 - OS - host`. + */ +export const parseDeviceList = (stdout: string): VegaDeviceEntry[] => { + const entries: VegaDeviceEntry[] = []; + + for (const rawLine of stdout.split('\n')) { + const line = rawLine.trim(); + const separator = line.indexOf(' : '); + + if (!line || separator === -1) { + continue; + } + + const id = line.slice(0, separator).trim(); + const description = line.slice(separator + 3).trim(); + + if (!id) { + continue; + } + + entries.push({ + id, + description, + isVirtual: + id === 'VirtualDevice' || /virtual\s*device/i.test(description), + }); + } + + return entries; +}; + +export const listDevices = async (): Promise => { + const { ok, stdout } = await runVegaQuietly(['device', 'list']); + return ok ? parseDeviceList(stdout) : []; +}; + +const findVirtualDeviceEntry = ( + entries: VegaDeviceEntry[], + preferredId: string +): VegaDeviceEntry | undefined => { + const exact = entries.find((entry) => entry.id === preferredId); + + if (exact) { + return exact; + } + + const virtual = entries.filter((entry) => entry.isVirtual); + return virtual.length === 1 ? virtual[0] : undefined; +}; + +export const isDeviceConnected = async (deviceId: string): Promise => { + const { ok, stdout } = await runVegaQuietly([ + 'device', + 'is-connected', + ...deviceArgs(deviceId), + ]); + + return ok && !/not connected|false/i.test(stdout); +}; + +/** + * Returns the id of the connected virtual device matching `preferredId`, or + * the only virtual device listed when the preferred name is not found. + */ +export const findConnectedVirtualDevice = async ( + preferredId: string +): Promise => { + const entry = findVirtualDeviceEntry(await listDevices(), preferredId); + + if (!entry) { + return null; + } + + return (await isDeviceConnected(entry.id)) ? entry.id : null; +}; + +export const startVirtualDevice = async ({ + signal, + gui, +}: { + signal: AbortSignal; + gui: boolean; +}): Promise => { + await runVega( + [ + 'virtual-device', + 'start', + '--timeout', + String(Math.floor(VIRTUAL_DEVICE_START_TIMEOUT_MS / 1000)), + ...(gui ? [] : ['--no-gui']), + ], + { signal, timeoutMs: VIRTUAL_DEVICE_START_TIMEOUT_MS } + ); +}; + +/** + * The device answers `is-connected` before its package manager accepts + * installs, so readiness is probed with a command that goes through it. + */ +export const isDeviceReady = async (deviceId: string): Promise => { + const { ok } = await runVegaQuietly([ + 'device', + 'installed-apps', + ...deviceArgs(deviceId), + ]); + + return ok; +}; + +export const stopVirtualDevice = async (): Promise => { + await runVegaQuietly(['virtual-device', 'stop'], { + timeoutMs: VIRTUAL_DEVICE_START_TIMEOUT_MS, + }); +}; + +const waitWithSignal = async ( + ms: number, + signal: AbortSignal +): Promise => { + if (signal.aborted) { + throw signal.reason; + } + + const timer = delay(ms); + const abort = waitForAbort(signal); + try { + await Promise.race([timer.promise, abort.promise]); + } finally { + timer.cancel(); + abort.cancel(); + } +}; + +export const waitForVirtualDevice = async ( + preferredId: string, + signal: AbortSignal, + { + pollIntervalMs = VIRTUAL_DEVICE_POLL_INTERVAL_MS, + timeoutMs = VIRTUAL_DEVICE_START_TIMEOUT_MS, + }: { pollIntervalMs?: number; timeoutMs?: number } = {} +): Promise => { + // Bounded on its own so the polling stops even when the caller's timeout + // fires without aborting `signal`. + const bounded = withAbortTimeout(signal, timeoutMs); + let readyProbes = 0; + + for (;;) { + if (bounded.aborted) { + if (signal.aborted) { + throw signal.reason; + } + + throw new Error( + `Vega virtual device "${preferredId}" did not become ready within ${timeoutMs} ms` + ); + } + + const deviceId = await findConnectedVirtualDevice(preferredId); + readyProbes = deviceId && (await isDeviceReady(deviceId)) ? readyProbes + 1 : 0; + + if (deviceId && readyProbes >= VIRTUAL_DEVICE_READY_PROBES) { + return deviceId; + } + + try { + await waitWithSignal(pollIntervalMs, bounded); + } catch (error) { + if (!bounded.aborted) { + throw error; + } + } + } +}; + +export type VegaBuildType = 'Debug' | 'Release'; + +/** + * Installs or updates a package in place. The build type must match the + * package variant: without "Debug" the device skips the debug sandbox + * overrides and a debug app cannot reach the dev server, so it gets killed as + * unresponsive after launch. Retries cover the transport dropping for a moment + * right after the device boots. + */ +export const installApp = async ( + deviceId: string, + packagePath: string, + { + signal, + buildType, + attempts = INSTALL_ATTEMPTS, + retryDelayMs = INSTALL_RETRY_DELAY_MS, + }: { + signal: AbortSignal; + buildType: VegaBuildType; + attempts?: number; + retryDelayMs?: number; + } +): Promise => { + const args = [ + 'device', + 'install-app', + ...deviceArgs(deviceId), + '--packagePath', + packagePath, + '--buildType', + buildType, + ]; + + for (let attempt = 1; ; attempt += 1) { + try { + await runVega(args, { signal, timeoutMs: INSTALL_TIMEOUT_MS }); + return; + } catch (error) { + const stderr = + error instanceof SubprocessError ? error.stderr.trim() : String(error); + + if (attempt >= attempts || signal.aborted) { + throw new Error( + `Installing ${packagePath} on ${deviceId} failed after ${attempt} attempt(s): ${stderr}` + ); + } + + log.debug('install attempt %d failed, retrying: %s', attempt, stderr); + await waitWithSignal(retryDelayMs, signal); + } + } +}; + +// Every --appName flag of the Vega CLI takes the interactive component id. +export type VegaAppRunState = 'running' | 'stopped' | 'unknown'; + +const NEGATIVE_ANSWER = /\bfalse\b|not installed|not running/i; +const POSITIVE_ANSWER = /\btrue\b|is installed|is running/i; + +/** + * Asks the CLI about an app and keeps "the probe failed" apart from "the app + * is gone": a non-zero exit, a timeout, or an unparseable answer is `unknown`, + * so callers polling for an exit can retry instead of reporting a crash. + */ +const getAppState = async ( + command: 'is-app-installed' | 'is-app-running', + deviceId: string, + appName: string +): Promise => { + const { ok, stdout } = await runVegaQuietly([ + 'device', + command, + ...deviceArgs(deviceId), + '--appName', + appName, + ]); + + if (!ok) { + return 'unknown'; + } + + if (NEGATIVE_ANSWER.test(stdout)) { + return 'stopped'; + } + + return POSITIVE_ANSWER.test(stdout) || stdout.trim() === '' ? 'running' : 'unknown'; +}; + +export const isAppInstalled = async ( + deviceId: string, + componentId: string +): Promise => + (await getAppState('is-app-installed', deviceId, componentId)) === 'running'; + +export const getAppRunState = (deviceId: string, componentId: string) => + getAppState('is-app-running', deviceId, componentId); + +export const launchApp = async ( + deviceId: string, + componentId: string +): Promise => { + await runVega([ + 'device', + 'launch-app', + ...deviceArgs(deviceId), + '--appName', + componentId, + ]); +}; + +export const terminateApp = async ( + deviceId: string, + componentId: string +): Promise => { + await runVegaQuietly([ + 'device', + 'terminate-app', + ...deviceArgs(deviceId), + '--appName', + componentId, + ]); +}; + +const portForwardingArgs = (deviceId: string, port: number) => [ + ...deviceArgs(deviceId), + '--port', + String(port), + // "--forward false" maps the device port to the host (reverse forwarding). + '--forward', + 'false', +]; + +/** + * Starts reverse port forwarding so the device reaches Metro on `localhost`. + * The CLI keeps running while the forwarding is active, so the caller owns + * the process for the lifetime of the run. + */ +export const startPortForwarding = ( + deviceId: string, + port: number +): OwnedProcess => + spawnOwnedProcess( + getVegaBinary(), + ['device', 'start-port-forwarding', ...portForwardingArgs(deviceId, port)], + { stdout: 'pipe', stderr: 'pipe' } + ); + +export const stopPortForwarding = async ( + deviceId: string, + port: number +): Promise => { + await runVegaQuietly([ + 'device', + 'stop-port-forwarding', + ...portForwardingArgs(deviceId, port), + ]); +}; + +const getVdaBinary = async (): Promise => { + const vda = await getVdaBinaryPath(); + + if (!vda) { + throw new DependencyNotFoundError(VDA_BINARY_NAME, INSTALL_HINT); + } + + return vda; +}; + +const runVda = (vda: string, args: readonly string[]) => + runCommand(vda, args, { + signal: new AbortController().signal, + timeoutMs: DEFAULT_COMMAND_TIMEOUT_MS, + }); + +/** Parses `vda devices`, which prints one `\t` line per device. */ +export const parseVdaDevices = (stdout: string): string[] => + stdout + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !/^List of devices/i.test(line)) + .map((line) => line.split(/\s+/)) + .filter(([, state]) => state === 'device') + .map(([serial]) => serial); + +/** + * Finds the vda serial for a device known to the Vega CLI. vda uses adb-style + * serials, which only match the CLI's ids for physical devices; the virtual + * device shows up as `emulator-`. + */ +export const resolveVdaSerial = async ( + deviceId: string, + isVirtual: boolean +): Promise => { + const vda = await getVdaBinary(); + const { stdout } = await runVda(vda, ['devices']); + const serials = parseVdaDevices(stdout); + + if (serials.length === 1) { + return serials[0]; + } + + if (serials.includes(deviceId)) { + return deviceId; + } + + const emulators = serials.filter((serial) => serial.startsWith('emulator-')); + + if (isVirtual && emulators.length === 1) { + return emulators[0]; + } + + throw new VegaVdaSerialError(deviceId, serials); +}; + +/** Maps `devicePort` on the device to `hostPort` on the host using vda. */ +export const vdaReverse = async ( + serial: string, + devicePort: number, + hostPort: number +): Promise => { + const vda = await getVdaBinary(); + await runVda(vda, [ + '-s', + serial, + 'reverse', + `tcp:${devicePort}`, + `tcp:${hostPort}`, + ]); +}; + +/** Removes a reverse mapping created by `vdaReverse`. */ +export const vdaRemoveReverse = async ( + serial: string, + devicePort: number +): Promise => { + const vda = await getVdaBinaryPath(); + + if (!vda) { + return; + } + + try { + await runVda(vda, ['-s', serial, 'reverse', '--remove', `tcp:${devicePort}`]); + } catch (error) { + log.debug('vda reverse --remove tcp:%d failed: %s', devicePort, error); + } +}; + +export const startLogStream = (deviceId: string): OwnedProcess => + spawnOwnedProcess( + getVegaBinary(), + ['device', 'start-log-stream', ...deviceArgs(deviceId)], + { stdout: 'pipe', stderr: 'pipe' } + ); + +export const stopLogStream = async (deviceId: string): Promise => { + await runVegaQuietly(['device', 'stop-log-stream', ...deviceArgs(deviceId)]); +}; diff --git a/packages/platform-vega/tsconfig.json b/packages/platform-vega/tsconfig.json index 0faf2791..5baa264c 100644 --- a/packages/platform-vega/tsconfig.json +++ b/packages/platform-vega/tsconfig.json @@ -12,6 +12,9 @@ { "path": "../config" }, + { + "path": "../bundler-metro" + }, { "path": "./tsconfig.lib.json" } diff --git a/packages/platform-vega/tsconfig.lib.json b/packages/platform-vega/tsconfig.lib.json index 1267bfb9..98f2a80b 100644 --- a/packages/platform-vega/tsconfig.lib.json +++ b/packages/platform-vega/tsconfig.lib.json @@ -19,6 +19,9 @@ }, { "path": "../config/tsconfig.lib.json" + }, + { + "path": "../bundler-metro/tsconfig.lib.json" } ] } diff --git a/packages/platforms/src/types.ts b/packages/platforms/src/types.ts index d1908d9c..6eb1ebcc 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -186,6 +186,12 @@ export type HarnessPlatform> = { * must satisfy is `MetroConfigEnhancer` in `@react-native-harness/bundler-metro`. */ metroConfigEnhancer?: string; + /** + * The `platform` value the app requests from Metro when it differs from + * `platformId`. Used for bundle prewarming and startup detection. Defaults + * to `platformId`. + */ + bundlePlatform?: string; }; export type AndroidEmulatorRunTarget = { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 96ed35d4..078129f2 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -45,7 +45,6 @@ "@vitest/spy": "4.0.16", "chai": "^6.2.2", "event-target-shim": "^6.0.2", - "react-native-url-polyfill": "^3.0.0", "use-sync-external-store": "^1.6.0", "zustand": "^5.0.5" }, diff --git a/packages/runtime/src/bundler/evaluate.test.ts b/packages/runtime/src/bundler/evaluate.test.ts new file mode 100644 index 00000000..d3433bdb --- /dev/null +++ b/packages/runtime/src/bundler/evaluate.test.ts @@ -0,0 +1,35 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { evaluateModule } from './evaluate.js'; +import { MalformedModuleError } from './errors.js'; + +describe('evaluateModule', () => { + const resetModule = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('__resetModule', resetModule); + vi.stubGlobal('__r', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + resetModule.mockReset(); + }); + + it('resets and evaluates a module addressed by a numeric id', () => { + evaluateModule('globalThis.__evaluated = 1;\n__r(7);\n__r(42);', 'a.ts'); + + expect(resetModule).toHaveBeenCalledWith(42); + }); + + it('accepts quoted string ids from custom module id factories', () => { + evaluateModule('__r("1a273c231bb2d16a5322");', 'a.ts'); + + expect(resetModule).toHaveBeenCalledWith('1a273c231bb2d16a5322'); + }); + + it('rejects a bundle without a require call', () => { + expect(() => evaluateModule('var x = 1;', 'a.ts')).toThrow( + MalformedModuleError + ); + }); +}); diff --git a/packages/runtime/src/bundler/evaluate.ts b/packages/runtime/src/bundler/evaluate.ts index 2546a355..1c60030b 100644 --- a/packages/runtime/src/bundler/evaluate.ts +++ b/packages/runtime/src/bundler/evaluate.ts @@ -1,7 +1,15 @@ import { MalformedModuleError } from './errors.js'; +import type { ModuleId } from '../mocker/types.js'; + +// Metro emits `__r(123)` by default; custom module id factories (Vega +// system bundles, for example) emit quoted string ids such as `__r("1a27")`. +const REQUIRE_CALL_PATTERN = /__r\((\d+|"[^"]*"|'[^']*')\)/g; + +const parseModuleId = (raw: string): ModuleId => + raw.startsWith('"') || raw.startsWith("'") ? raw.slice(1, -1) : Number(raw); export const evaluateModule = (moduleJs: string, modulePath: string): void => { - const __rMatches = Array.from(moduleJs.matchAll(/__r\((\d+)\)/g)); + const __rMatches = Array.from(moduleJs.matchAll(REQUIRE_CALL_PATTERN)); if (__rMatches.length === 0) { throw new MalformedModuleError(modulePath, 'No __r function found'); @@ -15,7 +23,7 @@ export const evaluateModule = (moduleJs: string, modulePath: string): void => { throw new MalformedModuleError(modulePath, 'No __r parameter found'); } - const moduleId = Number(__rParam); + const moduleId = parseModuleId(__rParam); // This is important as if module was already initialized, it would not be re-initialized global.__resetModule(moduleId); diff --git a/packages/runtime/src/client/getWSServer.test.ts b/packages/runtime/src/client/getWSServer.test.ts index 8351373d..566064bf 100644 --- a/packages/runtime/src/client/getWSServer.test.ts +++ b/packages/runtime/src/client/getWSServer.test.ts @@ -10,10 +10,6 @@ vi.mock('../utils/dev-server.js', () => ({ getDevServerUrl: mocks.getDevServerUrl, })); -vi.mock('react-native-url-polyfill', () => ({ - URL, -})); - describe('getWSServer', () => { beforeEach(() => { mocks.getDevServerUrl.mockReset(); diff --git a/packages/runtime/src/client/getWSServer.ts b/packages/runtime/src/client/getWSServer.ts index 79bd434a..0ed714f5 100644 --- a/packages/runtime/src/client/getWSServer.ts +++ b/packages/runtime/src/client/getWSServer.ts @@ -1,16 +1,21 @@ import { HARNESS_BRIDGE_PATH } from '@react-native-harness/bridge'; -import { URL } from 'react-native-url-polyfill'; import { getDevServerUrl } from '../utils/dev-server.js'; +// Scheme, optional user info, and host (with port) of an absolute URL. Parsed +// by hand because URL polyfills for React Native read the legacy +// NativeModules bridge, which bridgeless runtimes such as Vega do not have. +const ABSOLUTE_URL_PATTERN = /^([a-z][a-z0-9+.-]*):\/\/(?:[^@/?#]*@)?([^/?#]+)/i; + export const getWSServer = (): string => { - const devServerUrlString = getDevServerUrl(); - const devServerUrl = new URL(devServerUrlString); + const devServerUrl = getDevServerUrl(); + const match = devServerUrl.match(ABSOLUTE_URL_PATTERN); - if (!devServerUrl.host) { - throw new TypeError(`Invalid URL: ${devServerUrlString}`); + if (!match) { + throw new TypeError(`Invalid URL: ${devServerUrl}`); } - const protocol = devServerUrl.protocol === 'https:' ? 'wss:' : 'ws:'; + const [, scheme, host] = match; + const protocol = scheme.toLowerCase() === 'https' ? 'wss:' : 'ws:'; - return `${protocol}//${devServerUrl.host}${HARNESS_BRIDGE_PATH}`; + return `${protocol}//${host.toLowerCase()}${HARNESS_BRIDGE_PATH}`; }; diff --git a/packages/runtime/src/mocker/metro-require.d.ts b/packages/runtime/src/mocker/metro-require.d.ts index d07a573d..60c4c955 100644 --- a/packages/runtime/src/mocker/metro-require.d.ts +++ b/packages/runtime/src/mocker/metro-require.d.ts @@ -1,7 +1,7 @@ -import type { Require } from './types.js'; +import type { ModuleId, Require } from './types.js'; declare global { var __r: Require; var __resetAllModules: () => void; - var __resetModule: (moduleId: number) => void; + var __resetModule: (moduleId: ModuleId) => void; } diff --git a/packages/runtime/src/mocker/registry.ts b/packages/runtime/src/mocker/registry.ts index 27fb56d1..503cc71e 100644 --- a/packages/runtime/src/mocker/registry.ts +++ b/packages/runtime/src/mocker/registry.ts @@ -1,7 +1,7 @@ import { ModuleFactory, ModuleId, Require } from './types.js'; -const modulesCache = new Map(); -const mockRegistry = new Map(); +const modulesCache = new Map(); +const mockRegistry = new Map(); const originalRequire = global.__r; @@ -10,11 +10,11 @@ export const mock = (moduleId: string, factory: ModuleFactory): void => { mockRegistry.set(moduleId as unknown as ModuleId, factory); }; -const isModuleMocked = (moduleId: number): boolean => { +const isModuleMocked = (moduleId: ModuleId): boolean => { return mockRegistry.has(moduleId); }; -const getMockImplementation = (moduleId: number): unknown | null => { +const getMockImplementation = (moduleId: ModuleId): unknown | null => { const factory = mockRegistry.get(moduleId); if (!factory) { diff --git a/packages/runtime/src/mocker/types.ts b/packages/runtime/src/mocker/types.ts index 122cffec..6ed91324 100644 --- a/packages/runtime/src/mocker/types.ts +++ b/packages/runtime/src/mocker/types.ts @@ -1,6 +1,7 @@ -export type ModuleId = number; +// Metro assigns numeric ids by default; custom id factories may use strings. +export type ModuleId = number | string; export type Require = { - (moduleId: number): unknown; + (moduleId: ModuleId): unknown; }; export type ModuleFactory = () => unknown; diff --git a/packages/runtime/src/test-utils/react-native-url-polyfill.ts b/packages/runtime/src/test-utils/react-native-url-polyfill.ts deleted file mode 100644 index 3eb261c9..00000000 --- a/packages/runtime/src/test-utils/react-native-url-polyfill.ts +++ /dev/null @@ -1 +0,0 @@ -export const URL = globalThis.URL; diff --git a/packages/runtime/vite.config.ts b/packages/runtime/vite.config.ts index 31fa11bc..495ae598 100644 --- a/packages/runtime/vite.config.ts +++ b/packages/runtime/vite.config.ts @@ -22,10 +22,6 @@ export default defineConfig(() => ({ alias: { '@vitest/spy': path.resolve(__dirname, 'node_modules/@vitest/spy'), '@vitest/expect': path.resolve(__dirname, 'node_modules/@vitest/expect'), - 'react-native-url-polyfill': path.resolve( - __dirname, - 'src/test-utils/react-native-url-polyfill.ts', - ), }, }, })); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e41bc20..bdf9d1f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,9 +171,6 @@ importers: '@react-native-harness/platform-apple': specifier: workspace:* version: link:../../packages/platform-ios - '@react-native-harness/platform-vega': - specifier: workspace:* - version: link:../../packages/platform-vega '@react-native-harness/platform-web': specifier: workspace:* version: link:../../packages/platform-web @@ -202,6 +199,49 @@ importers: specifier: workspace:* version: link:../../packages/react-native-harness + apps/playground-vega: + dependencies: + '@amazon-devices/react-native-kepler': + specifier: ~4.0.0 + version: 4.0.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.83.0)(@types/node@20.19.25)(@types/react@19.1.13)(react-native@0.83.0(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.83.0)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3) + react: + specifier: 19.2.3 + version: 19.2.3 + react-native: + specifier: 0.83.0 + version: 0.83.0(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.83.0)(@types/react@19.1.13)(react@19.2.3) + devDependencies: + '@amazon-devices/kepler-cli-platform': + specifier: ^0.22.14 + version: 0.22.14(@babel/traverse@7.27.4)(@react-native-community/cli-tools@20.0.0)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/js-polyfills@0.83.0)(@react-native/metro-config@0.83.0)(react-native@0.83.0(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.83.0)(@types/react@19.1.13)(react@19.2.3)) + '@react-native-community/cli': + specifier: 20.0.0 + version: 20.0.0(typescript@5.9.3) + '@react-native-harness/jest': + specifier: workspace:* + version: link:../../packages/jest + '@react-native-harness/platform-vega': + specifier: workspace:* + version: link:../../packages/platform-vega + '@react-native-harness/runtime': + specifier: workspace:* + version: link:../../packages/runtime + '@react-native/babel-preset': + specifier: 0.83.0 + version: 0.83.0 + '@react-native/metro-config': + specifier: 0.83.0 + version: 0.83.0 + '@react-native/typescript-config': + specifier: 0.83.0 + version: 0.83.0 + jest: + specifier: ^30.2.0 + version: 30.2.0(@types/node@20.19.25)(babel-plugin-macros@3.1.0) + react-native-harness: + specifier: workspace:* + version: link:../../packages/react-native-harness + packages/babel-preset: dependencies: '@babel/plugin-transform-class-static-block': @@ -370,7 +410,7 @@ importers: devDependencies: react-native: specifier: '*' - version: 0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3) + version: 0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.83.0)(@types/react@19.1.13)(react@19.2.3) packages/jest: dependencies: @@ -479,6 +519,9 @@ importers: packages/platform-vega: dependencies: + '@react-native-harness/bundler-metro': + specifier: workspace:* + version: link:../bundler-metro '@react-native-harness/config': specifier: workspace:* version: link:../config @@ -494,6 +537,13 @@ importers: zod: specifier: ^3.25.67 version: 3.25.67 + devDependencies: + metro-config: + specifier: '*' + version: 0.83.3 + metro-resolver: + specifier: '*' + version: 0.83.3 packages/platform-web: dependencies: @@ -606,9 +656,6 @@ importers: event-target-shim: specifier: ^6.0.2 version: 6.0.2 - react-native-url-polyfill: - specifier: ^3.0.0 - version: 3.0.0(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3)) use-sync-external-store: specifier: ^1.6.0 version: 1.6.0(react@19.2.3) @@ -640,7 +687,7 @@ importers: devDependencies: react-native: specifier: '*' - version: 0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3) + version: 0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.83.0)(@types/react@19.1.13)(react@19.2.3) vite: specifier: ^7.2.2 version: 7.2.2(@types/node@20.19.25)(jiti@2.6.1)(terser@5.42.0)(yaml@2.8.0) @@ -656,7 +703,7 @@ importers: devDependencies: react-native: specifier: '*' - version: 0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3) + version: 0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.83.0)(@types/react@19.1.13)(react@19.2.3) website: dependencies: @@ -679,6 +726,39 @@ importers: packages: + '@amazon-devices/kepler-cli-platform@0.22.14': + resolution: {integrity: sha512-7TTSOTdavVkePFe4zjxV1d5a8iQTicl2JV7vxK7cDZH8tOKRDpvlkkjpeZ0F0kSCEBsVwB6B/1jkkjbjdwakWg==} + engines: {node: '>=16'} + peerDependencies: + '@babel/traverse': '*' + '@react-native-community/cli': '>=6.0.0' + '@react-native-community/cli-tools': '>=11.3.2' + '@react-native/js-polyfills': '*' + '@react-native/metro-config': '*' + react-native: '*' + + '@amazon-devices/kepler-compatibility-metro-config@0.0.7': + resolution: {integrity: sha512-SKdbd/Hv+mWgjhxj56y4U4Zmnq1krPOJMob5rL9kb0vxPhwCcweES0moC0NFjpauNxF5dy5FEjOouJ0JIkI1YA==} + + '@amazon-devices/kepler-module-manifest-builder@0.1.16': + resolution: {integrity: sha512-1Hdb9L337MjlD2/abOU/EWKQVZoBqO7qdbcj5oIIiuOeyWjSWr9iMLPyWsSbi6AH5SVJwv7pJQTsDLq3dVlrDw==} + hasBin: true + + '@amazon-devices/keplerscript-commonmodules@1.1.0': + resolution: {integrity: sha512-HCIVhMLv0gLzYkIh6wEgs+i5xceBqsFZY5MMw2lKr5ws+ZXdXr4uKx2WIB6eF/GaOjXH3o7ih5EZLLliWqZV/g==} + hasBin: true + + '@amazon-devices/react-native-kepler@4.0.1': + resolution: {integrity: sha512-0jalzgQ3iqa39GKbuDzrUvodk8PpN68uoxQU8VF7r2oUrdOpNllMEDd5IB5llBb5Wt74YeIpKnGwXOyh55XD8A==} + engines: {node: '>= 22.14.0'} + hasBin: true + peerDependencies: + '@types/react': ^19.1.1 + react: ^19.2.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -731,6 +811,10 @@ packages: peerDependencies: '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + '@babel/helper-environment-visitor@7.24.7': + resolution: {integrity: sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-member-expression-to-functions@7.27.1': resolution: {integrity: sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==} engines: {node: '>=6.9.0'} @@ -824,6 +908,20 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + '@babel/plugin-proposal-async-generator-functions@7.20.7': + resolution: {integrity: sha512-xMbiLsn/8RK7Wq7VeVytytS2L6qE69bXPB10YCmMdDZbKF4okCqY74pI/jJQ/8U0b/F6NrT2+14b8/P9/3AMGA==} + engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-async-generator-functions instead. + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-proposal-class-properties@7.18.6': + resolution: {integrity: sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==} + engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-class-properties instead. + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-proposal-decorators@7.27.1': resolution: {integrity: sha512-DTxe4LBPrtFdsWzgpmbBKevg3e9PBy+dXRt19kSbucbZvL2uqtdqwwpluL1jfxYE0wIDTFp1nTy/q6gNLsxXrg==} engines: {node: '>=6.9.0'} @@ -836,6 +934,41 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-proposal-nullish-coalescing-operator@7.18.6': + resolution: {integrity: sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==} + engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-nullish-coalescing-operator instead. + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-proposal-numeric-separator@7.18.6': + resolution: {integrity: sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q==} + engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-numeric-separator instead. + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-proposal-object-rest-spread@7.20.7': + resolution: {integrity: sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg==} + engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-object-rest-spread instead. + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-proposal-optional-catch-binding@7.18.6': + resolution: {integrity: sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw==} + engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-optional-catch-binding instead. + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-proposal-optional-chaining@7.21.0': + resolution: {integrity: sha512-p4zeefM72gpmEe2fkUr/OnOXpWEf8nAgk7ZYVqqfFiyIG7oFfVZcCrU64hWn5xp4tQ9LkV4bTIa5rD0KANpKNA==} + engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-optional-chaining instead. + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2': resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} engines: {node: '>=6.9.0'} @@ -1891,6 +2024,19 @@ packages: '@types/react': '>=16' react: '>=16' + '@microsoft/api-extractor-model@7.29.2': + resolution: {integrity: sha512-hAYajOjQan3uslhKJRwvvHIdLJ+ZByKqdSsJ/dgHFxPtEbdKpzMDO8zuW4K5gkSMYl5D0LbNwxkhxr51P2zsmw==} + + '@microsoft/api-extractor@7.47.0': + resolution: {integrity: sha512-LT8yvcWNf76EpDC+8/ArTVSYePvuDQ+YbAUrsTcpg3ptiZ93HIcMCozP/JOxDt+rrsFfFHcpfoselKfPyRI0GQ==} + hasBin: true + + '@microsoft/tsdoc-config@0.17.1': + resolution: {integrity: sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw==} + + '@microsoft/tsdoc@0.15.1': + resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==} + '@modern-js/node-bundle-require@2.68.2': resolution: {integrity: sha512-MWk/pYx7KOsp+A/rN0as2ji/Ba8x0m129aqZ3Lj6T6CCTWdz0E/IsamPdTmF9Jnb6whQoBKtWSaLTCQlmCoY0Q==} @@ -2231,18 +2377,34 @@ packages: resolution: {integrity: sha512-B1SRwpntaAcckiatxbjzylvNK562Ayza05gdJCjDQHTiDafa1OABmyB5LHt7qWDOpNkaluD+w11vHF7pBmTpzQ==} engines: {node: '>= 20.19.4'} + '@react-native/assets-registry@0.83.0': + resolution: {integrity: sha512-EmGSKDvmnEnBrTK75T+0Syt6gy/HACOTfziw5+392Kr1Bb28Rv26GyOIkvptnT+bb2VDHU0hx9G0vSy5/S3rmQ==} + engines: {node: '>= 20.19.4'} + '@react-native/babel-plugin-codegen@0.82.1': resolution: {integrity: sha512-wzmEz/RlR4SekqmaqeQjdMVh4LsnL9e62mrOikOOkHDQ3QN0nrKLuUDzXyYptVbxQ0IRua4pTm3efJLymDBoEg==} engines: {node: '>= 20.19.4'} + '@react-native/babel-plugin-codegen@0.83.0': + resolution: {integrity: sha512-H5K0hnv9EhcenojZb9nUMIKPvHZ7ba9vpCyQIeGJmUTDYwZqjmXXyH73+uZo+GHjCIq1n0eF/soC5HJQzalh/Q==} + engines: {node: '>= 20.19.4'} + '@react-native/babel-preset@0.82.1': resolution: {integrity: sha512-Olj7p4XIsUWLKjlW46CqijaXt45PZT9Lbvv/Hz698FXTenPKk4k7sy6RGRGZPWO2TCBBfcb73dus1iNHRFSq7g==} engines: {node: '>= 20.19.4'} + '@react-native/babel-preset@0.83.0': + resolution: {integrity: sha512-v20aTae9+aergUgRQSiy3CLqRSJu5VrHLpPpyYcAkTJ2JWTbtTlKfYuEw0V/WMFpeYZnZ7IVtu/6gTISVV74UQ==} + engines: {node: '>= 20.19.4'} + '@react-native/codegen@0.82.1': resolution: {integrity: sha512-ezXTN70ygVm9l2m0i+pAlct0RntoV4afftWMGUIeAWLgaca9qItQ54uOt32I/9dBJvzBibT33luIR/pBG0dQvg==} engines: {node: '>= 20.19.4'} + '@react-native/codegen@0.83.0': + resolution: {integrity: sha512-3fvMi/pSJHhikjwMZQplU4Ar9ANoR2GSBxotbkKIMI6iNduh+ln1FTvB2me69FA68aHtVZOO+cO+QpGCcvgaMA==} + engines: {node: '>= 20.19.4'} + '@react-native/community-cli-plugin@0.82.1': resolution: {integrity: sha512-H/eMdtOy9nEeX7YVeEG1N2vyCoifw3dr9OV8++xfUElNYV7LtSmJ6AqxZUUfxGJRDFPQvaU/8enmJlM/l11VxQ==} engines: {node: '>= 20.19.4'} @@ -2255,18 +2417,42 @@ packages: '@react-native/metro-config': optional: true + '@react-native/community-cli-plugin@0.83.0': + resolution: {integrity: sha512-bJD5pLURgKY2YK0R6gUsFWHiblSAFt1Xyc2fsyCL8XBnB7kJfVhLAKGItk6j1QZbwm1Io41ekZxBmZdyQqIDrg==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@react-native-community/cli': '*' + '@react-native/metro-config': '*' + peerDependenciesMeta: + '@react-native-community/cli': + optional: true + '@react-native/metro-config': + optional: true + '@react-native/debugger-frontend@0.82.1': resolution: {integrity: sha512-a2O6M7/OZ2V9rdavOHyCQ+10z54JX8+B+apYKCQ6a9zoEChGTxUMG2YzzJ8zZJVvYf1ByWSNxv9Se0dca1hO9A==} engines: {node: '>= 20.19.4'} + '@react-native/debugger-frontend@0.83.0': + resolution: {integrity: sha512-7XVbkH8nCjLKLe8z5DS37LNP62/QNNya/YuLlVoLfsiB54nR/kNZij5UU7rS0npAZ3WN7LR0anqLlYnzDd0JHA==} + engines: {node: '>= 20.19.4'} + '@react-native/debugger-shell@0.82.1': resolution: {integrity: sha512-fdRHAeqqPT93bSrxfX+JHPpCXHApfDUdrXMXhoxlPgSzgXQXJDykIViKhtpu0M6slX6xU/+duq+AtP/qWJRpBw==} engines: {node: '>= 20.19.4'} + '@react-native/debugger-shell@0.83.0': + resolution: {integrity: sha512-rJJxRRLLsKW+cqd0ALSBoqwL5SQTmwpd5SGl6rq9sY+fInCUKfkLEIc5HWQ0ppqoPyDteQVWbQ3a5VN84aJaNg==} + engines: {node: '>= 20.19.4'} + '@react-native/dev-middleware@0.82.1': resolution: {integrity: sha512-wuOIzms/Qg5raBV6Ctf2LmgzEOCqdP3p1AYN4zdhMT110c39TVMbunpBaJxm0Kbt2HQ762MQViF9naxk7SBo4w==} engines: {node: '>= 20.19.4'} + '@react-native/dev-middleware@0.83.0': + resolution: {integrity: sha512-HWn42tbp0h8RWttua6d6PjseaSr3IdwkaoqVxhiM9kVDY7Ro00eO7tdlVgSzZzhIibdVS2b2C3x+sFoWhag1fA==} + engines: {node: '>= 20.19.4'} + '@react-native/eslint-config@0.82.1': resolution: {integrity: sha512-K3xCTEAg8WDd7WpDhQ1hsKbuY3OXaQtqpokeOdgyJag100ZvUX84YIaqDqsVaAZqjA53zCA5PbxerWs6mPA+PQ==} engines: {node: '>= 20.19.4'} @@ -2282,27 +2468,49 @@ packages: resolution: {integrity: sha512-KkF/2T1NSn6EJ5ALNT/gx0MHlrntFHv8YdooH9OOGl9HQn5NM0ZmQSr86o5utJsGc7ME3R6p3SaQuzlsFDrn8Q==} engines: {node: '>= 20.19.4'} + '@react-native/gradle-plugin@0.83.0': + resolution: {integrity: sha512-BXZRmfsbgPhEPkrRPjk2njA2AzhSelBqhuoklnv3DdLTdxaRjKYW+LW0zpKo1k3qPKj7kG1YGI3miol6l1GB5g==} + engines: {node: '>= 20.19.4'} + '@react-native/js-polyfills@0.82.1': resolution: {integrity: sha512-tf70X7pUodslOBdLN37J57JmDPB/yiZcNDzS2m+4bbQzo8fhx3eG9QEBv5n4fmzqfGAgSB4BWRHgDMXmmlDSVA==} engines: {node: '>= 20.19.4'} + '@react-native/js-polyfills@0.83.0': + resolution: {integrity: sha512-cVB9BMqlfbQR0v4Wxi5M2yDhZoKiNqWgiEXpp7ChdZIXI0SEnj8WwLwE3bDkyOfF8tCHdytpInXyg/al2O+dLQ==} + engines: {node: '>= 20.19.4'} + '@react-native/metro-babel-transformer@0.82.1': resolution: {integrity: sha512-kVQyYxYe1Da7cr7uGK9c44O6vTzM8YY3KW9CSLhhV1CGw7jmohU1HfLaUxDEmYfFZMc4Kj3JsIEbdUlaHMtprQ==} engines: {node: '>= 20.19.4'} + '@react-native/metro-babel-transformer@0.83.0': + resolution: {integrity: sha512-hB5kpR5Ho9l9xKuh5uHZEIFqnuaW8T7rDYwqf1j0xvTZu88KwaHAXya2IpDZsjlWzVMCl50cAwPkVZOlEOfJvw==} + engines: {node: '>= 20.19.4'} + '@react-native/metro-config@0.82.1': resolution: {integrity: sha512-mAY6R3xnDMlmDOrUCAtLTjIkli26DZt4LNVuAjDEdnlv5sHANOr5x4qpMn7ea1p9Q/tpfHLalPQUQeJ8CZH4gA==} engines: {node: '>= 20.19.4'} + '@react-native/metro-config@0.83.0': + resolution: {integrity: sha512-7mWZNZOJJLMJ8adBrAgAXcwtyn8PtPjAGavK8k3/mtsWYm79ncf5PD8D9puh6wBHCYwPu2ff/l23nNV8JsqLyg==} + engines: {node: '>= 20.19.4'} + '@react-native/normalize-colors@0.74.89': resolution: {integrity: sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==} '@react-native/normalize-colors@0.82.1': resolution: {integrity: sha512-CCfTR1uX+Z7zJTdt3DNX9LUXr2zWXsNOyLbwupW2wmRzrxlHRYfmLgTABzRL/cKhh0Ubuwn15o72MQChvCRaHw==} + '@react-native/normalize-colors@0.83.0': + resolution: {integrity: sha512-DG1ELOqQ6RS82R1zEUGTWa/pfSPOf+vwAnQB7Ao1vRuhW/xdd2OPQJyqx5a5QWMYpGrlkCb7ERxEVX6p2QODCA==} + '@react-native/typescript-config@0.82.1': resolution: {integrity: sha512-kCTjmBg44p0kqU4xEMg7l6SNJyHWTHuTqiT9MpHasEYcnVpBWyEQsSQAiVKONHwcUWcAktrGVLE1dYGfBmPJ3Q==} + '@react-native/typescript-config@0.83.0': + resolution: {integrity: sha512-8IcgamT0qoBDL3D8Ho6YHkQrxUMf3fKHkRd6MYDjVKNamz0XtfXNLY/FNnUOolx1HbgMkkwKFcbP3AbIKFxirQ==} + '@react-native/virtualized-lists@0.82.1': resolution: {integrity: sha512-f5zpJg9gzh7JtCbsIwV+4kP3eI0QBuA93JGmwFRd4onQ3DnCjV2J5pYqdWtM95sjSKK1dyik59Gj01lLeKqs1Q==} engines: {node: '>= 20.19.4'} @@ -2314,6 +2522,20 @@ packages: '@types/react': optional: true + '@react-native/virtualized-lists@0.83.0': + resolution: {integrity: sha512-AVnDppwPidQrPrzA4ETr4o9W+40yuijg3EVgFt2hnMldMZkqwPRrgJL2GSreQjCYe1NfM5Yn4Egyy4Kd0yp4Lw==} + engines: {node: '>= 20.19.4'} + peerDependencies: + '@types/react': ^19.2.0 + react: '*' + react-native: '*' + peerDependenciesMeta: + '@types/react': + optional: true + + '@rnx-kit/tools-node@2.1.2': + resolution: {integrity: sha512-pCpiUpC/032ZoN4iFZFWtKp3Vrjma115nXwv2gyD2XFxj6DFyTX6pYjSK70xT7gwLMU0C3bZonN1JxiOBlGb0A==} + '@rolldown/pluginutils@1.0.0-beta.19': resolution: {integrity: sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==} @@ -2728,6 +2950,28 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@rushstack/node-core-library@5.4.1': + resolution: {integrity: sha512-WNnwdS8r9NZ/2K3u29tNoSRldscFa7SxU0RT+82B6Dy2I4Hl2MeCSKm4EXLXPKeNzLGvJ1cqbUhTLviSF8E6iA==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/rig-package@0.5.2': + resolution: {integrity: sha512-mUDecIJeH3yYGZs2a48k+pbhM6JYwWlgjs2Ca5f2n1G2/kgdgP9D/07oglEGf6mRyXEnazhEENeYTSNDRCwdqA==} + + '@rushstack/terminal@0.13.0': + resolution: {integrity: sha512-Ou44Q2s81BqJu3dpYedAX54am9vn245F0HzqVrfJCMQk5pGgoKKOBOjkbfZC9QKcGNaECh6pwH2s5noJt7X6ew==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/ts-command-line@4.22.0': + resolution: {integrity: sha512-Qj28t6MO3HRgAZ72FDeFsrpdE6wBWxF3VENgvrXh7JF2qIT+CrXiOJIesW80VFZB9QwObSpkB1ilx794fGQg6g==} + '@selderee/plugin-htmlparser2@0.11.0': resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} @@ -2968,6 +3212,9 @@ packages: '@tybys/wasm-util@0.9.0': resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} + '@types/argparse@1.0.38': + resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -3493,6 +3740,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + address@1.2.2: resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==} engines: {node: '>= 10.0.0'} @@ -3509,6 +3761,14 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -3517,6 +3777,9 @@ packages: ajv: optional: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + ajv-keywords@3.5.2: resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} peerDependencies: @@ -3530,6 +3793,12 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@8.12.0: + resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} + + ajv@8.13.0: + resolution: {integrity: sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==} + ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} @@ -3746,6 +4015,9 @@ packages: peerDependencies: '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + babel-plugin-syntax-hermes-parser@0.25.1: + resolution: {integrity: sha512-IVNpGzboFLfXZUAwkLFcI/bnqVbwky0jP3eBno4HKtqvQJAHBLdgxiG6lQ4to0+Q/YCN3PO0od5NZwIKyY4REQ==} + babel-plugin-syntax-hermes-parser@0.32.0: resolution: {integrity: sha512-m5HthL++AbyeEA2FcdwOLfVFvWYECOBObLHNqdR8ceY4TsEdn4LdX2oTvbB2QJSSElE2AWA/b2MXZ/PF/CqLZg==} @@ -3901,6 +4173,10 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + chalk@3.0.0: resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} engines: {node: '>=8'} @@ -3959,6 +4235,10 @@ packages: cjs-module-lexer@2.1.0: resolution: {integrity: sha512-UX0OwmYRYQQetfrLEZeewIFFI+wSTofC+pMBLNuH3RUuu/xzG1oz84UCEDOSoQlN3fZ4+AzmV50ZYvGqkMh9yA==} + cli-cursor@2.1.0: + resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==} + engines: {node: '>=4'} + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -4071,6 +4351,9 @@ packages: concat-with-sourcemaps@1.1.0: resolution: {integrity: sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg==} + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + confusing-browser-globals@1.0.11: resolution: {integrity: sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==} @@ -4909,6 +5192,9 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + find-workspaces@0.3.1: + resolution: {integrity: sha512-UDkGILGJSA1LN5Aa7McxCid4sqW3/e+UYsVwyxki3dDT0F8+ym0rAfnCkEfkL0rO7M+8/mvkim4t/s3IPHmg+w==} + flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} @@ -4972,6 +5258,10 @@ packages: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + fs-extra@8.1.0: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} @@ -5127,6 +5417,10 @@ packages: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -5193,9 +5487,18 @@ packages: hermes-compiler@0.0.0: resolution: {integrity: sha512-boVFutx6ME/Km2mB6vvsQcdnazEYYI/jV1pomx1wcFUG/EVqTkr5CU0CW9bKipOA/8Hyu3NYwW3THg2Q1kNCfA==} + hermes-compiler@0.14.0: + resolution: {integrity: sha512-clxa193o+GYYwykWVFfpHduCATz8fR5jvU7ngXpfKHj+E9hr9vjLNtdLSEe8MUbObvVexV3wcyxQ00xTPIrB1Q==} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + hermes-estree@0.32.0: resolution: {integrity: sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==} + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + hermes-parser@0.32.0: resolution: {integrity: sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==} @@ -5323,6 +5626,10 @@ packages: resolution: {integrity: sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ==} engines: {node: '>=8'} + import-lazy@4.0.0: + resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} + engines: {node: '>=8'} + import-local@3.2.0: resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} engines: {node: '>=8'} @@ -5352,6 +5659,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + interpret@1.4.0: + resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} + engines: {node: '>= 0.10'} + invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} @@ -5771,6 +6082,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jju@1.4.0: + resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + joi@17.13.3: resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==} @@ -6014,6 +6328,10 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + log-symbols@2.2.0: + resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==} + engines: {node: '>=4'} + log-symbols@4.1.0: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} @@ -6048,6 +6366,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + luxon@3.6.1: resolution: {integrity: sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==} engines: {node: '>=12'} @@ -6202,6 +6524,11 @@ packages: resolution: {integrity: sha512-O2BmfWj6FSfzBLrNCXt/rr2VYZdX5i6444QJU0fFoc7Ljg+Q+iqebwE3K0eTvkI6TRjELsXk1cjU+fXwAR4OjQ==} engines: {node: '>=20.19.4'} + metro-react-native-babel-preset@0.76.9: + resolution: {integrity: sha512-eCBtW/UkJPDr6HlMgFEGF+964DZsUEF9RGeJdZLKWE7d/0nY3ABZ9ZAGxzu9efQ35EWRox5bDMXUGaOwUe5ikQ==} + engines: {node: '>=16'} + deprecated: Use @react-native/babel-preset instead + metro-resolver@0.83.3: resolution: {integrity: sha512-0js+zwI5flFxb1ktmR///bxHYg7OLpRpWZlBBruYG8OKYxeMP7SV0xQ/o/hUelrEMdK4LJzqVtHAhBm25LVfAQ==} engines: {node: '>=20.19.4'} @@ -6372,6 +6699,10 @@ packages: engines: {node: '>=10.0.0'} hasBin: true + mimic-fn@1.2.0: + resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} + engines: {node: '>=4'} + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -6380,6 +6711,9 @@ packages: resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} hasBin: true + minimatch@3.0.8: + resolution: {integrity: sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -6415,6 +6749,9 @@ packages: engines: {node: '>=10'} hasBin: true + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -6584,6 +6921,10 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@2.0.1: + resolution: {integrity: sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==} + engines: {node: '>=4'} + onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} @@ -6614,6 +6955,10 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + ora@3.4.0: + resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==} + engines: {node: '>=6'} + ora@5.3.0: resolution: {integrity: sha512-zAKMgGXUim0Jyd6CXK9lraBnD3H5yPGBPPOkC23a2BG6hsm4Zu6OQSjQuEtV0BHDf4aKHcUFvJiGRrFuW3MG8g==} engines: {node: '>=10'} @@ -6761,6 +7106,9 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + pkg-up@3.1.0: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} engines: {node: '>=8'} @@ -7122,11 +7470,6 @@ packages: react: '*' react-native: '*' - react-native-url-polyfill@3.0.0: - resolution: {integrity: sha512-aA5CiuUCUb/lbrliVCJ6lZ17/RpNJzvTO/C7gC/YmDQhTUoRD5q5HlJfwLWcxz4VgAhHwXKzhxH+wUN24tAdqg==} - peerDependencies: - react-native: '*' - react-native-vision-camera@5.0.4: resolution: {integrity: sha512-RkKPiI3nC0eqrmJM8PP6VWEZpsMaKY8TzZM53Vq8OKfmiO+MAipd7EVmGH0cTos7o6AJjFgh0QUQz9oIDN1awg==} peerDependencies: @@ -7152,6 +7495,17 @@ packages: '@types/react': optional: true + react-native@0.83.0: + resolution: {integrity: sha512-a8wPjGfkktb1+Mjvzkky3d0u6j6zdWAzftZ2LdQtgRgqkMMfgQxD9S+ri3RNlfAFQpuCAOYUIyrNHiVkUQChxA==} + engines: {node: '>= 20.19.4'} + hasBin: true + peerDependencies: + '@types/react': ^19.1.1 + react: ^19.2.0 + peerDependenciesMeta: + '@types/react': + optional: true + react-reconciler@0.33.0: resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} engines: {node: '>=0.10.0'} @@ -7170,6 +7524,10 @@ packages: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} + react-refresh@0.4.3: + resolution: {integrity: sha512-Hwln1VNuGl/6bVwnd0Xdn1e84gT/8T9aYNL+HAKDArLCS7LWjwr7StE30IEYbIkx0Vi3vs+coQxe+SQDbGbbpA==} + engines: {node: '>=0.10.0'} + react-router-dom@7.12.0: resolution: {integrity: sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==} engines: {node: '>=20.0.0'} @@ -7199,6 +7557,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + rechoir@0.6.2: + resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} + engines: {node: '>= 0.10'} + recma-build-jsx@1.0.0: resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} @@ -7325,6 +7687,10 @@ packages: resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} hasBin: true + restore-cursor@2.0.0: + resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} + engines: {node: '>=4'} + restore-cursor@3.1.0: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} @@ -7445,11 +7811,21 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.5.4: + resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==} + engines: {node: '>=10'} + hasBin: true + semver@7.6.3: resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} engines: {node: '>=10'} hasBin: true + semver@7.7.1: + resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} + engines: {node: '>=10'} + hasBin: true + semver@7.7.2: resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} engines: {node: '>=10'} @@ -7506,6 +7882,11 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} + shelljs@0.8.5: + resolution: {integrity: sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==} + engines: {node: '>=4'} + hasBin: true + shiki@3.21.0: resolution: {integrity: sha512-N65B/3bqL/TI2crrXr+4UivctrAGEjmsib5rPMMPpFp1xAx/w03v8WZ9RDDFYteXoEgY7qZ4HGgl5KBIu1153w==} @@ -7627,6 +8008,10 @@ packages: resolution: {integrity: sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==} engines: {node: '>=8.0'} + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + string-hash@1.1.3: resolution: {integrity: sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==} @@ -7730,6 +8115,10 @@ packages: styleq@0.1.3: resolution: {integrity: sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==} + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -7852,6 +8241,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + toml@3.0.0: + resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -7950,6 +8342,11 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' + typescript@5.4.2: + resolution: {integrity: sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==} + engines: {node: '>=14.17'} + hasBin: true + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -7959,6 +8356,9 @@ packages: resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} hasBin: true + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -8182,10 +8582,6 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - webidl-conversions@5.0.0: - resolution: {integrity: sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==} - engines: {node: '>=8'} - webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -8216,10 +8612,6 @@ packages: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} - whatwg-url-without-unicode@8.0.0-3: - resolution: {integrity: sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig==} - engines: {node: '>=10'} - whatwg-url@12.0.1: resolution: {integrity: sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==} engines: {node: '>=14'} @@ -8351,6 +8743,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@1.10.2: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} @@ -8406,16 +8801,105 @@ packages: snapshots: - '@ampproject/remapping@2.3.0': + '@amazon-devices/kepler-cli-platform@0.22.14(@babel/traverse@7.27.4)(@react-native-community/cli-tools@20.0.0)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/js-polyfills@0.83.0)(@react-native/metro-config@0.83.0)(react-native@0.83.0(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.83.0)(@types/react@19.1.13)(react@19.2.3))': dependencies: - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 + '@amazon-devices/kepler-compatibility-metro-config': 0.0.7 + '@amazon-devices/kepler-module-manifest-builder': 0.1.16 + '@babel/traverse': 7.27.4 + '@react-native-community/cli': 20.0.0(typescript@5.9.3) + '@react-native-community/cli-tools': 20.0.0 + '@react-native/js-polyfills': 0.83.0 + '@react-native/metro-config': 0.83.0 + '@rnx-kit/tools-node': 2.1.2 + find-workspaces: 0.3.1 + lodash: 4.17.21 + ora: 3.4.0 + react-native: 0.83.0(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.83.0)(@types/react@19.1.13)(react@19.2.3) + semver: 7.7.1 + shelljs: 0.8.5 + optionalDependencies: + '@amazon-devices/keplerscript-commonmodules': 1.1.0 + transitivePeerDependencies: + - supports-color - '@babel/code-frame@7.27.1': - dependencies: - '@babel/helper-validator-identifier': 7.27.1 - js-tokens: 4.0.0 - picocolors: 1.1.1 + '@amazon-devices/kepler-compatibility-metro-config@0.0.7': {} + + '@amazon-devices/kepler-module-manifest-builder@0.1.16': + dependencies: + lodash: 4.17.21 + semver: 7.7.2 + toml: 3.0.0 + yargs: 17.7.2 + + '@amazon-devices/keplerscript-commonmodules@1.1.0': + dependencies: + babel-plugin-syntax-hermes-parser: 0.25.1 + metro-react-native-babel-preset: 0.76.9 + transitivePeerDependencies: + - supports-color + optional: true + + '@amazon-devices/react-native-kepler@4.0.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.83.0)(@types/node@20.19.25)(@types/react@19.1.13)(react-native@0.83.0(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.83.0)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3)': + dependencies: + '@jest/create-cache-key-function': 29.7.0 + '@microsoft/api-extractor': 7.47.0(@types/node@20.19.25) + '@react-native/assets-registry': 0.83.0 + '@react-native/codegen': 0.83.0 + '@react-native/community-cli-plugin': 0.83.0(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.83.0) + '@react-native/gradle-plugin': 0.83.0 + '@react-native/js-polyfills': 0.83.0 + '@react-native/normalize-colors': 0.83.0 + '@react-native/virtualized-lists': 0.83.0(@types/react@19.1.13)(react-native@0.83.0(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.83.0)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3) + abort-controller: 3.0.0 + anser: 1.4.10 + ansi-regex: 5.0.1 + babel-jest: 29.7.0(@babel/core@7.27.4) + babel-plugin-syntax-hermes-parser: 0.32.0 + base64-js: 1.5.1 + commander: 12.1.0 + flow-enums-runtime: 0.0.6 + glob: 7.2.3 + hermes-compiler: 0.14.0 + invariant: 2.2.4 + jest-environment-node: 29.7.0 + memoize-one: 5.2.1 + metro-runtime: 0.83.3 + metro-source-map: 0.83.3 + nullthrows: 1.1.1 + pretty-format: 29.7.0 + promise: 8.3.0 + react: 19.2.3 + react-devtools-core: 6.1.5 + react-refresh: 0.14.2 + regenerator-runtime: 0.13.11 + scheduler: 0.27.0 + semver: 7.7.2 + stacktrace-parser: 0.1.11 + whatwg-fetch: 3.6.20 + ws: 7.5.10 + yargs: 17.7.2 + optionalDependencies: + '@types/react': 19.1.13 + transitivePeerDependencies: + - '@babel/core' + - '@react-native-community/cli' + - '@react-native/metro-config' + - '@types/node' + - bufferutil + - react-native + - supports-color + - utf-8-validate + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.27.1 + js-tokens: 4.0.0 + picocolors: 1.1.1 '@babel/compat-data@7.27.5': {} @@ -8498,6 +8982,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-environment-visitor@7.24.7': + dependencies: + '@babel/types': 7.27.6 + optional: true + '@babel/helper-member-expression-to-functions@7.27.1': dependencies: '@babel/traverse': 7.27.4 @@ -8610,6 +9099,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-proposal-async-generator-functions@7.20.7(@babel/core@7.27.4)': + dependencies: + '@babel/core': 7.27.4 + '@babel/helper-environment-visitor': 7.24.7 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.27.4) + transitivePeerDependencies: + - supports-color + optional: true + + '@babel/plugin-proposal-class-properties@7.18.6(@babel/core@7.27.4)': + dependencies: + '@babel/core': 7.27.4 + '@babel/helper-create-class-features-plugin': 7.27.1(@babel/core@7.27.4) + '@babel/helper-plugin-utils': 7.27.1 + transitivePeerDependencies: + - supports-color + optional: true + '@babel/plugin-proposal-decorators@7.27.1(@babel/core@7.27.4)': dependencies: '@babel/core': 7.27.4 @@ -8624,6 +9133,47 @@ snapshots: '@babel/core': 7.27.4 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-proposal-nullish-coalescing-operator@7.18.6(@babel/core@7.27.4)': + dependencies: + '@babel/core': 7.27.4 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.27.4) + optional: true + + '@babel/plugin-proposal-numeric-separator@7.18.6(@babel/core@7.27.4)': + dependencies: + '@babel/core': 7.27.4 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.27.4) + optional: true + + '@babel/plugin-proposal-object-rest-spread@7.20.7(@babel/core@7.27.4)': + dependencies: + '@babel/compat-data': 7.27.5 + '@babel/core': 7.27.4 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.27.4) + '@babel/plugin-transform-parameters': 7.27.1(@babel/core@7.27.4) + optional: true + + '@babel/plugin-proposal-optional-catch-binding@7.18.6(@babel/core@7.27.4)': + dependencies: + '@babel/core': 7.27.4 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.27.4) + optional: true + + '@babel/plugin-proposal-optional-chaining@7.21.0(@babel/core@7.27.4)': + dependencies: + '@babel/core': 7.27.4 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.27.4) + transitivePeerDependencies: + - supports-color + optional: true + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.27.4)': dependencies: '@babel/core': 7.27.4 @@ -9907,6 +10457,41 @@ snapshots: '@types/react': 19.1.13 react: 19.2.3 + '@microsoft/api-extractor-model@7.29.2(@types/node@20.19.25)': + dependencies: + '@microsoft/tsdoc': 0.15.1 + '@microsoft/tsdoc-config': 0.17.1 + '@rushstack/node-core-library': 5.4.1(@types/node@20.19.25) + transitivePeerDependencies: + - '@types/node' + + '@microsoft/api-extractor@7.47.0(@types/node@20.19.25)': + dependencies: + '@microsoft/api-extractor-model': 7.29.2(@types/node@20.19.25) + '@microsoft/tsdoc': 0.15.1 + '@microsoft/tsdoc-config': 0.17.1 + '@rushstack/node-core-library': 5.4.1(@types/node@20.19.25) + '@rushstack/rig-package': 0.5.2 + '@rushstack/terminal': 0.13.0(@types/node@20.19.25) + '@rushstack/ts-command-line': 4.22.0(@types/node@20.19.25) + lodash: 4.17.21 + minimatch: 3.0.8 + resolve: 1.22.10 + semver: 7.5.4 + source-map: 0.6.1 + typescript: 5.4.2 + transitivePeerDependencies: + - '@types/node' + + '@microsoft/tsdoc-config@0.17.1': + dependencies: + '@microsoft/tsdoc': 0.15.1 + ajv: 8.12.0 + jju: 1.4.0 + resolve: 1.22.10 + + '@microsoft/tsdoc@0.15.1': {} + '@modern-js/node-bundle-require@2.68.2': dependencies: '@modern-js/utils': 2.68.2 @@ -10751,6 +11336,8 @@ snapshots: '@react-native/assets-registry@0.82.1': {} + '@react-native/assets-registry@0.83.0': {} + '@react-native/babel-plugin-codegen@0.82.1': dependencies: '@babel/traverse': 7.27.4 @@ -10758,6 +11345,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@react-native/babel-plugin-codegen@0.83.0': + dependencies: + '@babel/traverse': 7.27.4 + '@react-native/codegen': 0.83.0 + transitivePeerDependencies: + - supports-color + '@react-native/babel-preset@0.82.1': dependencies: '@babel/core': 7.27.4 @@ -10808,6 +11402,56 @@ snapshots: transitivePeerDependencies: - supports-color + '@react-native/babel-preset@0.83.0': + dependencies: + '@babel/core': 7.27.4 + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.27.4) + '@babel/plugin-syntax-export-default-from': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.27.4) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.27.4) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-async-generator-functions': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-async-to-generator': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-block-scoping': 7.27.5(@babel/core@7.27.4) + '@babel/plugin-transform-class-properties': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-classes': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-computed-properties': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-destructuring': 7.27.3(@babel/core@7.27.4) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-logical-assignment-operators': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-named-capturing-groups-regex': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-nullish-coalescing-operator': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-numeric-separator': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-object-rest-spread': 7.27.3(@babel/core@7.27.4) + '@babel/plugin-transform-optional-catch-binding': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-optional-chaining': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-parameters': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-private-methods': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-private-property-in-object': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-react-display-name': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-regenerator': 7.27.5(@babel/core@7.27.4) + '@babel/plugin-transform-runtime': 7.27.4(@babel/core@7.27.4) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-spread': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-typescript': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.27.4) + '@babel/template': 7.27.2 + '@react-native/babel-plugin-codegen': 0.83.0 + babel-plugin-syntax-hermes-parser: 0.32.0 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.27.4) + react-refresh: 0.14.2 + transitivePeerDependencies: + - supports-color + '@react-native/codegen@0.82.1': dependencies: '@babel/core': 7.27.4 @@ -10820,6 +11464,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@react-native/codegen@0.83.0': + dependencies: + '@babel/core': 7.27.4 + '@babel/parser': 7.27.5 + glob: 7.2.3 + hermes-parser: 0.32.0 + invariant: 2.2.4 + nullthrows: 1.1.1 + yargs: 17.7.2 + transitivePeerDependencies: + - supports-color + '@react-native/community-cli-plugin@0.82.1(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)': dependencies: '@react-native/dev-middleware': 0.82.1 @@ -10837,13 +11493,54 @@ snapshots: - supports-color - utf-8-validate + '@react-native/community-cli-plugin@0.82.1(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.83.0)': + dependencies: + '@react-native/dev-middleware': 0.82.1 + debug: 4.4.1 + invariant: 2.2.4 + metro: 0.83.3 + metro-config: 0.83.3 + metro-core: 0.83.3 + semver: 7.7.2 + optionalDependencies: + '@react-native-community/cli': 20.0.0(typescript@5.9.3) + '@react-native/metro-config': 0.83.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/community-cli-plugin@0.83.0(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.83.0)': + dependencies: + '@react-native/dev-middleware': 0.83.0 + debug: 4.4.1 + invariant: 2.2.4 + metro: 0.83.3 + metro-config: 0.83.3 + metro-core: 0.83.3 + semver: 7.7.2 + optionalDependencies: + '@react-native-community/cli': 20.0.0(typescript@5.9.3) + '@react-native/metro-config': 0.83.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@react-native/debugger-frontend@0.82.1': {} + '@react-native/debugger-frontend@0.83.0': {} + '@react-native/debugger-shell@0.82.1': dependencies: cross-spawn: 7.0.6 fb-dotslash: 0.5.8 + '@react-native/debugger-shell@0.83.0': + dependencies: + cross-spawn: 7.0.6 + fb-dotslash: 0.5.8 + '@react-native/dev-middleware@0.82.1': dependencies: '@isaacs/ttlcache': 1.4.1 @@ -10863,6 +11560,25 @@ snapshots: - supports-color - utf-8-validate + '@react-native/dev-middleware@0.83.0': + dependencies: + '@isaacs/ttlcache': 1.4.1 + '@react-native/debugger-frontend': 0.83.0 + '@react-native/debugger-shell': 0.83.0 + chrome-launcher: 0.15.2 + chromium-edge-launcher: 0.2.0 + connect: 3.7.0 + debug: 4.4.1 + invariant: 2.2.4 + nullthrows: 1.1.1 + open: 7.4.2 + serve-static: 1.16.2 + ws: 7.5.10 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@react-native/eslint-config@0.82.1(eslint@9.29.0(jiti@2.6.1))(jest@30.2.0(@types/node@20.19.25)(babel-plugin-macros@3.1.0))(prettier@2.8.8)(typescript@5.9.3)': dependencies: '@babel/core': 7.27.4 @@ -10888,8 +11604,12 @@ snapshots: '@react-native/gradle-plugin@0.82.1': {} + '@react-native/gradle-plugin@0.83.0': {} + '@react-native/js-polyfills@0.82.1': {} + '@react-native/js-polyfills@0.83.0': {} + '@react-native/metro-babel-transformer@0.82.1': dependencies: '@babel/core': 7.27.4 @@ -10899,10 +11619,30 @@ snapshots: transitivePeerDependencies: - supports-color + '@react-native/metro-babel-transformer@0.83.0': + dependencies: + '@babel/core': 7.27.4 + '@react-native/babel-preset': 0.83.0 + hermes-parser: 0.32.0 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + '@react-native/metro-config@0.82.1': dependencies: - '@react-native/js-polyfills': 0.82.1 - '@react-native/metro-babel-transformer': 0.82.1 + '@react-native/js-polyfills': 0.82.1 + '@react-native/metro-babel-transformer': 0.82.1 + metro-config: 0.83.3 + metro-runtime: 0.83.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@react-native/metro-config@0.83.0': + dependencies: + '@react-native/js-polyfills': 0.83.0 + '@react-native/metro-babel-transformer': 0.83.0 metro-config: 0.83.3 metro-runtime: 0.83.3 transitivePeerDependencies: @@ -10914,8 +11654,12 @@ snapshots: '@react-native/normalize-colors@0.82.1': {} + '@react-native/normalize-colors@0.83.0': {} + '@react-native/typescript-config@0.82.1': {} + '@react-native/typescript-config@0.83.0': {} + '@react-native/virtualized-lists@0.82.1(@types/react@19.1.13)(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3)': dependencies: invariant: 2.2.4 @@ -10925,6 +11669,26 @@ snapshots: optionalDependencies: '@types/react': 19.1.13 + '@react-native/virtualized-lists@0.82.1(@types/react@19.1.13)(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.83.0)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3)': + dependencies: + invariant: 2.2.4 + nullthrows: 1.1.1 + react: 19.2.3 + react-native: 0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.83.0)(@types/react@19.1.13)(react@19.2.3) + optionalDependencies: + '@types/react': 19.1.13 + + '@react-native/virtualized-lists@0.83.0(@types/react@19.1.13)(react-native@0.83.0(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.83.0)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3)': + dependencies: + invariant: 2.2.4 + nullthrows: 1.1.1 + react: 19.2.3 + react-native: 0.83.0(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.83.0)(@types/react@19.1.13)(react@19.2.3) + optionalDependencies: + '@types/react': 19.1.13 + + '@rnx-kit/tools-node@2.1.2': {} + '@rolldown/pluginutils@1.0.0-beta.19': {} '@rollup/plugin-babel@6.0.4(@babel/core@7.27.4)(@types/babel__core@7.20.5)(rollup@4.43.0)': @@ -11312,6 +12076,40 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@rushstack/node-core-library@5.4.1(@types/node@20.19.25)': + dependencies: + ajv: 8.13.0 + ajv-draft-04: 1.0.0(ajv@8.13.0) + ajv-formats: 3.0.1 + fs-extra: 7.0.1 + import-lazy: 4.0.0 + jju: 1.4.0 + resolve: 1.22.10 + semver: 7.5.4 + optionalDependencies: + '@types/node': 20.19.25 + + '@rushstack/rig-package@0.5.2': + dependencies: + resolve: 1.22.10 + strip-json-comments: 3.1.1 + + '@rushstack/terminal@0.13.0(@types/node@20.19.25)': + dependencies: + '@rushstack/node-core-library': 5.4.1(@types/node@20.19.25) + supports-color: 8.1.1 + optionalDependencies: + '@types/node': 20.19.25 + + '@rushstack/ts-command-line@4.22.0(@types/node@20.19.25)': + dependencies: + '@rushstack/terminal': 0.13.0(@types/node@20.19.25) + '@types/argparse': 1.0.38 + argparse: 1.0.10 + string-argv: 0.3.2 + transitivePeerDependencies: + - '@types/node' + '@selderee/plugin-htmlparser2@0.11.0': dependencies: domhandler: 5.0.3 @@ -11572,6 +12370,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@types/argparse@1.0.38': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.27.5 @@ -12216,6 +13016,8 @@ snapshots: acorn@8.15.0: {} + acorn@8.18.0: {} + address@1.2.2: {} adm-zip@0.5.16: {} @@ -12228,10 +13030,18 @@ snapshots: agent-base@7.1.4: {} + ajv-draft-04@1.0.0(ajv@8.13.0): + optionalDependencies: + ajv: 8.13.0 + ajv-formats@2.1.1(ajv@8.17.1): optionalDependencies: ajv: 8.17.1 + ajv-formats@3.0.1: + dependencies: + ajv: 8.17.1 + ajv-keywords@3.5.2(ajv@6.12.6): dependencies: ajv: 6.12.6 @@ -12248,6 +13058,20 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.12.0: + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + + ajv@8.13.0: + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 @@ -12529,6 +13353,11 @@ snapshots: transitivePeerDependencies: - supports-color + babel-plugin-syntax-hermes-parser@0.25.1: + dependencies: + hermes-parser: 0.25.1 + optional: true + babel-plugin-syntax-hermes-parser@0.32.0: dependencies: hermes-parser: 0.32.0 @@ -12711,6 +13540,12 @@ snapshots: chai@6.2.2: {} + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + chalk@3.0.0: dependencies: ansi-styles: 4.3.0 @@ -12775,6 +13610,10 @@ snapshots: cjs-module-lexer@2.1.0: {} + cli-cursor@2.1.0: + dependencies: + restore-cursor: 2.0.0 + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -12872,6 +13711,8 @@ snapshots: dependencies: source-map: 0.6.1 + confbox@0.1.8: {} + confusing-browser-globals@1.0.11: {} connect@3.7.0: @@ -13980,6 +14821,12 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + find-workspaces@0.3.1: + dependencies: + fast-glob: 3.3.3 + pkg-types: 1.3.1 + yaml: 2.8.0 + flat-cache@4.0.1: dependencies: flatted: 3.3.3 @@ -14040,6 +14887,12 @@ snapshots: jsonfile: 6.1.0 universalify: 2.0.1 + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + fs-extra@8.1.0: dependencies: graceful-fs: 4.2.11 @@ -14222,6 +15075,8 @@ snapshots: has-bigints@1.1.0: {} + has-flag@3.0.0: {} + has-flag@4.0.0: {} has-property-descriptors@1.0.2: @@ -14366,8 +15221,18 @@ snapshots: hermes-compiler@0.0.0: {} + hermes-compiler@0.14.0: {} + + hermes-estree@0.25.1: + optional: true + hermes-estree@0.32.0: {} + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + optional: true + hermes-parser@0.32.0: dependencies: hermes-estree: 0.32.0 @@ -14532,6 +15397,8 @@ snapshots: dependencies: resolve-from: 5.0.0 + import-lazy@4.0.0: {} + import-local@3.2.0: dependencies: pkg-dir: 4.2.0 @@ -14560,6 +15427,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + interpret@1.4.0: {} + invariant@2.2.4: dependencies: loose-envify: 1.4.0 @@ -15226,6 +16095,8 @@ snapshots: jiti@2.6.1: {} + jju@1.4.0: {} + joi@17.13.3: dependencies: '@hapi/hoek': 9.3.0 @@ -15477,6 +16348,10 @@ snapshots: lodash@4.17.21: {} + log-symbols@2.2.0: + dependencies: + chalk: 2.4.2 + log-symbols@4.1.0: dependencies: chalk: 4.1.2 @@ -15518,6 +16393,10 @@ snapshots: dependencies: yallist: 3.1.1 + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + luxon@3.6.1: {} magic-string@0.30.17: @@ -15816,6 +16695,51 @@ snapshots: flow-enums-runtime: 0.0.6 terser: 5.42.0 + metro-react-native-babel-preset@0.76.9: + dependencies: + '@babel/core': 7.27.4 + '@babel/plugin-proposal-async-generator-functions': 7.20.7(@babel/core@7.27.4) + '@babel/plugin-proposal-class-properties': 7.18.6(@babel/core@7.27.4) + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-proposal-nullish-coalescing-operator': 7.18.6(@babel/core@7.27.4) + '@babel/plugin-proposal-numeric-separator': 7.18.6(@babel/core@7.27.4) + '@babel/plugin-proposal-object-rest-spread': 7.20.7(@babel/core@7.27.4) + '@babel/plugin-proposal-optional-catch-binding': 7.18.6(@babel/core@7.27.4) + '@babel/plugin-proposal-optional-chaining': 7.21.0(@babel/core@7.27.4) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.27.4) + '@babel/plugin-syntax-export-default-from': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-syntax-flow': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.27.4) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.27.4) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-async-to-generator': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-block-scoping': 7.27.5(@babel/core@7.27.4) + '@babel/plugin-transform-classes': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-computed-properties': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-destructuring': 7.27.3(@babel/core@7.27.4) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-named-capturing-groups-regex': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-parameters': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-react-display-name': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-runtime': 7.27.4(@babel/core@7.27.4) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-spread': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-typescript': 7.27.1(@babel/core@7.27.4) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.27.4) + '@babel/template': 7.27.2 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.27.4) + react-refresh: 0.4.3 + transitivePeerDependencies: + - supports-color + optional: true + metro-resolver@0.83.3: dependencies: flow-enums-runtime: 0.0.6 @@ -16216,10 +17140,16 @@ snapshots: mime@3.0.0: {} + mimic-fn@1.2.0: {} + mimic-fn@2.1.0: {} mini-svg-data-uri@1.4.4: {} + minimatch@3.0.8: + dependencies: + brace-expansion: 1.1.12 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -16248,6 +17178,13 @@ snapshots: mkdirp@1.0.4: {} + mlly@1.8.2: + dependencies: + acorn: 8.18.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + mrmime@2.0.1: {} ms@2.0.0: {} @@ -16432,6 +17369,10 @@ snapshots: dependencies: wrappy: 1.0.2 + onetime@2.0.1: + dependencies: + mimic-fn: 1.2.0 + onetime@5.1.2: dependencies: mimic-fn: 2.1.0 @@ -16470,6 +17411,15 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + ora@3.4.0: + dependencies: + chalk: 2.4.2 + cli-cursor: 2.1.0 + cli-spinners: 2.9.2 + log-symbols: 2.2.0 + strip-ansi: 5.2.0 + wcwidth: 1.0.1 + ora@5.3.0: dependencies: bl: 4.1.0 @@ -16613,6 +17563,12 @@ snapshots: dependencies: find-up: 4.1.0 + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + pkg-up@3.1.0: dependencies: find-up: 3.0.0 @@ -16959,11 +17915,6 @@ snapshots: react: 19.2.3 react-native: 0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3) - react-native-url-polyfill@3.0.0(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3)): - dependencies: - react-native: 0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3) - whatwg-url-without-unicode: 8.0.0-3 - react-native-vision-camera@5.0.4(react-native-nitro-image@0.13.1(react-native-nitro-modules@0.35.4(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3))(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3))(react-native-nitro-modules@0.35.4(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3))(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3): dependencies: react: 19.2.3 @@ -17034,6 +17985,102 @@ snapshots: - supports-color - utf-8-validate + react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.83.0)(@types/react@19.1.13)(react@19.2.3): + dependencies: + '@jest/create-cache-key-function': 29.7.0 + '@react-native/assets-registry': 0.82.1 + '@react-native/codegen': 0.82.1 + '@react-native/community-cli-plugin': 0.82.1(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.83.0) + '@react-native/gradle-plugin': 0.82.1 + '@react-native/js-polyfills': 0.82.1 + '@react-native/normalize-colors': 0.82.1 + '@react-native/virtualized-lists': 0.82.1(@types/react@19.1.13)(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.83.0)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3) + abort-controller: 3.0.0 + anser: 1.4.10 + ansi-regex: 5.0.1 + babel-jest: 29.7.0(@babel/core@7.27.4) + babel-plugin-syntax-hermes-parser: 0.32.0 + base64-js: 1.5.1 + commander: 12.1.0 + flow-enums-runtime: 0.0.6 + glob: 7.2.3 + hermes-compiler: 0.0.0 + invariant: 2.2.4 + jest-environment-node: 29.7.0 + memoize-one: 5.2.1 + metro-runtime: 0.83.3 + metro-source-map: 0.83.3 + nullthrows: 1.1.1 + pretty-format: 29.7.0 + promise: 8.3.0 + react: 19.2.3 + react-devtools-core: 6.1.5 + react-refresh: 0.14.2 + regenerator-runtime: 0.13.11 + scheduler: 0.26.0 + semver: 7.7.2 + stacktrace-parser: 0.1.11 + whatwg-fetch: 3.6.20 + ws: 6.2.3 + yargs: 17.7.2 + optionalDependencies: + '@types/react': 19.1.13 + transitivePeerDependencies: + - '@babel/core' + - '@react-native-community/cli' + - '@react-native/metro-config' + - bufferutil + - supports-color + - utf-8-validate + + react-native@0.83.0(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.83.0)(@types/react@19.1.13)(react@19.2.3): + dependencies: + '@jest/create-cache-key-function': 29.7.0 + '@react-native/assets-registry': 0.83.0 + '@react-native/codegen': 0.83.0 + '@react-native/community-cli-plugin': 0.83.0(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.83.0) + '@react-native/gradle-plugin': 0.83.0 + '@react-native/js-polyfills': 0.83.0 + '@react-native/normalize-colors': 0.83.0 + '@react-native/virtualized-lists': 0.83.0(@types/react@19.1.13)(react-native@0.83.0(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.83.0)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3) + abort-controller: 3.0.0 + anser: 1.4.10 + ansi-regex: 5.0.1 + babel-jest: 29.7.0(@babel/core@7.27.4) + babel-plugin-syntax-hermes-parser: 0.32.0 + base64-js: 1.5.1 + commander: 12.1.0 + flow-enums-runtime: 0.0.6 + glob: 7.2.3 + hermes-compiler: 0.14.0 + invariant: 2.2.4 + jest-environment-node: 29.7.0 + memoize-one: 5.2.1 + metro-runtime: 0.83.3 + metro-source-map: 0.83.3 + nullthrows: 1.1.1 + pretty-format: 29.7.0 + promise: 8.3.0 + react: 19.2.3 + react-devtools-core: 6.1.5 + react-refresh: 0.14.2 + regenerator-runtime: 0.13.11 + scheduler: 0.27.0 + semver: 7.7.2 + stacktrace-parser: 0.1.11 + whatwg-fetch: 3.6.20 + ws: 7.5.10 + yargs: 17.7.2 + optionalDependencies: + '@types/react': 19.1.13 + transitivePeerDependencies: + - '@babel/core' + - '@react-native-community/cli' + - '@react-native/metro-config' + - bufferutil + - supports-color + - utf-8-validate + react-reconciler@0.33.0(react@19.2.3): dependencies: react: 19.2.3 @@ -17045,6 +18092,9 @@ snapshots: react-refresh@0.18.0: {} + react-refresh@0.4.3: + optional: true + react-router-dom@7.12.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: react: 19.2.3 @@ -17071,6 +18121,10 @@ snapshots: dependencies: picomatch: 2.3.1 + rechoir@0.6.2: + dependencies: + resolve: 1.22.10 + recma-build-jsx@1.0.0: dependencies: '@types/estree': 1.0.8 @@ -17260,6 +18314,11 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + restore-cursor@2.0.0: + dependencies: + onetime: 2.0.1 + signal-exit: 3.0.7 + restore-cursor@3.1.0: dependencies: onetime: 5.1.2 @@ -17424,8 +18483,14 @@ snapshots: semver@6.3.1: {} + semver@7.5.4: + dependencies: + lru-cache: 6.0.0 + semver@7.6.3: {} + semver@7.7.1: {} + semver@7.7.2: {} send@0.19.0: @@ -17499,6 +18564,12 @@ snapshots: shell-quote@1.8.3: {} + shelljs@0.8.5: + dependencies: + glob: 7.2.3 + interpret: 1.4.0 + rechoir: 0.6.2 + shiki@3.21.0: dependencies: '@shikijs/core': 3.21.0 @@ -17629,6 +18700,8 @@ snapshots: transitivePeerDependencies: - supports-color + string-argv@0.3.2: {} + string-hash@1.1.3: {} string-length@4.0.2: @@ -17755,6 +18828,10 @@ snapshots: styleq@0.1.3: {} + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -17868,6 +18945,8 @@ snapshots: toidentifier@1.0.1: {} + toml@3.0.0: {} + totalist@3.0.1: {} tough-cookie@4.1.4: @@ -17979,10 +19058,14 @@ snapshots: transitivePeerDependencies: - supports-color + typescript@5.4.2: {} + typescript@5.9.3: {} ua-parser-js@1.0.41: {} + ufo@1.6.4: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -18323,8 +19406,6 @@ snapshots: webidl-conversions@3.0.1: {} - webidl-conversions@5.0.0: {} - webidl-conversions@7.0.0: {} webpack-sources@3.3.3: {} @@ -18369,12 +19450,6 @@ snapshots: whatwg-mimetype@3.0.0: {} - whatwg-url-without-unicode@8.0.0-3: - dependencies: - buffer: 5.7.1 - punycode: 2.3.1 - webidl-conversions: 5.0.0 - whatwg-url@12.0.1: dependencies: tr46: 4.1.1 @@ -18493,6 +19568,8 @@ snapshots: yallist@3.1.1: {} + yallist@4.0.0: {} + yaml@1.10.2: {} yaml@2.8.0: {} diff --git a/tsconfig.json b/tsconfig.json index 0d520766..72172350 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,9 @@ { "path": "./apps/playground" }, + { + "path": "./apps/playground-vega" + }, { "path": "./packages/runtime" }, diff --git a/website/src/docs/getting-started/configuration.mdx b/website/src/docs/getting-started/configuration.mdx index 2a0d6f16..7f140f07 100644 --- a/website/src/docs/getting-started/configuration.mdx +++ b/website/src/docs/getting-started/configuration.mdx @@ -141,6 +141,7 @@ For detailed installation and configuration instructions, please refer to the pl - [**iOS**](/docs/platforms/ios) - [**Web**](/docs/platforms/web) - [**Windows**](/docs/platforms/windows) +- [**Vega (Fire TV)**](/docs/platforms/vega) ## Default Runner diff --git a/website/src/docs/guides/ci-cd.md b/website/src/docs/guides/ci-cd.md index d89c6895..657fef02 100644 --- a/website/src/docs/guides/ci-cd.md +++ b/website/src/docs/guides/ci-cd.md @@ -36,7 +36,7 @@ The action reads your `rn-harness.config.mjs` file to determine the selected run The action accepts the following inputs: -- `app` (optional): Path to your built app (`.apk` for Android, `.app` for iOS). Not needed for web or Windows runners +- `app` (optional): Path to your built app (`.apk` for Android, `.app` for iOS, `.vpkg` for Vega). Not needed for web or Windows runners - `runner` (required): The runner name from your Harness config (for example `"android"`, `"ios"`, or `"chromium"`) - `projectRoot` (optional): The project root directory (defaults to the repository root) - `uploadVisualTestArtifacts` (optional): Whether to upload visual test diff and actual images as artifacts @@ -358,3 +358,32 @@ For Rock projects: - Use Rock's integrated build commands - Benefit from Rock's advanced caching mechanisms - Follow Rock's CI/CD best practices for optimal performance + +## Vega in CI + +The Vega SDK and the Vega Virtual Device are not available on GitHub-hosted runners, so Vega jobs need a self-hosted macOS or Ubuntu runner with the SDK installed (`source ~/vega/env` in the runner's shell) and either a virtual device or a Fire TV in developer mode on the runner's network. Build the Debug package in an earlier step and pass it through the `app` input (see the [Vega platform guide](/docs/platforms/vega)). The repository's own [`e2e-tests.yml`](https://github.com/callstackincubator/react-native-harness/blob/main/.github/workflows/e2e-tests.yml) has an `e2e-vega` job of this shape, gated on `workflow_dispatch` with `platform: vega` and a runner carrying the `vega` label. + +```yaml +jobs: + test-vega: + name: Test Vega + runs-on: [self-hosted, vega] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install dependencies + run: pnpm install + + - name: Build the Vega app + run: npx react-native build-vega --build-type Debug + + # Keep @v… in sync with the react-native-harness version in package.json + - name: Run React Native Harness + uses: callstackincubator/react-native-harness@v1.0.0 + with: + runner: vega + app: build/aarch64-debug/MyApp_aarch64.vpkg + packageManager: pnpm +``` diff --git a/website/src/docs/platforms/_meta.json b/website/src/docs/platforms/_meta.json index ead4d63d..0330dd80 100644 --- a/website/src/docs/platforms/_meta.json +++ b/website/src/docs/platforms/_meta.json @@ -18,5 +18,10 @@ "type": "file", "name": "windows", "label": "Windows" + }, + { + "type": "file", + "name": "vega", + "label": "Vega (Fire TV)" } ] diff --git a/website/src/docs/platforms/vega.mdx b/website/src/docs/platforms/vega.mdx new file mode 100644 index 00000000..a169cbc7 --- /dev/null +++ b/website/src/docs/platforms/vega.mdx @@ -0,0 +1,124 @@ +import { PackageManagerTabs } from '@theme'; + +# Vega (Fire TV) + +React Native Harness runs tests on the [Vega Virtual Device](https://developer.amazon.com/docs/vega/0.24/run-apps) and on Fire TV devices running Vega OS, using an app built with [React Native for Vega](https://developer.amazon.com/docs/vega/0.24/vega-rn-overview). + +## Overview + +The Vega runner drives the `vega` CLI from the Vega SDK. It starts the Vega Virtual Device when it is not running, installs the app package (`.vpkg`) from `HARNESS_APP_PATH`, forwards Metro's port to the device, launches the app by its component id, and follows the app's process and device logs for the duration of the run. Build the app with `react-native build-vega` before invoking Harness. + +## Installation + + + +## Configuration + +Import the Vega platform helpers in your `rn-harness.config.mjs`: + +```javascript +import { + vegaPlatform, + vegaVirtualDevice, + vegaPhysicalDevice, +} from '@react-native-harness/platform-vega'; + +export default { + entryPoint: './index.js', + // React Native for Vega runs the component registered under the interactive + // component id from manifest.toml, so app.json "name" and this value must + // both be ".main". + appRegistryComponentName: 'com.example.myapp.main', + runners: [ + vegaPlatform({ + name: 'vega', + device: vegaVirtualDevice(), + // The [package] id from manifest.toml + bundleId: 'com.example.myapp', + }), + vegaPlatform({ + name: 'fire-tv', + // The serial number printed by `vega device list` + device: vegaPhysicalDevice('G0X1AB1234567890'), + bundleId: 'com.example.myapp', + }), + ], +}; +``` + +### Options + +| Option | Type | Default | Description | +| :------------ | :------- | :---------------- | :-------------------------------------------------------------------------------------------------------------------------- | +| `name` | `string` | – | Unique name for the runner. | +| `device` | `object` | – | `vegaVirtualDevice(deviceId?)` for the Vega Virtual Device or `vegaPhysicalDevice(serial)` for a Fire TV in developer mode. | +| `bundleId` | `string` | – | The `[package] id` from `manifest.toml`. The component id defaults to `.main`. | +| `componentId` | `string` | `.main` | The `[[components.interactive]] id` from `manifest.toml`. The Vega CLI identifies the app by it for every install and launch step. | +| `buildType` | `string` | `'Debug'` | Build variant of the package in `HARNESS_APP_PATH`, passed to `vega device install-app --buildType`. Harness needs a Debug package. | + +### Finding device names + +`vega device list` prints every device the SDK can reach. The Vega Virtual Device shows up as `VirtualDevice`, which is the default for `vegaVirtualDevice()`. Fire TV devices are listed by serial number: + +``` +G0X1AB1234567890 : A1ZZ32RVTQ796E +VirtualDevice : tv - aarch64 - OS - amazon-ab123456cd789012 +``` + +## Building and deploying the app + +Harness needs a **Debug** package, because debug builds load the JavaScript bundle from Metro: + +```bash +npx react-native build-vega --build-type Debug +``` + +The package lands in `build/-debug/_.vpkg`, one per architecture: `aarch64` for the virtual device on Apple Silicon, `x86_64` for the virtual device on Intel, and `armv7` for Fire TV devices. + +Point `HARNESS_APP_PATH` at the package that matches the target and run the tests: + +```bash +HARNESS_APP_PATH=build/aarch64-debug/MyApp_aarch64.vpkg npx react-native-harness --harnessRunner vega +``` + +Harness installs the package when the app is missing, and reinstalls it whenever `HARNESS_APP_PATH` is set. With `HARNESS_APP_PATH` unset, the app must already be installed on the device. + +Install Debug packages with the Debug build type. `vega device install-app` defaults to Release, and a Debug app installed that way never gets the debug sandbox configuration it needs to reach Metro, so Vega OS kills it as unresponsive about fifteen seconds after launch. Harness passes `--buildType Debug` itself; when you install manually, add `-b Debug`. + +### Fire TV devices + +1. On the Fire TV, open **Settings > My Fire TV > About**, select the device name seven times to unlock developer options, then enable **Developer Mode**. The device shows a six digit code. +2. On your machine, sign in and enable the device: + + ```bash + vega devmode login + vega devmode enable-device --code + ``` + +3. After the device reboots, confirm it is listed by `vega device list` and use its serial number in `vegaPhysicalDevice(...)`. +4. Build the `armv7` package and run the `fire-tv` runner with `HARNESS_APP_PATH` pointing at it. + +The device must be on the same network as your machine. + +## Metro configuration + +Harness applies the same out-of-tree platform wiring that `react-native start` gets from `@amazon-devices/kepler-cli-platform` (the `react-native` → `@amazon-devices/react-native-kepler` resolver redirect and the `kepler` platform), so a plain `metro.config.js` works. Vega system bundles are honoured too: when the app uses them (the default), Harness applies the plugin's split-bundle config so React Native comes from the OS copy and the app bundle stays small; with `kepler.useSystemJsBundles: false` the whole bundle is served by Harness. + +Platform-specific test files use the `vega` suffix, for example `settings.vega.harness.ts`, while `Platform.OS` inside tests is `'kepler'`. + +Vega apps load the bundle from `http://localhost:8081` on the device. Harness reverse-forwards that port to its Metro server with `vega device start-port-forwarding`. When Harness has to run Metro on a different port, it maps the ports with the SDK's `vda` tool, and fails with a clear error if `vda` is not available. `vda` identifies devices by adb-style serials, so this fallback works with a single attached device, or with a physical device whose serial `vda` reports; with a virtual device and a Fire TV attached at the same time keep Metro on 8081. + +## Limitations + +- `@react-native-harness/ui` (`screen`, `userEvent`, screenshots) has no Vega native module yet, so test files importing it fail to load on Vega. +- Crash detection relies on the app process disappearing and on the device log stream (Vega OS reports the kill reason there, for example `AppNotResponding`). Minidumps are not collected. + +## Requirements + +- Vega SDK 0.24 or newer on macOS or Ubuntu, with the `vega` CLI on `PATH` (`source ~/vega/env`). Harness also looks in `$KEPLER_SDK_PATH/bin` and `~/vega/bin`, or at the binary named by `VEGA_CLI_PATH`. +- An app built with `@amazon-devices/react-native-kepler` 4.x (React Native 0.83). +- A Debug `.vpkg` for the target architecture, passed through `HARNESS_APP_PATH`. + +## CI + +The Vega SDK is not available on GitHub-hosted runners. Run Vega jobs on a self-hosted runner with the SDK installed — see [Running in CI/CD](/docs/guides/ci-cd#vega-in-ci).