@react-native-harness/platform-android 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/__tests__/adb-wait.test.d.ts +2 -0
  2. package/dist/__tests__/adb-wait.test.d.ts.map +1 -0
  3. package/dist/__tests__/adb-wait.test.js +69 -0
  4. package/dist/__tests__/app-session.test.d.ts +2 -0
  5. package/dist/__tests__/app-session.test.d.ts.map +1 -0
  6. package/dist/__tests__/app-session.test.js +57 -0
  7. package/dist/__tests__/avd-config.test.js +58 -0
  8. package/dist/__tests__/crash-diagnostics.test.d.ts +2 -0
  9. package/dist/__tests__/crash-diagnostics.test.d.ts.map +1 -0
  10. package/dist/__tests__/crash-diagnostics.test.js +119 -0
  11. package/dist/__tests__/crash-reporter.test.d.ts +2 -0
  12. package/dist/__tests__/crash-reporter.test.d.ts.map +1 -0
  13. package/dist/__tests__/crash-reporter.test.js +150 -0
  14. package/dist/__tests__/emulator-startup.test.js +30 -3
  15. package/dist/__tests__/instance.test.js +139 -24
  16. package/dist/adb.d.ts +7 -1
  17. package/dist/adb.d.ts.map +1 -1
  18. package/dist/adb.js +72 -23
  19. package/dist/app-session.d.ts +26 -0
  20. package/dist/app-session.d.ts.map +1 -0
  21. package/dist/app-session.js +214 -0
  22. package/dist/avd-config.d.ts +1 -0
  23. package/dist/avd-config.d.ts.map +1 -1
  24. package/dist/avd-config.js +11 -0
  25. package/dist/crash-diagnostics.d.ts +40 -0
  26. package/dist/crash-diagnostics.d.ts.map +1 -0
  27. package/dist/crash-diagnostics.js +256 -0
  28. package/dist/crash-reporter.d.ts +12 -0
  29. package/dist/crash-reporter.d.ts.map +1 -0
  30. package/dist/crash-reporter.js +150 -0
  31. package/dist/emulator-startup.d.ts +23 -0
  32. package/dist/emulator-startup.d.ts.map +1 -1
  33. package/dist/emulator-startup.js +58 -0
  34. package/dist/index.d.ts +1 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -0
  37. package/dist/instance.d.ts +1 -1
  38. package/dist/instance.d.ts.map +1 -1
  39. package/dist/instance.js +51 -72
  40. package/dist/runner.d.ts +2 -2
  41. package/dist/runner.d.ts.map +1 -1
  42. package/dist/runner.js +1 -1
  43. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  44. package/package.json +4 -4
  45. package/src/__tests__/adb-wait.test.ts +95 -0
  46. package/src/__tests__/app-session.test.ts +76 -0
  47. package/src/__tests__/avd-config.test.ts +70 -0
  48. package/src/__tests__/crash-diagnostics.test.ts +144 -0
  49. package/src/__tests__/crash-reporter.test.ts +167 -0
  50. package/src/__tests__/emulator-startup.test.ts +53 -6
  51. package/src/__tests__/instance.test.ts +226 -84
  52. package/src/adb.ts +129 -63
  53. package/src/app-session.ts +316 -0
  54. package/src/avd-config.ts +17 -0
  55. package/src/crash-diagnostics.ts +471 -0
  56. package/src/crash-reporter.ts +263 -0
  57. package/src/emulator-startup.ts +74 -0
  58. package/src/index.ts +1 -0
  59. package/src/instance.ts +84 -105
  60. package/src/runner.ts +11 -11
  61. package/test/setup.ts +1 -0
  62. package/src/app-monitor.ts +0 -577
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=adb-wait.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adb-wait.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/adb-wait.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,69 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ const mocks = vi.hoisted(() => ({
3
+ spawn: vi.fn(),
4
+ }));
5
+ // The global test/setup.ts mocks '../adb.js' wholesale (including
6
+ // waitForEmulator itself), which is right for consumers of this module but
7
+ // defeats the purpose here: this suite exercises the *real* polling loop
8
+ // (and its private waitWithSignal helper) to catch timer/listener leaks.
9
+ // Re-declaring the mock for this file restores the actual implementation,
10
+ // while `spawn` and `getAdbBinaryPath` (genuine cross-module imports) are
11
+ // swapped out to keep `getDeviceIds()` from touching a real adb binary.
12
+ vi.mock('../adb.js', async () => {
13
+ const actual = await vi.importActual('../adb.js');
14
+ return actual;
15
+ });
16
+ vi.mock('../environment.js', async () => {
17
+ const actual = await vi.importActual('../environment.js');
18
+ return { ...actual, getAdbBinaryPath: () => '/fake/sdk/platform-tools/adb' };
19
+ });
20
+ vi.mock('@react-native-harness/tools', async () => {
21
+ const actual = await vi.importActual('@react-native-harness/tools');
22
+ return { ...actual, spawn: mocks.spawn };
23
+ });
24
+ const { waitForEmulator } = await import('../adb.js');
25
+ afterEach(() => {
26
+ vi.useRealTimers();
27
+ vi.restoreAllMocks();
28
+ });
29
+ describe('waitForEmulator polling', () => {
30
+ it('does not leak an abort listener per poll iteration when the delay wins', async () => {
31
+ vi.useFakeTimers();
32
+ // No matching device on any poll: `adb devices` reports an empty list.
33
+ mocks.spawn.mockResolvedValue({ stdout: 'List of devices attached\n' });
34
+ const controller = new AbortController();
35
+ const addSpy = vi.spyOn(controller.signal, 'addEventListener');
36
+ const removeSpy = vi.spyOn(controller.signal, 'removeEventListener');
37
+ const resultPromise = waitForEmulator('Test_AVD', controller.signal);
38
+ resultPromise.catch(() => undefined);
39
+ for (let i = 0; i < 3; i += 1) {
40
+ await vi.advanceTimersByTimeAsync(1_000);
41
+ }
42
+ // Regression: each 1s poll iteration used to add an 'abort' listener
43
+ // to the local waitForAbort helper that was never removed when the
44
+ // delay (not the abort) won the race, leaking one listener per second
45
+ // of polling against this session-lifetime signal.
46
+ expect(addSpy.mock.calls.length).toBeGreaterThanOrEqual(3);
47
+ const addCallsBeforeAbort = addSpy.mock.calls.length;
48
+ controller.abort(new Error('teardown'));
49
+ await expect(resultPromise).rejects.toThrow('teardown');
50
+ // Every listener added while losing the race — including the one still
51
+ // pending when the abort itself won — must be removed again.
52
+ expect(removeSpy).toHaveBeenCalledTimes(addCallsBeforeAbort);
53
+ });
54
+ it('clears the pending 1s poll timer when the signal aborts mid-wait', async () => {
55
+ vi.useFakeTimers();
56
+ mocks.spawn.mockResolvedValue({ stdout: 'List of devices attached\n' });
57
+ const controller = new AbortController();
58
+ const resultPromise = waitForEmulator('Test_AVD', controller.signal);
59
+ resultPromise.catch(() => undefined);
60
+ // Let the loop enter its 1s wait, then abort before the timer fires.
61
+ await vi.advanceTimersByTimeAsync(0);
62
+ controller.abort(new Error('teardown'));
63
+ await expect(resultPromise).rejects.toThrow('teardown');
64
+ // Regression: the bare setTimeout-backed `wait()` used to have no
65
+ // handle to clear, so it stayed alive in the event loop for the rest
66
+ // of the 1s interval even after the abort branch won.
67
+ expect(vi.getTimerCount()).toBe(0);
68
+ });
69
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=app-session.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app-session.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/app-session.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createAndroidAppSession } from '../app-session.js';
3
+ // Mimics nano-spawn: the async iterator only ends once its abort signal
4
+ // fires, matching how startLogcat's real subprocess reacts to abort.
5
+ const createAbortableLogcatProcess = (signal) => ({
6
+ [Symbol.asyncIterator]: () => ({
7
+ next: () => new Promise((resolve) => {
8
+ if (signal.aborted) {
9
+ resolve({ done: true, value: undefined });
10
+ return;
11
+ }
12
+ signal.addEventListener('abort', () => resolve({ done: true, value: undefined }), { once: true });
13
+ }),
14
+ }),
15
+ catch: () => undefined,
16
+ });
17
+ describe('createAndroidAppSession', () => {
18
+ it('combines the session-lifetime signal with its own logcat abort controller', async () => {
19
+ let capturedSignal;
20
+ const controller = new AbortController();
21
+ const session = await createAndroidAppSession({
22
+ appUid: 1,
23
+ bundleId: 'com.example',
24
+ startApp: async () => undefined,
25
+ stopApp: async () => undefined,
26
+ getAppPid: async () => null,
27
+ getLogcatTimestamp: async () => '00:00:00.000',
28
+ startLogcat: (_args, options) => {
29
+ capturedSignal = options.signal;
30
+ return createAbortableLogcatProcess(options.signal);
31
+ },
32
+ signal: controller.signal,
33
+ });
34
+ expect(capturedSignal?.aborted).toBe(false);
35
+ controller.abort();
36
+ expect(capturedSignal?.aborted).toBe(true);
37
+ await session.dispose();
38
+ });
39
+ it('still aborts the logcat signal via dispose() with no external signal provided', async () => {
40
+ let capturedSignal;
41
+ const session = await createAndroidAppSession({
42
+ appUid: 1,
43
+ bundleId: 'com.example',
44
+ startApp: async () => undefined,
45
+ stopApp: async () => undefined,
46
+ getAppPid: async () => null,
47
+ getLogcatTimestamp: async () => '00:00:00.000',
48
+ startLogcat: (_args, options) => {
49
+ capturedSignal = options.signal;
50
+ return createAbortableLogcatProcess(options.signal);
51
+ },
52
+ });
53
+ expect(capturedSignal?.aborted).toBe(false);
54
+ await session.dispose();
55
+ expect(capturedSignal?.aborted).toBe(true);
56
+ });
57
+ });
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { getNormalizedAvdCacheConfig, isAvdCompatible, parseAvdConfig, resolveAvdCachingEnabled, } from '../avd-config.js';
3
3
  import { AndroidPlatformConfigSchema } from '../config.js';
4
+ import { getEmulatorCpuCores } from '../emulator-startup.js';
5
+ const emulatorCpuCores = getEmulatorCpuCores();
4
6
  describe('AVD config helpers', () => {
5
7
  it('parses snapshot config from Android schema', () => {
6
8
  const config = AndroidPlatformConfigSchema.parse({
@@ -59,7 +61,9 @@ abi.type=x86_64
59
61
  hw.device.name=pixel_8
60
62
  disk.dataPartition.size=1G
61
63
  vm.heapSize=512M
64
+ hw.cpu.ncore=${emulatorCpuCores}
62
65
  `);
66
+ expect(avdConfig.hwCpuNcore).toBe(String(emulatorCpuCores));
63
67
  expect(isAvdCompatible({
64
68
  emulator: {
65
69
  type: 'emulator',
@@ -75,6 +79,59 @@ vm.heapSize=512M
75
79
  hostArch: 'x86_64',
76
80
  })).toEqual({ compatible: true });
77
81
  });
82
+ it('rejects AVDs missing hw.cpu.ncore', () => {
83
+ const avdConfig = parseAvdConfig(`
84
+ image.sysdir.1=system-images/android-35/default/x86_64/
85
+ abi.type=x86_64
86
+ hw.device.name=pixel_8
87
+ disk.dataPartition.size=1G
88
+ vm.heapSize=512M
89
+ `);
90
+ expect(isAvdCompatible({
91
+ emulator: {
92
+ type: 'emulator',
93
+ name: 'Pixel_8_API_35',
94
+ avd: {
95
+ apiLevel: 35,
96
+ profile: 'pixel_8',
97
+ diskSize: '1G',
98
+ heapSize: '512M',
99
+ },
100
+ },
101
+ avdConfig,
102
+ hostArch: 'x86_64',
103
+ })).toMatchObject({
104
+ compatible: false,
105
+ reason: `CPU core count mismatch: expected ${emulatorCpuCores}, got missing.`,
106
+ });
107
+ });
108
+ it('rejects AVDs with a different hw.cpu.ncore', () => {
109
+ const avdConfig = parseAvdConfig(`
110
+ image.sysdir.1=system-images/android-35/default/x86_64/
111
+ abi.type=x86_64
112
+ hw.device.name=pixel_8
113
+ disk.dataPartition.size=1G
114
+ vm.heapSize=512M
115
+ hw.cpu.ncore=${emulatorCpuCores === 4 ? 3 : 4}
116
+ `);
117
+ expect(isAvdCompatible({
118
+ emulator: {
119
+ type: 'emulator',
120
+ name: 'Pixel_8_API_35',
121
+ avd: {
122
+ apiLevel: 35,
123
+ profile: 'pixel_8',
124
+ diskSize: '1G',
125
+ heapSize: '512M',
126
+ },
127
+ },
128
+ avdConfig,
129
+ hostArch: 'x86_64',
130
+ })).toMatchObject({
131
+ compatible: false,
132
+ reason: `CPU core count mismatch: expected ${emulatorCpuCores}, got ${emulatorCpuCores === 4 ? 3 : 4}.`,
133
+ });
134
+ });
78
135
  it('accepts disk partition sizes rewritten to bytes', () => {
79
136
  const avdConfig = parseAvdConfig(`
80
137
  image.sysdir.1=system-images/android-35/default/x86_64/
@@ -82,6 +139,7 @@ abi.type=x86_64
82
139
  hw.device.name=pixel_8
83
140
  disk.dataPartition.size=6442450944
84
141
  vm.heapSize=512M
142
+ hw.cpu.ncore=${emulatorCpuCores}
85
143
  `);
86
144
  expect(isAvdCompatible({
87
145
  emulator: {
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=crash-diagnostics.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crash-diagnostics.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/crash-diagnostics.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,119 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { collectDropboxArtifacts, filterExitInfo, getBestDropboxArtifact, parseDropboxOutput, } from '../crash-diagnostics.js';
3
+ const javaCrashDropbox = `
4
+ Drop box contents: 1 entries
5
+ Max entries: 1000
6
+ ========================================
7
+ 2026-03-12 10:30:45.123 data_app_crash (text, 500 bytes)
8
+ Process: com.harnessplayground
9
+ PID: 7777
10
+ Package: com.harnessplayground v1 (1.0)
11
+ java.lang.RuntimeException: boom
12
+ \tat com.harnessplayground.MainActivity.onCreate(MainActivity.kt:38)
13
+ `;
14
+ const nativeCrashDropbox = `
15
+ ========================================
16
+ 2026-03-12 10:31:10.456 data_app_native_crash (text, 800 bytes)
17
+ Process: com.harnessplayground
18
+ PID: 8888
19
+ signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0xdeadbeef
20
+ Abort message: 'JNI DETECTED ERROR IN APPLICATION'
21
+ backtrace:
22
+ #00 pc 00001234 /data/app/lib/libapp.so
23
+ `;
24
+ const deviceJavaCrashDropbox = `
25
+ Drop box contents: 8 entries
26
+ Searching for: data_app_crash
27
+
28
+ ========================================
29
+ 2026-05-29 10:22:34 data_app_crash (text, 1328 bytes)
30
+ SystemUptimeMs: 79467172
31
+ Process: com.harnessplayground
32
+ PID: 586
33
+ Package: com.harnessplayground v1 (1.0)
34
+ java.lang.RuntimeException: Intentional asynchronous Kotlin crash
35
+ \tat com.harnessplayground.PlaygroundCrashModule.crashFromKotlinAsync(PlaygroundCrashModule.kt:44)
36
+ `;
37
+ describe('collectDropboxArtifacts', () => {
38
+ it('matches harness playground crashes from merged per-tag dropbox output', async () => {
39
+ const artifacts = await collectDropboxArtifacts({
40
+ bundleId: 'com.harnessplayground',
41
+ getDropboxOutput: async () => deviceJavaCrashDropbox,
42
+ occurredAt: Date.now(),
43
+ pid: 586,
44
+ });
45
+ expect(artifacts).toHaveLength(1);
46
+ expect(artifacts[0]).toMatchObject({
47
+ artifactType: 'dropbox-crash',
48
+ pid: 586,
49
+ processName: 'com.harnessplayground',
50
+ });
51
+ });
52
+ it('ignores Dropbox entries older than the session start time', async () => {
53
+ const artifacts = await collectDropboxArtifacts({
54
+ bundleId: 'com.harnessplayground',
55
+ getDropboxOutput: async () => deviceJavaCrashDropbox,
56
+ minOccurredAt: Date.parse('2026-05-29T10:22:35.000Z'),
57
+ occurredAt: Date.parse('2026-05-29T10:22:36.000Z'),
58
+ });
59
+ expect(artifacts).toHaveLength(0);
60
+ });
61
+ });
62
+ describe('parseDropboxOutput', () => {
63
+ it('parses java and native dropbox entries', () => {
64
+ const entries = parseDropboxOutput(`${javaCrashDropbox}\n${nativeCrashDropbox}`);
65
+ expect(entries).toHaveLength(2);
66
+ expect(entries[0]).toMatchObject({
67
+ tag: 'data_app_crash',
68
+ pid: 7777,
69
+ });
70
+ expect(entries[1]).toMatchObject({
71
+ tag: 'data_app_native_crash',
72
+ pid: 8888,
73
+ });
74
+ expect(entries[1]?.content).toContain('SIGSEGV');
75
+ });
76
+ });
77
+ describe('getBestDropboxArtifact', () => {
78
+ it('prefers the highest-scored artifact', () => {
79
+ const entries = parseDropboxOutput(`${javaCrashDropbox}\n${nativeCrashDropbox}`);
80
+ const artifacts = entries.map((entry, index) => ({
81
+ artifactType: entry.tag === 'data_app_native_crash'
82
+ ? 'dropbox-native-crash'
83
+ : 'dropbox-crash',
84
+ dropboxTag: entry.tag,
85
+ occurredAt: Date.now(),
86
+ score: index === 0 ? 100 : 300,
87
+ summary: entry.content,
88
+ pid: entry.pid,
89
+ processName: 'com.harnessplayground',
90
+ }));
91
+ expect(getBestDropboxArtifact(artifacts)?.dropboxTag).toBe('data_app_native_crash');
92
+ });
93
+ });
94
+ describe('filterExitInfo', () => {
95
+ it('returns matching exit-info records for the target package and pid', () => {
96
+ const output = `
97
+ ApplicationExitInfo #0:
98
+ package=com.harnessplayground pid=7777 realUid=10123
99
+ reason=4 (APP CRASH(NATIVE))
100
+ timestamp=2026-03-12 10:31:10.456
101
+ ApplicationExitInfo #1:
102
+ package=com.other.app pid=9999 realUid=10199
103
+ reason=10 (USER REQUESTED)
104
+ `;
105
+ const filtered = filterExitInfo({
106
+ output,
107
+ bundleId: 'com.harnessplayground',
108
+ pid: 7777,
109
+ });
110
+ expect(filtered).toContain('com.harnessplayground pid=7777');
111
+ expect(filtered).not.toContain('com.other.app');
112
+ });
113
+ it('returns null when no exit-info records exist', () => {
114
+ expect(filterExitInfo({
115
+ output: 'No exit info records for com.harnessplayground',
116
+ bundleId: 'com.harnessplayground',
117
+ })).toBeNull();
118
+ });
119
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=crash-reporter.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crash-reporter.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/crash-reporter.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,150 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { createAndroidCrashReporter } from '../crash-reporter.js';
3
+ const javaCrashDropbox = `
4
+ ========================================
5
+ 2026-03-12 10:30:45.123 data_app_crash (text, 500 bytes)
6
+ Process: com.harnessplayground
7
+ PID: 7777
8
+ Package: com.harnessplayground v1 (1.0)
9
+ java.lang.RuntimeException: boom
10
+ \tat com.harnessplayground.MainActivity.onCreate(MainActivity.kt:38)
11
+ `;
12
+ const nativeCrashDropbox = `
13
+ ========================================
14
+ 2026-03-12 10:31:10.456 data_app_native_crash (text, 800 bytes)
15
+ Process: com.harnessplayground
16
+ PID: 8888
17
+ signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0xdeadbeef
18
+ Abort message: 'JNI DETECTED ERROR IN APPLICATION'
19
+ backtrace:
20
+ #00 pc 00001234 /data/app/lib/libapp.so
21
+ `;
22
+ describe('createAndroidCrashReporter', () => {
23
+ afterEach(() => {
24
+ vi.useRealTimers();
25
+ });
26
+ it('persists the current logcat crash block with the test file path', async () => {
27
+ vi.useFakeTimers();
28
+ const persistArtifact = vi.fn(() => '/tmp/.harness/crash-reports/logcat.txt');
29
+ const reporter = createAndroidCrashReporter({
30
+ bundleId: 'com.harnessplayground',
31
+ crashArtifactWriter: {
32
+ runTimestamp: '2026-03-12T11-35-08-000Z',
33
+ persistArtifact,
34
+ },
35
+ getLogs: () => [
36
+ { line: 'ordinary crash-buffer line', occurredAt: 1_000 },
37
+ { line: '--------- beginning of crash', occurredAt: 1_100 },
38
+ {
39
+ line: 'Process: com.harnessplayground, PID: 7777',
40
+ occurredAt: 1_100,
41
+ },
42
+ { line: 'FATAL EXCEPTION: main', occurredAt: 1_100 },
43
+ ],
44
+ });
45
+ const detailsPromise = reporter.getCrashDetails({
46
+ occurredAt: 1_100,
47
+ pid: 7777,
48
+ testFilePath: '/test/native-crash.test.ts',
49
+ });
50
+ await vi.advanceTimersByTimeAsync(100);
51
+ const details = await detailsPromise;
52
+ expect(details).toMatchObject({
53
+ artifactType: 'logcat',
54
+ artifactPath: '/tmp/.harness/crash-reports/logcat.txt',
55
+ pid: 7777,
56
+ processName: 'com.harnessplayground',
57
+ });
58
+ expect(persistArtifact).toHaveBeenCalledWith({
59
+ artifactKind: 'logcat',
60
+ testFilePath: '/test/native-crash.test.ts',
61
+ source: {
62
+ kind: 'text',
63
+ fileName: 'logcat.txt',
64
+ text: [
65
+ '--------- beginning of crash',
66
+ 'Process: com.harnessplayground, PID: 7777',
67
+ 'FATAL EXCEPTION: main',
68
+ '',
69
+ ].join('\n'),
70
+ },
71
+ });
72
+ });
73
+ it('adds dropbox artifacts as enrichment when logcat evidence is available', async () => {
74
+ vi.useFakeTimers();
75
+ const persistArtifact = vi
76
+ .fn()
77
+ .mockImplementation(({ artifactKind }) => {
78
+ return `/tmp/.harness/crash-reports/${artifactKind}.txt`;
79
+ });
80
+ const reporter = createAndroidCrashReporter({
81
+ bundleId: 'com.harnessplayground',
82
+ crashArtifactWriter: {
83
+ runTimestamp: '2026-03-12T11-35-08-000Z',
84
+ persistArtifact,
85
+ },
86
+ getLogs: () => [
87
+ { line: '--------- beginning of crash', occurredAt: 1_100 },
88
+ {
89
+ line: 'Process: com.harnessplayground, PID: 7777',
90
+ occurredAt: 1_100,
91
+ },
92
+ { line: 'FATAL EXCEPTION: main', occurredAt: 1_100 },
93
+ ],
94
+ getDropboxOutput: async () => javaCrashDropbox,
95
+ getExitInfo: async () => 'ApplicationExitInfo #0:\n package=com.harnessplayground pid=7777 reason=4 (APP CRASH(NATIVE))',
96
+ });
97
+ const detailsPromise = reporter.getCrashDetails({
98
+ occurredAt: 1_100,
99
+ pid: 7777,
100
+ testFilePath: '/test/native-crash.test.ts',
101
+ });
102
+ await vi.advanceTimersByTimeAsync(100);
103
+ const details = await detailsPromise;
104
+ expect(details).toMatchObject({
105
+ artifactType: 'logcat',
106
+ artifactPath: '/tmp/.harness/crash-reports/logcat.txt',
107
+ });
108
+ expect(details?.enrichmentArtifacts).toEqual(expect.arrayContaining([
109
+ {
110
+ artifactType: 'dropbox-crash',
111
+ artifactPath: '/tmp/.harness/crash-reports/dropbox-crash.txt',
112
+ },
113
+ {
114
+ artifactType: 'exit-info',
115
+ artifactPath: '/tmp/.harness/crash-reports/exit-info.txt',
116
+ },
117
+ ]));
118
+ });
119
+ it('falls back to native dropbox evidence when logcat is empty', async () => {
120
+ vi.useFakeTimers();
121
+ const persistArtifact = vi
122
+ .fn()
123
+ .mockImplementation(({ artifactKind }) => {
124
+ return `/tmp/.harness/crash-reports/${artifactKind}.txt`;
125
+ });
126
+ const reporter = createAndroidCrashReporter({
127
+ bundleId: 'com.harnessplayground',
128
+ crashArtifactWriter: {
129
+ runTimestamp: '2026-03-12T11-35-08-000Z',
130
+ persistArtifact,
131
+ },
132
+ getLogs: () => [],
133
+ getDropboxOutput: async () => nativeCrashDropbox,
134
+ });
135
+ const detailsPromise = reporter.getCrashDetails({
136
+ occurredAt: 1_100,
137
+ pid: 8888,
138
+ testFilePath: '/test/native-crash.test.ts',
139
+ });
140
+ await vi.advanceTimersByTimeAsync(100);
141
+ const details = await detailsPromise;
142
+ expect(details).toMatchObject({
143
+ artifactType: 'dropbox-native-crash',
144
+ artifactPath: '/tmp/.harness/crash-reports/dropbox-native-crash.txt',
145
+ pid: 8888,
146
+ signal: 'SIGSEGV',
147
+ processName: 'com.harnessplayground',
148
+ });
149
+ });
150
+ });
@@ -1,20 +1,47 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { getEmulatorStartupArgs } from '../emulator-startup.js';
2
+ import { getEmulatorCpuCores, getEmulatorStartupArgs, isAccelerationUsable, } from '../emulator-startup.js';
3
+ import { getDeviceCoreBudget } from '@react-native-harness/tools';
4
+ import os from 'node:os';
5
+ describe('getEmulatorCpuCores', () => {
6
+ it('uses the shared device-core budget for the current host', () => {
7
+ expect(getEmulatorCpuCores()).toBe(getDeviceCoreBudget(os.availableParallelism()));
8
+ });
9
+ });
3
10
  describe('emulator startup modes', () => {
4
11
  it('builds default boot args', () => {
5
12
  expect(getEmulatorStartupArgs('Pixel_8_API_35', 'default-boot')).toEqual(expect.arrayContaining([
6
13
  '@Pixel_8_API_35',
7
14
  '-no-snapshot-load',
8
15
  '-no-snapshot-save',
16
+ '-no-metrics',
9
17
  ]));
10
18
  expect(getEmulatorStartupArgs('Pixel_8_API_35', 'default-boot')).not.toEqual(expect.arrayContaining(['-camera-back', 'none']));
11
19
  });
12
20
  it('builds clean snapshot generation args', () => {
13
- expect(getEmulatorStartupArgs('Pixel_8_API_35', 'clean-snapshot-generation')).toEqual(expect.arrayContaining(['@Pixel_8_API_35', '-no-snapshot-load']));
21
+ expect(getEmulatorStartupArgs('Pixel_8_API_35', 'clean-snapshot-generation')).toEqual(expect.arrayContaining([
22
+ '@Pixel_8_API_35',
23
+ '-no-snapshot-load',
24
+ '-no-metrics',
25
+ ]));
14
26
  expect(getEmulatorStartupArgs('Pixel_8_API_35', 'clean-snapshot-generation')).not.toContain('-no-snapshot-save');
15
27
  });
16
28
  it('builds snapshot reuse args', () => {
17
- expect(getEmulatorStartupArgs('Pixel_8_API_35', 'snapshot-reuse')).toEqual(expect.arrayContaining(['@Pixel_8_API_35', '-no-snapshot-save']));
29
+ expect(getEmulatorStartupArgs('Pixel_8_API_35', 'snapshot-reuse')).toEqual(expect.arrayContaining([
30
+ '@Pixel_8_API_35',
31
+ '-no-snapshot-save',
32
+ '-no-metrics',
33
+ ]));
18
34
  expect(getEmulatorStartupArgs('Pixel_8_API_35', 'snapshot-reuse')).not.toContain('-no-snapshot-load');
19
35
  });
20
36
  });
37
+ describe('isAccelerationUsable', () => {
38
+ it('returns true when the emulator reports acceleration is usable', () => {
39
+ expect(isAccelerationUsable('accel:\n0\nKVM (version 12) is installed and usable.\naccel\n')).toBe(true);
40
+ });
41
+ it('returns false when acceleration is disabled', () => {
42
+ expect(isAccelerationUsable('accel:\n0\nKVM is not installed on this machine.\naccel\n')).toBe(false);
43
+ });
44
+ it('returns false for empty output', () => {
45
+ expect(isAccelerationUsable('')).toBe(false);
46
+ });
47
+ });