@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.
- package/dist/__tests__/adb-wait.test.d.ts +2 -0
- package/dist/__tests__/adb-wait.test.d.ts.map +1 -0
- package/dist/__tests__/adb-wait.test.js +69 -0
- package/dist/__tests__/app-session.test.d.ts +2 -0
- package/dist/__tests__/app-session.test.d.ts.map +1 -0
- package/dist/__tests__/app-session.test.js +57 -0
- package/dist/__tests__/avd-config.test.js +58 -0
- package/dist/__tests__/crash-diagnostics.test.d.ts +2 -0
- package/dist/__tests__/crash-diagnostics.test.d.ts.map +1 -0
- package/dist/__tests__/crash-diagnostics.test.js +119 -0
- package/dist/__tests__/crash-reporter.test.d.ts +2 -0
- package/dist/__tests__/crash-reporter.test.d.ts.map +1 -0
- package/dist/__tests__/crash-reporter.test.js +150 -0
- package/dist/__tests__/emulator-startup.test.js +30 -3
- package/dist/__tests__/instance.test.js +139 -24
- package/dist/adb.d.ts +7 -1
- package/dist/adb.d.ts.map +1 -1
- package/dist/adb.js +72 -23
- package/dist/app-session.d.ts +26 -0
- package/dist/app-session.d.ts.map +1 -0
- package/dist/app-session.js +214 -0
- package/dist/avd-config.d.ts +1 -0
- package/dist/avd-config.d.ts.map +1 -1
- package/dist/avd-config.js +11 -0
- package/dist/crash-diagnostics.d.ts +40 -0
- package/dist/crash-diagnostics.d.ts.map +1 -0
- package/dist/crash-diagnostics.js +256 -0
- package/dist/crash-reporter.d.ts +12 -0
- package/dist/crash-reporter.d.ts.map +1 -0
- package/dist/crash-reporter.js +150 -0
- package/dist/emulator-startup.d.ts +23 -0
- package/dist/emulator-startup.d.ts.map +1 -1
- package/dist/emulator-startup.js +58 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/instance.d.ts +1 -1
- package/dist/instance.d.ts.map +1 -1
- package/dist/instance.js +51 -72
- package/dist/runner.d.ts +2 -2
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +1 -1
- package/dist/tsconfig.lib.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/__tests__/adb-wait.test.ts +95 -0
- package/src/__tests__/app-session.test.ts +76 -0
- package/src/__tests__/avd-config.test.ts +70 -0
- package/src/__tests__/crash-diagnostics.test.ts +144 -0
- package/src/__tests__/crash-reporter.test.ts +167 -0
- package/src/__tests__/emulator-startup.test.ts +53 -6
- package/src/__tests__/instance.test.ts +226 -84
- package/src/adb.ts +129 -63
- package/src/app-session.ts +316 -0
- package/src/avd-config.ts +17 -0
- package/src/crash-diagnostics.ts +471 -0
- package/src/crash-reporter.ts +263 -0
- package/src/emulator-startup.ts +74 -0
- package/src/index.ts +1 -0
- package/src/instance.ts +84 -105
- package/src/runner.ts +11 -11
- package/test/setup.ts +1 -0
- 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
|
|