@react-native-harness/platform-android 1.0.0 → 1.1.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.
@@ -0,0 +1,273 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { createAndroidAppMonitor, createAndroidLogEvent } from '../app-monitor.js';
6
+ import * as tools from '@react-native-harness/tools';
7
+ import { createCrashArtifactWriter } from '@react-native-harness/tools';
8
+
9
+ const createMockSubprocess = (): tools.Subprocess =>
10
+ ({
11
+ nodeChildProcess: Promise.resolve({
12
+ kill: vi.fn(),
13
+ }),
14
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
15
+ [Symbol.asyncIterator]: async function* () {},
16
+ }) as unknown as tools.Subprocess;
17
+
18
+ const createStreamingSubprocess = (
19
+ chunks: Array<{ line: string; delayMs?: number }>
20
+ ): tools.Subprocess =>
21
+ ({
22
+ nodeChildProcess: Promise.resolve({
23
+ kill: vi.fn(),
24
+ }),
25
+ [Symbol.asyncIterator]: async function* () {
26
+ for (const { line, delayMs = 0 } of chunks) {
27
+ if (delayMs > 0) {
28
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
29
+ }
30
+
31
+ yield line;
32
+ }
33
+ },
34
+ }) as unknown as tools.Subprocess;
35
+
36
+ const artifactRoot = fs.mkdtempSync(
37
+ path.join(tmpdir(), 'rn-harness-android-monitor-artifacts-')
38
+ );
39
+
40
+ afterEach(() => {
41
+ fs.rmSync(artifactRoot, { recursive: true, force: true });
42
+ fs.mkdirSync(artifactRoot, { recursive: true });
43
+ });
44
+
45
+ describe('createAndroidLogEvent', () => {
46
+ it('extracts crash details from fatal signal log lines', () => {
47
+ const event = createAndroidLogEvent(
48
+ '03-12 11:35:08.000 1234 1234 F libc : Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR) in tid 1234 (com.harnessplayground), pid 1234 (com.harnessplayground)',
49
+ 'com.harnessplayground'
50
+ );
51
+
52
+ expect(event).toMatchObject({
53
+ type: 'possible_crash',
54
+ source: 'logs',
55
+ crashDetails: {
56
+ source: 'logs',
57
+ signal: 'SIGSEGV',
58
+ summary:
59
+ '03-12 11:35:08.000 1234 1234 F libc : Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR) in tid 1234 (com.harnessplayground), pid 1234 (com.harnessplayground)',
60
+ },
61
+ });
62
+ });
63
+
64
+ it('extracts process and pid when AndroidRuntime reports a crash', () => {
65
+ const event = createAndroidLogEvent(
66
+ '03-12 11:35:09.000 1234 1234 E AndroidRuntime: Process: com.harnessplayground, PID: 1234',
67
+ 'com.harnessplayground'
68
+ );
69
+
70
+ expect(event).toMatchObject({
71
+ type: 'possible_crash',
72
+ pid: 1234,
73
+ crashDetails: {
74
+ processName: 'com.harnessplayground',
75
+ pid: 1234,
76
+ },
77
+ });
78
+ });
79
+
80
+ it('starts logcat from the current device timestamp', async () => {
81
+ const spawnSpy = vi.spyOn(tools, 'spawn');
82
+
83
+ spawnSpy.mockImplementation(
84
+ ((file: string, args?: readonly string[]) => {
85
+ if (file === 'adb' && args?.includes('date')) {
86
+ return {
87
+ stdout: '03-12 11:35:08.000\n',
88
+ } as Awaited<ReturnType<typeof tools.spawn>>;
89
+ }
90
+
91
+ return createMockSubprocess();
92
+ }) as typeof tools.spawn
93
+ );
94
+
95
+ const monitor = createAndroidAppMonitor({
96
+ adbId: 'emulator-5554',
97
+ bundleId: 'com.harnessplayground',
98
+ appUid: 10234,
99
+ });
100
+
101
+ await monitor.start();
102
+ await monitor.stop();
103
+
104
+ expect(spawnSpy).toHaveBeenNthCalledWith(2, 'adb', [
105
+ '-s',
106
+ 'emulator-5554',
107
+ 'logcat',
108
+ '-v',
109
+ 'threadtime',
110
+ '-b',
111
+ 'crash',
112
+ '--uid=10234',
113
+ '-T',
114
+ '03-12 11:35:08.000',
115
+ ], {
116
+ stdout: 'pipe',
117
+ stderr: 'pipe',
118
+ });
119
+ });
120
+
121
+ it('hydrates crash details with stack lines that arrive after the first crash event', async () => {
122
+ const spawnSpy = vi.spyOn(tools, 'spawn');
123
+
124
+ spawnSpy.mockImplementation(
125
+ ((file: string, args?: readonly string[]) => {
126
+ if (file === 'adb' && args?.includes('date')) {
127
+ return {
128
+ stdout: '03-12 10:44:40.000\n',
129
+ } as Awaited<ReturnType<typeof tools.spawn>>;
130
+ }
131
+
132
+ return createStreamingSubprocess([
133
+ { line: '--------- beginning of crash' },
134
+ {
135
+ line: '03-12 10:44:40.420 13861 13861 E AndroidRuntime: Process: com.harnessplayground, PID: 13861',
136
+ },
137
+ {
138
+ line: '03-12 10:44:40.421 13861 13861 E AndroidRuntime: java.lang.RuntimeException: boom',
139
+ delayMs: 25,
140
+ },
141
+ {
142
+ line: '03-12 10:44:40.422 13861 13861 E AndroidRuntime: at com.harnessplayground.MainActivity.onCreate(MainActivity.kt:42)',
143
+ delayMs: 25,
144
+ },
145
+ ]);
146
+ }) as typeof tools.spawn
147
+ );
148
+
149
+ const monitor = createAndroidAppMonitor({
150
+ adbId: 'emulator-5554',
151
+ bundleId: 'com.harnessplayground',
152
+ appUid: 10234,
153
+ });
154
+
155
+ await monitor.start();
156
+ await new Promise((resolve) => setTimeout(resolve, 10));
157
+
158
+ const details = await monitor.getCrashDetails({
159
+ pid: 13861,
160
+ occurredAt: Date.now(),
161
+ });
162
+
163
+ await monitor.stop();
164
+
165
+ expect(details?.rawLines).toEqual([
166
+ '--------- beginning of crash',
167
+ '03-12 10:44:40.420 13861 13861 E AndroidRuntime: Process: com.harnessplayground, PID: 13861',
168
+ '03-12 10:44:40.421 13861 13861 E AndroidRuntime: java.lang.RuntimeException: boom',
169
+ '03-12 10:44:40.422 13861 13861 E AndroidRuntime: at com.harnessplayground.MainActivity.onCreate(MainActivity.kt:42)',
170
+ ]);
171
+ });
172
+
173
+ it('persists resolved Android crash blocks into .harness', async () => {
174
+ const spawnSpy = vi.spyOn(tools, 'spawn');
175
+
176
+ spawnSpy.mockImplementation(
177
+ ((file: string, args?: readonly string[]) => {
178
+ if (file === 'adb' && args?.includes('date')) {
179
+ return {
180
+ stdout: '03-12 10:44:40.000\n',
181
+ } as Awaited<ReturnType<typeof tools.spawn>>;
182
+ }
183
+
184
+ return createStreamingSubprocess([
185
+ { line: '--------- beginning of crash' },
186
+ {
187
+ line: '03-12 10:44:40.420 13861 13861 E AndroidRuntime: Process: com.harnessplayground, PID: 13861',
188
+ },
189
+ {
190
+ line: '03-12 10:44:40.421 13861 13861 E AndroidRuntime: java.lang.RuntimeException: boom',
191
+ delayMs: 20,
192
+ },
193
+ ]);
194
+ }) as typeof tools.spawn
195
+ );
196
+
197
+ const monitor = createAndroidAppMonitor({
198
+ adbId: 'emulator-5554',
199
+ bundleId: 'com.harnessplayground',
200
+ appUid: 10234,
201
+ crashArtifactWriter: createCrashArtifactWriter({
202
+ runnerName: 'android',
203
+ platformId: 'android',
204
+ rootDir: path.join(artifactRoot, '.harness', 'crash-reports'),
205
+ runTimestamp: '2026-03-12T11-35-08-000Z',
206
+ }),
207
+ });
208
+
209
+ await monitor.start();
210
+ await new Promise((resolve) => setTimeout(resolve, 10));
211
+
212
+ const details = await monitor.getCrashDetails({
213
+ pid: 13861,
214
+ occurredAt: Date.now(),
215
+ });
216
+
217
+ await monitor.stop();
218
+
219
+ expect(details?.artifactPath).toContain('/.harness/crash-reports/');
220
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
221
+ expect(fs.readFileSync(details!.artifactPath!, 'utf8')).toContain(
222
+ 'RuntimeException: boom'
223
+ );
224
+ });
225
+
226
+ it('can be started again after timestamp lookup fails', async () => {
227
+ const spawnSpy = vi.spyOn(tools, 'spawn');
228
+ const timestampError = new Error('date failed');
229
+
230
+ spawnSpy.mockImplementation(
231
+ ((file: string, args?: readonly string[]) => {
232
+ if (file === 'adb' && args?.includes('date')) {
233
+ if (
234
+ spawnSpy.mock.calls.filter(
235
+ ([calledFile, calledArgs]) =>
236
+ calledFile === 'adb' &&
237
+ Array.isArray(calledArgs) &&
238
+ calledArgs.includes('date')
239
+ ).length === 1
240
+ ) {
241
+ throw timestampError;
242
+ }
243
+
244
+ return {
245
+ stdout: '03-12 11:35:08.000\n',
246
+ } as Awaited<ReturnType<typeof tools.spawn>>;
247
+ }
248
+
249
+ return createMockSubprocess();
250
+ }) as typeof tools.spawn
251
+ );
252
+
253
+ const monitor = createAndroidAppMonitor({
254
+ adbId: 'emulator-5554',
255
+ bundleId: 'com.harnessplayground',
256
+ appUid: 10234,
257
+ });
258
+
259
+ await expect(monitor.start()).rejects.toThrow(timestampError);
260
+ await expect(monitor.start()).resolves.toBeUndefined();
261
+ await monitor.stop();
262
+
263
+ expect(
264
+ spawnSpy.mock.calls.some(
265
+ ([file, args]) =>
266
+ file === 'adb' &&
267
+ Array.isArray(args) &&
268
+ args.includes('logcat') &&
269
+ args.includes('--uid=10234')
270
+ )
271
+ ).toBe(true);
272
+ });
273
+ });
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { androidCrashParser } from '../crash-parser.js';
3
+
4
+ describe('androidCrashParser.parse', () => {
5
+ it('parses an AndroidRuntime crash block into a crash details object', () => {
6
+ expect(
7
+ androidCrashParser.parse({
8
+ contents: [
9
+ '--------- beginning of crash',
10
+ '03-12 11:35:09.000 1234 1234 E AndroidRuntime: Process: com.harnessplayground, PID: 1234',
11
+ '03-12 11:35:09.001 1234 1234 E AndroidRuntime: java.lang.RuntimeException: boom',
12
+ '03-12 11:35:09.002 1234 1234 E AndroidRuntime: at com.harnessplayground.MainActivity.onCreate(MainActivity.kt:42)',
13
+ ].join('\n'),
14
+ bundleId: 'com.harnessplayground',
15
+ })
16
+ ).toEqual({
17
+ source: 'logs',
18
+ summary: [
19
+ '--------- beginning of crash',
20
+ '03-12 11:35:09.000 1234 1234 E AndroidRuntime: Process: com.harnessplayground, PID: 1234',
21
+ '03-12 11:35:09.001 1234 1234 E AndroidRuntime: java.lang.RuntimeException: boom',
22
+ '03-12 11:35:09.002 1234 1234 E AndroidRuntime: at com.harnessplayground.MainActivity.onCreate(MainActivity.kt:42)',
23
+ ].join('\n'),
24
+ signal: undefined,
25
+ exceptionType: 'java.lang.RuntimeException: boom',
26
+ processName: 'com.harnessplayground',
27
+ pid: 1234,
28
+ rawLines: [
29
+ '--------- beginning of crash',
30
+ '03-12 11:35:09.000 1234 1234 E AndroidRuntime: Process: com.harnessplayground, PID: 1234',
31
+ '03-12 11:35:09.001 1234 1234 E AndroidRuntime: java.lang.RuntimeException: boom',
32
+ '03-12 11:35:09.002 1234 1234 E AndroidRuntime: at com.harnessplayground.MainActivity.onCreate(MainActivity.kt:42)',
33
+ ],
34
+ stackTrace: [
35
+ '03-12 11:35:09.002 1234 1234 E AndroidRuntime: at com.harnessplayground.MainActivity.onCreate(MainActivity.kt:42)',
36
+ ],
37
+ });
38
+ });
39
+
40
+ it('extracts fatal signals from a native crash block', () => {
41
+ expect(
42
+ androidCrashParser.parse({
43
+ contents:
44
+ '03-12 11:35:08.000 1234 1234 F libc : Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR) in tid 1234 (com.harnessplayground), pid 1234 (com.harnessplayground)',
45
+ bundleId: 'com.harnessplayground',
46
+ })
47
+ ).toMatchObject({
48
+ signal: 'SIGSEGV',
49
+ processName: 'com.harnessplayground',
50
+ });
51
+ });
52
+ });
package/src/adb.ts CHANGED
@@ -1,4 +1,47 @@
1
- import { spawn } from '@react-native-harness/tools';
1
+ import { type AndroidAppLaunchOptions } from '@react-native-harness/platforms';
2
+ import { spawn, SubprocessError } from '@react-native-harness/tools';
3
+
4
+ export const getStartAppArgs = (
5
+ bundleId: string,
6
+ activityName: string,
7
+ options?: AndroidAppLaunchOptions
8
+ ): string[] => {
9
+ const args = [
10
+ 'shell',
11
+ 'am',
12
+ 'start',
13
+ '-a',
14
+ 'android.intent.action.MAIN',
15
+ '-c',
16
+ 'android.intent.category.LAUNCHER',
17
+ '-n',
18
+ `${bundleId}/${activityName}`,
19
+ ];
20
+
21
+ const extras = options?.extras ?? {};
22
+
23
+ for (const [key, value] of Object.entries(extras)) {
24
+ if (typeof value === 'string') {
25
+ args.push('--es', key, value);
26
+ continue;
27
+ }
28
+
29
+ if (typeof value === 'boolean') {
30
+ args.push('--ez', key, value ? 'true' : 'false');
31
+ continue;
32
+ }
33
+
34
+ if (!Number.isSafeInteger(value)) {
35
+ throw new Error(
36
+ `Android app launch option "${key}" must be a safe integer.`
37
+ );
38
+ }
39
+
40
+ args.push('--ei', key, value.toString());
41
+ }
42
+
43
+ return args;
44
+ };
2
45
 
