@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,316 @@
1
+ import {
2
+ createAppSessionEmitter,
3
+ createBoundedLogBuffer,
4
+ type AppSession,
5
+ type AppSessionState,
6
+ type CrashArtifactWriter,
7
+ } from '@react-native-harness/platforms';
8
+ import {
9
+ escapeRegExp,
10
+ logger,
11
+ type Subprocess,
12
+ } from '@react-native-harness/tools';
13
+ import { createAndroidCrashReporter } from './crash-reporter.js';
14
+
15
+ const androidAppSessionLogger = logger.child('android-app-session');
16
+ const APP_EXIT_POLL_INTERVAL_MS = 1000;
17
+
18
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
19
+
20
+ const getLogcatArgs = (appUid: number, fromTime: string) =>
21
+ [
22
+ 'logcat',
23
+ '-v',
24
+ 'threadtime',
25
+ '-b',
26
+ 'crash',
27
+ `--uid=${appUid}`,
28
+ '-T',
29
+ fromTime,
30
+ ] as const;
31
+
32
+ const getProcessPattern = (bundleId: string) =>
33
+ new RegExp(`Process:\\s*${escapeRegExp(bundleId)},\\s*PID:\\s*(\\d+)`);
34
+
35
+ const getStartProcPattern = (bundleId: string) =>
36
+ new RegExp(`Start proc (\\d+):${escapeRegExp(bundleId)}(?:/|\\s)`);
37
+
38
+ const getProcessDiedPattern = (bundleId: string) =>
39
+ new RegExp(
40
+ `Process\\s+${escapeRegExp(
41
+ bundleId
42
+ )}\\s+\\(pid\\s+(\\d+)\\)\\s+has\\s+died`,
43
+ 'i'
44
+ );
45
+
46
+ const getObservedPid = (line: string, bundleId: string): number | undefined => {
47
+ const match =
48
+ line.match(getProcessPattern(bundleId)) ??
49
+ line.match(getStartProcPattern(bundleId)) ??
50
+ line.match(getProcessDiedPattern(bundleId));
51
+
52
+ return match?.[1] ? Number(match[1]) : undefined;
53
+ };
54
+
55
+ const isCrashSignal = (line: string, bundleId: string): boolean => {
56
+ return (
57
+ getProcessPattern(bundleId).test(line) ||
58
+ new RegExp(`>>>\\s*${escapeRegExp(bundleId)}\\s*<<<`).test(line) ||
59
+ getProcessDiedPattern(bundleId).test(line) ||
60
+ (line.includes(bundleId) &&
61
+ /fatal|crash|signal 11|signal 6|backtrace/i.test(line))
62
+ );
63
+ };
64
+
65
+ const isExitedState = (
66
+ state: AppSessionState
67
+ ): state is Extract<AppSessionState, { status: 'exited' }> =>
68
+ state.status === 'exited';
69
+
70
+ type CreateAndroidAppSessionOptions = {
71
+ appUid: number;
72
+ bundleId: string;
73
+ startApp: () => Promise<void>;
74
+ stopApp: () => Promise<void>;
75
+ getAppPid: () => Promise<number | null>;
76
+ getLogcatTimestamp: () => Promise<string>;
77
+ startLogcat: (
78
+ args: readonly string[],
79
+ options: { signal: AbortSignal }
80
+ ) => Subprocess;
81
+ getDropboxOutput?: () => Promise<string>;
82
+ getExitInfo?: () => Promise<string>;
83
+ crashArtifactWriter?: CrashArtifactWriter;
84
+ /**
85
+ * Session-lifetime abort signal (see HarnessPlatformInitOptions.signal).
86
+ * Combined with the app session's own logcatAbortController so that
87
+ * aborting the session stops the logcat stream even if dispose() hasn't
88
+ * been called yet (e.g. SIGTERM during teardown of other resources).
89
+ */
90
+ signal?: AbortSignal;
91
+ };
92
+
93
+ export const createAndroidAppSession = async ({
94
+ appUid,
95
+ bundleId,
96
+ startApp,
97
+ stopApp,
98
+ getAppPid,
99
+ getLogcatTimestamp,
100
+ startLogcat,
101
+ getDropboxOutput,
102
+ getExitInfo,
103
+ crashArtifactWriter,
104
+ signal,
105
+ }: CreateAndroidAppSessionOptions): Promise<AppSession> => {
106
+ const emitter = createAppSessionEmitter();
107
+ const logBuffer = createBoundedLogBuffer();
108
+ let state: AppSessionState = { status: 'running' };
109
+ let disposed = false;
110
+ let stopPolling = false;
111
+ let hasObservedProcess = false;
112
+ let exitNotification: ReturnType<typeof setTimeout> | null = null;
113
+ let pollDelayTimeout: ReturnType<typeof setTimeout> | null = null;
114
+ let resolvePollDelay: (() => void) | null = null;
115
+
116
+ const getCurrentPid = () => ('pid' in state ? state.pid : undefined);
117
+
118
+ const setRunning = (pid?: number) => {
119
+ if (disposed || state.status === 'disposed' || state.status === 'exited') {
120
+ return;
121
+ }
122
+
123
+ if (pid !== undefined) {
124
+ hasObservedProcess = true;
125
+ }
126
+
127
+ state =
128
+ pid === undefined ? { status: 'running' } : { status: 'running', pid };
129
+ };
130
+
131
+ const scheduleExitNotification = () => {
132
+ if (exitNotification) {
133
+ return;
134
+ }
135
+
136
+ exitNotification = setTimeout(() => {
137
+ exitNotification = null;
138
+
139
+ if (!disposed && state.status === 'exited') {
140
+ emitter.emit({ type: 'app_exited' });
141
+ }
142
+ }, 0);
143
+ };
144
+
145
+ const waitForNextPoll = () =>
146
+ new Promise<void>((resolve) => {
147
+ resolvePollDelay = () => {
148
+ resolvePollDelay = null;
149
+ pollDelayTimeout = null;
150
+ resolve();
151
+ };
152
+
153
+ pollDelayTimeout = setTimeout(() => {
154
+ resolvePollDelay?.();
155
+ }, APP_EXIT_POLL_INTERVAL_MS);
156
+ });
157
+
158
+ const cancelPendingPollDelay = () => {
159
+ if (pollDelayTimeout) {
160
+ clearTimeout(pollDelayTimeout);
161
+ pollDelayTimeout = null;
162
+ }
163
+
164
+ resolvePollDelay?.();
165
+ };
166
+
167
+ const setExited = (
168
+ reason: 'observed-exit' | 'process-gone',
169
+ pid?: number
170
+ ) => {
171
+ if (disposed || state.status === 'disposed' || state.status === 'exited') {
172
+ return;
173
+ }
174
+
175
+ state = {
176
+ status: 'exited',
177
+ occurredAt: Date.now(),
178
+ pid: pid ?? getCurrentPid(),
179
+ reason,
180
+ };
181
+ scheduleExitNotification();
182
+ };
183
+
184
+ const setExitedPid = (pid: number) => {
185
+ if (state.status !== 'exited' || state.pid !== undefined) {
186
+ return;
187
+ }
188
+
189
+ state = {
190
+ status: 'exited',
191
+ occurredAt: state.occurredAt,
192
+ reason: state.reason,
193
+ pid,
194
+ };
195
+ };
196
+
197
+ const logcatTimestamp = await getLogcatTimestamp();
198
+ const sessionStartedAt = Date.now();
199
+ const logcatAbortController = new AbortController();
200
+ const logcatSignal = signal
201
+ ? AbortSignal.any([signal, logcatAbortController.signal])
202
+ : logcatAbortController.signal;
203
+ const logcatProcess = startLogcat(getLogcatArgs(appUid, logcatTimestamp), {
204
+ signal: logcatSignal,
205
+ });
206
+ // Aborting is nano-spawn's own cancellation path, so the resulting
207
+ // SubprocessError is settled the same way regardless of which of the
208
+ // merged stdout/stderr iterators observes it first. Without this, killing
209
+ // the underlying process directly can make both iterators reject at once,
210
+ // and nano-spawn's internal `Promise.race` only reports one of them,
211
+ // leaving the other an unhandled rejection.
212
+ logcatProcess.catch(() => undefined);
213
+ const crashReporter = createAndroidCrashReporter({
214
+ bundleId,
215
+ crashArtifactWriter,
216
+ getLogs: () => logBuffer.getLogs(),
217
+ getDropboxOutput,
218
+ getExitInfo,
219
+ minOccurredAt: sessionStartedAt,
220
+ });
221
+
222
+ const logTask = (async () => {
223
+ try {
224
+ for await (const rawLine of logcatProcess) {
225
+ const line = String(rawLine);
226
+
227
+ if (!disposed) {
228
+ logBuffer.push(line);
229
+ }
230
+
231
+ const observedPid = getObservedPid(line, bundleId);
232
+
233
+ if (observedPid !== undefined) {
234
+ if (state.status === 'running') {
235
+ setRunning(observedPid);
236
+ } else {
237
+ setExitedPid(observedPid);
238
+ }
239
+ }
240
+
241
+ if (state.status === 'running' && isCrashSignal(line, bundleId)) {
242
+ setExited('observed-exit', observedPid);
243
+ }
244
+ }
245
+ } catch (error) {
246
+ if (!disposed) {
247
+ androidAppSessionLogger.debug('Android logcat stream stopped', error);
248
+ }
249
+ }
250
+ })();
251
+
252
+ try {
253
+ await startApp();
254
+ } catch (error) {
255
+ disposed = true;
256
+ stopPolling = true;
257
+ emitter.clear();
258
+ logcatAbortController.abort();
259
+ await Promise.allSettled([logTask]);
260
+ throw error;
261
+ }
262
+
263
+ const pollTask = (async () => {
264
+ await sleep(0);
265
+
266
+ while (!stopPolling) {
267
+ if (isExitedState(state)) {
268
+ return;
269
+ }
270
+
271
+ try {
272
+ const pid = await getAppPid();
273
+
274
+ if (pid != null) {
275
+ setRunning(pid);
276
+ } else if (hasObservedProcess) {
277
+ setExited('process-gone');
278
+ return;
279
+ }
280
+ } catch (error) {
281
+ androidAppSessionLogger.debug('Android app session poll failed', error);
282
+ }
283
+
284
+ await waitForNextPoll();
285
+ }
286
+ })();
287
+
288
+ return {
289
+ dispose: async () => {
290
+ if (disposed) {
291
+ return;
292
+ }
293
+
294
+ disposed = true;
295
+ stopPolling = true;
296
+ state = { status: 'disposed', occurredAt: Date.now() };
297
+
298
+ if (exitNotification) {
299
+ clearTimeout(exitNotification);
300
+ exitNotification = null;
301
+ }
302
+
303
+ cancelPendingPollDelay();
304
+
305
+ emitter.clear();
306
+ logcatAbortController.abort();
307
+ await stopApp();
308
+ await Promise.allSettled([logTask, pollTask]);
309
+ },
310
+ getState: async () => state,
311
+ getLogs: () => logBuffer.getLogs(),
312
+ getCrashDetails: crashReporter.getCrashDetails,
313
+ addListener: emitter.addListener,
314
+ removeListener: emitter.removeListener,
315
+ };
316
+ };
package/src/avd-config.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { access, readFile } from 'node:fs/promises';
2
2
  import type { AndroidSystemImageArch } from './environment.js';
