@react-native-harness/platform-android 1.1.0-rc.4 → 1.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/adb.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  import { type AndroidAppLaunchOptions } from '@react-native-harness/platforms';
2
- import { spawn, SubprocessError } from '@react-native-harness/tools';
2
+ import { logger, spawn, SubprocessError } from '@react-native-harness/tools';
3
3
  import { spawn as nodeSpawn } from 'node:child_process';
4
4
  import type { ChildProcessByStdio } from 'node:child_process';
5
5
  import { access, rm } from 'node:fs/promises';
6
6
  import type { Readable } from 'node:stream';
7
+ import {
8
+ getAvdConfigPath,
9
+ getAvdDirectory,
10
+ readAvdConfig,
11
+ } from './avd-config.js';
7
12
  import {
8
13
  ensureAndroidEmulatorAvailable,
9
14
  ensureAndroidSdkPackages,
@@ -18,6 +23,10 @@ import {
18
23
  getEmulatorStartupArgs,
19
24
  type EmulatorBootMode,
20
25
  } from './emulator-startup.js';
26
+ import {
27
+ AdbAppNotInstalledError,
28
+ AdbPermissionGrantError,
29
+ } from './adb-errors.js';
21
30
 
22
31
  const wait = async (ms: number): Promise<void> => {
23
32
  await new Promise((resolve) => {
@@ -52,13 +61,9 @@ const waitWithSignal = async (
52
61
  await Promise.race([wait(ms), waitForAbort(signal)]);
53
62
  };
54
63
 
55
- const getAvdConfigPath = (name: string): string =>
56
- `${
57
- process.env.ANDROID_AVD_HOME ?? `${process.env.HOME}/.android/avd`
58
- }/${name}.avd/config.ini`;
59
-
60
64
  const EMULATOR_STARTUP_OBSERVATION_TIMEOUT_MS = 5000;
61
65
  const EMULATOR_OUTPUT_BUFFER_LIMIT = 16 * 1024;
66
+ const androidAdbLogger = logger.child('android-adb');
62
67
 
63
68
  export const emulatorProcess = {
64
69
  startDetachedProcess: (
@@ -160,6 +165,68 @@ export const verifyAndroidEmulatorSdk = async (
160
165
  await ensureAndroidSdkPackages(getRequiredEmulatorPackages(apiLevel));
161
166
  };
162
167
 
168
+ const listAvdProfiles = async (): Promise<string[]> => {
169
+ const { stdout } = await spawn(getAvdManagerBinaryPath(), ['list', 'device']);
170
+
171
+ return stdout
172
+ .split('\n')
173
+ .map((line) => line.match(/^id:\s+\d+\s+or\s+"([^"]+)"/i)?.[1]?.trim())
174
+ .filter((profile): profile is string => profile != null && profile !== '');
175
+ };
176
+
177
+ const ensureAvdProfileAvailable = async (profile: string): Promise<void> => {
178
+ const availableProfiles = await listAvdProfiles();
179
+
180
+ if (availableProfiles.includes(profile)) {
181
+ return;
182
+ }
183
+
184
+ const availableProfilesList =
185
+ availableProfiles.length > 0
186
+ ? availableProfiles.join(', ')
187
+ : 'None reported by avdmanager.';
188
+
189
+ throw new Error(
190
+ `Android AVD profile "${profile}" is not available on this machine. Available profiles: ${availableProfilesList}`,
191
+ );
192
+ };
193
+
194
+ const ensureAvdConfigExists = async (name: string): Promise<string> => {
195
+ const configPath = getAvdConfigPath(name);
196
+
197
+ if ((await readAvdConfig(name)) == null) {
198
+ throw new Error(
199
+ `Android AVD "${name}" was created, but config.ini was not found at ${configPath}.`,
200
+ );
201
+ }
202
+
203
+ return configPath;
204
+ };
205
+
206
+ const getAvdIniPath = (name: string): string => {
207
+ const avdHome =
208
+ process.env.ANDROID_AVD_HOME ?? `${process.env.HOME}/.android/avd`;
209
+ return `${avdHome}/${name}.ini`;
210
+ };
211
+
212
+ const ensureAvdIniExists = async ({
213
+ name,
214
+ apiLevel,
215
+ }: {
216
+ name: string;
217
+ apiLevel: number;
218
+ }): Promise<string> => {
219
+ const iniPath = getAvdIniPath(name);
220
+
221
+ const avdDirectory = getAvdDirectory(name);
222
+ await spawn('bash', [
223
+ '-lc',
224
+ `printf '%s\n%s\n%s\n%s\n' 'avd.ini.encoding=UTF-8' 'path=${avdDirectory}' 'path.rel=avd/${name}.avd' 'target=android-${apiLevel}' > "${iniPath}"`,
225
+ ]);
226
+
227
+ return iniPath;
228
+ };
229
+
163
230
  export const getStartAppArgs = (
164
231
  bundleId: string,
165
232
  activityName: string,
@@ -312,6 +379,79 @@ export const getDeviceInfo = async (
312
379
  return { manufacturer, model };
313
380
  };
314
381
 
382
+ const getRequestedPermissions = async (
383
+ adbId: string,
384
+ bundleId: string,
385
+ ): Promise<string[]> => {
386
+ const { stdout } = await spawn(getAdbBinaryPath(), [
387
+ '-s',
388
+ adbId,
389
+ 'shell',
390
+ 'dumpsys',
391
+ 'package',
392
+ bundleId,
393
+ ]);
394
+
395
+ const requestedPermissions = new Set<string>();
396
+ const lines = stdout.split('\n');
397
+ let inRequestedPermissionsSection = false;
398
+
399
+ for (const line of lines) {
400
+ const trimmedLine = line.trim();
401
+
402
+ if (trimmedLine === 'requested permissions:') {
403
+ inRequestedPermissionsSection = true;
404
+ continue;
405
+ }
406
+
407
+ if (!inRequestedPermissionsSection) {
408
+ continue;
409
+ }
410
+
411
+ if (trimmedLine === '') {
412
+ continue;
413
+ }
414
+
415
+ if (trimmedLine.endsWith(':')) {
416
+ break;
417
+ }
418
+
419
+ if (/^[a-zA-Z0-9_.]+$/.test(trimmedLine)) {
420
+ requestedPermissions.add(trimmedLine);
421
+ continue;
422
+ }
423
+
424
+ break;
425
+ }
426
+
427
+ return [...requestedPermissions];
428
+ };
429
+
430
+ const getDangerousPermissions = async (adbId: string): Promise<Set<string>> => {
431
+ const { stdout } = await spawn(getAdbBinaryPath(), [
432
+ '-s',
433
+ adbId,
434
+ 'shell',
435
+ 'pm',
436
+ 'list',
437
+ 'permissions',
438
+ '-g',
439
+ '-d',
440
+ ]);
441
+
442
+ const dangerousPermissions = new Set<string>();
443
+
444
+ for (const match of stdout.matchAll(/permission:([a-zA-Z0-9_.]+)/g)) {
445
+ const permission = match[1]?.trim();
446
+
447
+ if (permission) {
448
+ dangerousPermissions.add(permission);
449
+ }
450
+ }
451
+
452
+ return dangerousPermissions;
453
+ };
454
+
315
455
  export const isBootCompleted = async (adbId: string): Promise<boolean> => {
316
456
  try {
317
457
  const bootCompleted = await getShellProperty(adbId, 'sys.boot_completed');
@@ -336,6 +476,13 @@ export const installApp = async (
336
476
  await spawn(getAdbBinaryPath(), ['-s', adbId, 'install', '-r', appPath]);
337
477
  };
338
478
 
479
+ export const uninstallApp = async (
480
+ adbId: string,
481
+ bundleId: string,
482
+ ): Promise<void> => {
483
+ await spawn(getAdbBinaryPath(), ['-s', adbId, 'uninstall', bundleId]);
484
+ };
485
+
339
486
  export const hasAvd = async (name: string): Promise<boolean> => {
340
487
  const avds = await getAvds();
341
488
  return avds.includes(name);
@@ -354,15 +501,16 @@ export const createAvd = async ({
354
501
  );
355
502
 
356
503
  await verifyAndroidEmulatorSdk(apiLevel);
504
+ await ensureAvdProfileAvailable(profile);
357
505
  await spawn('bash', [
358
506
  '-lc',
359
- `printf 'no\n' | "${getAvdManagerBinaryPath()}" create avd --force --name "${name}" --package "${systemImagePackage}" --device "${profile}"`,
507
+ `printf 'no\n' | "${getAvdManagerBinaryPath()}" create avd --force --name "${name}" --package "${systemImagePackage}" --device "${profile}" -p "${getAvdDirectory(name)}"`,
360
508
  ]);
509
+ await ensureAvdIniExists({ name, apiLevel });
510
+ const configPath = await ensureAvdConfigExists(name);
361
511
  await spawn('bash', [
362
512
  '-lc',
363
- `printf '%s\n%s\n' 'disk.dataPartition.size=${diskSize}' 'vm.heapSize=${heapSize}' >> "${getAvdConfigPath(
364
- name,
365
- )}"`,
513
+ `printf '%s\n%s\n' 'disk.dataPartition.size=${diskSize}' 'vm.heapSize=${heapSize}' >> "${configPath}"`,
366
514
  ]);
367
515
  };
368
516
 
@@ -667,3 +815,77 @@ export const getConnectedDevices = async (): Promise<AdbDevice[]> => {
667
815
 
668
816
  return devices;
669
817
  };
818
+
819
+ export const grantPermissions = async (
820
+ adbId: string,
821
+ bundleId: string,
822
+ ): Promise<void> => {
823
+ androidAdbLogger.debug('grantPermissions:start %o', {
824
+ adbId,
825
+ bundleId,
826
+ });
827
+
828
+ const isInstalled = await isAppInstalled(adbId, bundleId);
829
+ if (!isInstalled) {
830
+ throw new AdbAppNotInstalledError(bundleId, adbId);
831
+ }
832
+
833
+ const [requestedPermissions, dangerousPermissions] = await Promise.all([
834
+ getRequestedPermissions(adbId, bundleId),
835
+ getDangerousPermissions(adbId),
836
+ ]);
837
+ const permissions = requestedPermissions.filter((permission) =>
838
+ dangerousPermissions.has(permission),
839
+ );
840
+
841
+ androidAdbLogger.debug('grantPermissions:resolved %o', {
842
+ adbId,
843
+ bundleId,
844
+ requestedPermissions,
845
+ permissions,
846
+ });
847
+
848
+ if (permissions.length === 0) {
849
+ androidAdbLogger.debug('grantPermissions:skip %o', {
850
+ adbId,
851
+ bundleId,
852
+ });
853
+ return;
854
+ }
855
+
856
+ const grantCommands = permissions.map((permission) => [
857
+ '-s',
858
+ adbId,
859
+ 'shell',
860
+ 'pm',
861
+ 'grant',
862
+ bundleId,
863
+ permission,
864
+ ]);
865
+
866
+ try {
867
+ androidAdbLogger.debug('grantPermissions:commands %o', {
868
+ adbId,
869
+ bundleId,
870
+ grantCommands,
871
+ });
872
+
873
+ await Promise.all(
874
+ grantCommands.map((args) => spawn(getAdbBinaryPath(), args as string[])),
875
+ );
876
+
877
+ androidAdbLogger.debug('grantPermissions:success %o', {
878
+ adbId,
879
+ bundleId,
880
+ permissions,
881
+ });
882
+ } catch (error) {
883
+ androidAdbLogger.debug('grantPermissions:error %o', {
884
+ adbId,
885
+ bundleId,
886
+ permissions,
887
+ error,
888
+ });
889
+ throw new AdbPermissionGrantError(bundleId, permissions, adbId);
890
+ }
891
+ };
@@ -9,8 +9,6 @@ const COMMON_EMULATOR_ARGS = [
9
9
  'swiftshader_indirect',
10
10
  '-noaudio',
11
11
  '-no-boot-anim',
12
- '-camera-back',
13
- 'none',
14
12
  ] as const;
15
13
 
16
14
  export const getEmulatorStartupArgs = (
package/src/instance.ts CHANGED
@@ -59,6 +59,20 @@ const getHarnessAppPath = (): string => {
59
59
  return appPath;
60
60
  };
61
61
 
62
+ const getOptionalHarnessAppPath = (): string | undefined => {
63
+ const appPath = process.env.HARNESS_APP_PATH;
64
+
65
+ if (!appPath) {
66
+ return undefined;
67
+ }
68
+
69
+ if (!fs.existsSync(appPath)) {
70
+ throw new HarnessAppPathError('invalid', appPath);
71
+ }
72
+
73
+ return appPath;
74
+ };
75
+
62
76
  const configureAndroidRuntime = async (
63
77
  adbId: string,
64
78
  config: AndroidPlatformConfig,
@@ -180,6 +194,7 @@ export const getAndroidEmulatorPlatformInstance = async (
180
194
  ): Promise<HarnessPlatformRunner> => {
181
195
  assertAndroidDeviceEmulator(config.device);
182
196
  const detectNativeCrashes = harnessConfig.detectNativeCrashes ?? true;
197
+ const permissionsEnabled = harnessConfig.permissions ?? false;
183
198
  const emulatorConfig = config.device;
184
199
  const emulatorName = emulatorConfig.name;
185
200
  const avdConfig = emulatorConfig.avd;
@@ -250,14 +265,22 @@ export const getAndroidEmulatorPlatformInstance = async (
250
265
  );
251
266
 
252
267
  const isInstalled = await adb.isAppInstalled(adbId, config.bundleId);
268
+ const appPath = getOptionalHarnessAppPath();
253
269
 
254
- if (!isInstalled) {
255
- const appPath = getHarnessAppPath();
270
+ if (isInstalled && appPath) {
271
+ await adb.uninstallApp(adbId, config.bundleId);
256
272
  await adb.installApp(adbId, appPath);
273
+ } else if (!isInstalled) {
274
+ const installPath = appPath ?? getHarnessAppPath();
275
+ await adb.installApp(adbId, installPath);
257
276
  }
258
277
 
259
278
  const appUid = await configureAndroidRuntime(adbId, config, harnessConfig);
260
279
 
280
+ if (permissionsEnabled) {
281
+ await adb.grantPermissions(adbId, config.bundleId);
282
+ }
283
+
261
284
  return {
262
285
  startApp: async (options) => {
263
286
  await adb.startApp(
@@ -315,6 +338,7 @@ export const getAndroidPhysicalDevicePlatformInstance = async (
315
338
  ): Promise<HarnessPlatformRunner> => {
316
339
  assertAndroidDevicePhysical(config.device);
317
340
  const detectNativeCrashes = harnessConfig.detectNativeCrashes ?? true;
341
+ const permissionsEnabled = harnessConfig.permissions ?? false;
318
342
 
319
343
  const adbId = await getAdbId(config.device);
320
344
 
@@ -333,6 +357,10 @@ export const getAndroidPhysicalDevicePlatformInstance = async (
333
357
 
334
358
  const appUid = await configureAndroidRuntime(adbId, config, harnessConfig);
335
359
 
360
+ if (permissionsEnabled) {
361
+ await adb.grantPermissions(adbId, config.bundleId);
362
+ }
363
+
336
364
  return {
337
365
  startApp: async (options) => {
338
366
  await adb.startApp(
@@ -1,5 +0,0 @@
1
- import { Config, NativeTestRunnerConfig } from './types.js';
2
- export declare function assertNativeRunner(config: Config): asserts config is Config & {
3
- runner: NativeTestRunnerConfig;
4
- };
5
- //# sourceMappingURL=assertions.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"assertions.d.ts","sourceRoot":"","sources":["../src/assertions.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAC;AAE5D,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,MAAM,IAAI,MAAM,GAAG;IAAE,MAAM,EAAE,sBAAsB,CAAA;CAAE,CAO/D"}
@@ -1,6 +0,0 @@
1
- export function assertNativeRunner(config) {
2
- if (config.runner.platform !== 'ios' &&
3
- config.runner.platform !== 'android') {
4
- throw new Error('Runner is not a native runner');
5
- }
6
- }
@@ -1,6 +0,0 @@
1
- export type AndroidEmulator = {
2
- adbId: string;
3
- stop: () => Promise<void>;
4
- };
5
- export declare const runEmulator: (avdName: string) => Promise<AndroidEmulator>;
6
- //# sourceMappingURL=emulator.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"emulator.d.ts","sourceRoot":"","sources":["../src/emulator.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,eAAe,GAAG;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3B,CAAC;AAEF,eAAO,MAAM,WAAW,GACtB,SAAS,MAAM,KACd,OAAO,CAAC,eAAe,CA8BzB,CAAC"}
package/dist/emulator.js DELETED
@@ -1,27 +0,0 @@
1
- import { spawn } from '@react-native-harness/tools';
2
- import * as adb from './adb.js';
3
- export const runEmulator = async (avdName) => {
4
- const process = spawn('emulator', ['-avd', avdName]);
5
- await process.nodeChildProcess;
6
- const adbId = await adb.getEmulatorName(avdName);
7
- if (!adbId) {
8
- throw new Error('Emulator not found');
9
- }
10
- // Poll for emulator status until it's fully running
11
- const checkStatus = async () => {
12
- const status = await adb.isBootCompleted(adbId);
13
- if (!status) {
14
- await new Promise((resolve) => setTimeout(resolve, 2000));
15
- await checkStatus();
16
- }
17
- };
18
- // Start checking status after a brief delay to allow emulator to start
19
- await new Promise((resolve) => setTimeout(resolve, 3000));
20
- await checkStatus();
21
- return {
22
- adbId,
23
- stop: async () => {
24
- await adb.stopEmulator(adbId);
25
- },
26
- };
27
- };
package/dist/reader.d.ts DELETED
@@ -1,6 +0,0 @@
1
- import { Config } from './types.js';
2
- export declare const getConfig: (dir: string) => Promise<{
3
- config: Config;
4
- projectRoot: string;
5
- }>;
6
- //# sourceMappingURL=reader.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"reader.d.ts","sourceRoot":"","sources":["../src/reader.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAgB,MAAM,YAAY,CAAC;AAsElD,eAAO,MAAM,SAAS,GACpB,KAAK,MAAM,KACV,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAUjD,CAAC"}
package/dist/reader.js DELETED
@@ -1,57 +0,0 @@
1
- import { ConfigSchema } from './types.js';
2
- import { ConfigValidationError, ConfigNotFoundError, ConfigLoadError, } from './errors.js';
3
- import path from 'node:path';
4
- import fs from 'node:fs';
5
- import { createRequire } from 'node:module';
6
- import { ZodError } from 'zod';
7
- const extensions = ['.js', '.mjs', '.cjs', '.json'];
8
- const importUp = async (dir, name) => {
9
- const filePath = path.join(dir, name);
10
- for (const ext of extensions) {
11
- const filePathWithExt = `${filePath}${ext}`;
12
- if (fs.existsSync(filePathWithExt)) {
13
- let rawConfig;
14
- try {
15
- if (ext === '.mjs') {
16
- rawConfig = await import(filePathWithExt).then((module) => module.default);
17
- }
18
- else {
19
- const require = createRequire(import.meta.url);
20
- rawConfig = require(filePathWithExt);
21
- }
22
- }
23
- catch (error) {
24
- throw new ConfigLoadError(filePathWithExt, error instanceof Error ? error : undefined);
25
- }
26
- try {
27
- const config = ConfigSchema.parse(rawConfig);
28
- return { config, filePathWithExt, configDir: dir };
29
- }
30
- catch (error) {
31
- if (error instanceof ZodError) {
32
- const validationErrors = error.errors.map((err) => {
33
- const path = err.path.length > 0 ? ` at "${err.path.join('.')}"` : '';
34
- return `${err.message}${path}`;
35
- });
36
- throw new ConfigValidationError(filePathWithExt, validationErrors);
37
- }
38
- throw error;
39
- }
40
- }
41
- }
42
- const parentDir = path.dirname(dir);
43
- if (parentDir === dir) {
44
- throw new ConfigNotFoundError(dir);
45
- }
46
- return importUp(parentDir, name);
47
- };
48
- export const getConfig = async (dir) => {
49
- const { config, configDir } = await importUp(dir, 'rn-harness.config');
50
- return {
51
- config: {
52
- ...config,
53
- reporter: config.reporter,
54
- },
55
- projectRoot: configDir,
56
- };
57
- };