3
46
  export const isAppInstalled = async (
4
47
  adbId: string,
@@ -40,21 +83,10 @@ export const stopApp = async (
40
83
  export const startApp = async (
41
84
  adbId: string,
42
85
  bundleId: string,
43
- activityName: string
86
+ activityName: string,
87
+ options?: AndroidAppLaunchOptions
44
88
  ): Promise<void> => {
45
- await spawn('adb', [
46
- '-s',
47
- adbId,
48
- 'shell',
49
- 'am',
50
- 'start',
51
- '-a',
52
- 'android.intent.action.MAIN',
53
- '-c',
54
- 'android.intent.category.LAUNCHER',
55
- '-n',
56
- `${bundleId}/${activityName}`,
57
- ]);
89
+ await spawn('adb', ['-s', adbId, ...getStartAppArgs(bundleId, activityName, options)]);
58
90
  };
59
91
 
60
92
  export const getDeviceIds = async (): Promise<string[]> => {
@@ -113,14 +145,75 @@ export const isAppRunning = async (
113
145
  adbId: string,
114
146
  bundleId: string
115
147
  ): Promise<boolean> => {
148
+ try {
149
+ const { stdout } = await spawn('adb', [
150
+ '-s',
151
+ adbId,
152
+ 'shell',
153
+ 'pidof',
154
+ bundleId,
155
+ ]);
156
+ return stdout.trim() !== '';
157
+ } catch (error) {
158
+ if (error instanceof SubprocessError && error.exitCode === 1) {
159
+ return false;
160
+ }
161
+
162
+ throw error;
163
+ }
164
+ };
165
+
166
+ export const getAppUid = async (
167
+ adbId: string,
168
+ bundleId: string
169
+ ): Promise<number> => {
116
170
  const { stdout } = await spawn('adb', [
117
171
  '-s',
118
172
  adbId,
119
173
  'shell',
120
- 'pidof',
121
- bundleId,
174
+ 'pm',
175
+ 'list',
176
+ 'packages',
177
+ '-U',
122
178
  ]);
123
- return stdout.trim() !== '';
179
+ const line = stdout
180
+ .split('\n')
181
+ .find((entry) => entry.includes(`package:${bundleId}`));
182
+ const match = line?.match(/\buid:(\d+)\b/);
183
+
184
+ if (!match) {
185
+ throw new Error(`Failed to resolve Android app UID for "${bundleId}".`);
186
+ }
187
+
188
+ return Number(match[1]);
189
+ };
190
+
191
+ export const setHideErrorDialogs = async (
192
+ adbId: string,
193
+ hide: boolean
194
+ ): Promise<void> => {
195
+ await spawn('adb', [
196
+ '-s',
197
+ adbId,
198
+ 'shell',
199
+ 'settings',
200
+ 'put',
201
+ 'global',
202
+ 'hide_error_dialogs',
203
+ hide ? '1' : '0',
204
+ ]);
205
+ };
206
+
207
+ export const getLogcatTimestamp = async (adbId: string): Promise<string> => {
208
+ const { stdout } = await spawn('adb', [
209
+ '-s',
210
+ adbId,
211
+ 'shell',
212
+ 'date',
213
+ "+'%m-%d %H:%M:%S.000'",
214
+ ]);
215
+
216
+ return stdout.trim().replace(/^'+|'+$/g, '');
124
217
  };
125
218
 
126
219
  export const getAvds = async (): Promise<string[]> => {