3
3
  import type { AndroidEmulator, AndroidEmulatorAVDConfig } from './config.js';
4
+ import { getEmulatorCpuCores } from './emulator-startup.js';
4
5
 
5
6
  export type AvdConfig = {
6
7
  imageSysdir1?: string;
@@ -8,6 +9,7 @@ export type AvdConfig = {
8
9
  hwDeviceName?: string;
9
10
  diskDataPartitionSize?: string;
10
11
  vmHeapSize?: string;
12
+ hwCpuNcore?: string;
11
13
  };
12
14
 
13
15
  export type AvdCompatibilityResult =
@@ -123,6 +125,9 @@ export const parseAvdConfig = (contents: string): AvdConfig => {
123
125
  case 'vm.heapSize':
124
126
  config.vmHeapSize = value;
125
127
  break;
128
+ case 'hw.cpu.ncore':
129
+ config.hwCpuNcore = value;
130
+ break;
126
131
  default:
127
132
  break;
128
133
  }
@@ -235,6 +240,18 @@ export const isAvdCompatible = ({
235
240
  };
236
241
  }
237
242
 
243
+ if (
244
+ normalizeConfigValue(avdConfig.hwCpuNcore ?? '') !==
245
+ normalizeConfigValue(String(getEmulatorCpuCores()))
246
+ ) {
247
+ return {
248
+ compatible: false,
249
+ reason: `CPU core count mismatch: expected ${getEmulatorCpuCores()}, got ${
250
+ avdConfig.hwCpuNcore ?? 'missing'
251
+ }.`,
252
+ };
253
+ }
254
+
238
255
  return { compatible: true };
239
256
  };
240
257