@react-native-harness/platform-android 1.3.0 → 1.4.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.
Files changed (37) hide show
  1. package/dist/__tests__/crash-diagnostics.test.d.ts +2 -0
  2. package/dist/__tests__/crash-diagnostics.test.d.ts.map +1 -0
  3. package/dist/__tests__/crash-diagnostics.test.js +119 -0
  4. package/dist/__tests__/crash-reporter.test.d.ts +2 -0
  5. package/dist/__tests__/crash-reporter.test.d.ts.map +1 -0
  6. package/dist/__tests__/crash-reporter.test.js +150 -0
  7. package/dist/__tests__/instance.test.js +133 -24
  8. package/dist/adb.d.ts +4 -0
  9. package/dist/adb.d.ts.map +1 -1
  10. package/dist/adb.js +42 -2
  11. package/dist/app-session.d.ts +17 -0
  12. package/dist/app-session.d.ts.map +1 -0
  13. package/dist/app-session.js +209 -0
  14. package/dist/crash-diagnostics.d.ts +40 -0
  15. package/dist/crash-diagnostics.d.ts.map +1 -0
  16. package/dist/crash-diagnostics.js +256 -0
  17. package/dist/crash-reporter.d.ts +12 -0
  18. package/dist/crash-reporter.d.ts.map +1 -0
  19. package/dist/crash-reporter.js +150 -0
  20. package/dist/instance.d.ts +1 -1
  21. package/dist/instance.d.ts.map +1 -1
  22. package/dist/instance.js +32 -59
  23. package/dist/runner.d.ts.map +1 -1
  24. package/dist/runner.js +1 -1
  25. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  26. package/package.json +4 -4
  27. package/src/__tests__/crash-diagnostics.test.ts +144 -0
  28. package/src/__tests__/crash-reporter.test.ts +167 -0
  29. package/src/__tests__/instance.test.ts +174 -31
  30. package/src/adb.ts +61 -2
  31. package/src/app-session.ts +300 -0
  32. package/src/crash-diagnostics.ts +471 -0
  33. package/src/crash-reporter.ts +263 -0
  34. package/src/instance.ts +56 -91
  35. package/src/runner.ts +7 -3
  36. package/test/setup.ts +1 -0
  37. package/src/app-monitor.ts +0 -577
package/src/adb.ts CHANGED
@@ -699,6 +699,13 @@ export const isAppRunning = async (
699
699
  adbId: string,
700
700
  bundleId: string,
701
701
  ): Promise<boolean> => {
702
+ return (await getAppPid(adbId, bundleId)) != null;
703
+ };
704
+
705
+ export const getAppPid = async (
706
+ adbId: string,
707
+ bundleId: string,
708
+ ): Promise<number | null> => {
702
709
  try {
703
710
  const { stdout } = await spawn(getAdbBinaryPath(), [
704
711
  '-s',
@@ -707,10 +714,15 @@ export const isAppRunning = async (
707
714
  'pidof',
708
715
  bundleId,
709
716
  ]);
710
- return stdout.trim() !== '';
717
+ const pid = stdout
718
+ .trim()
719
+ .split(/\s+/)
720
+ .find((value) => value !== '');
721
+
722
+ return pid ? Number(pid) : null;
711
723
  } catch (error) {
712
724
  if (error instanceof SubprocessError && error.exitCode === 1) {
713
- return false;
725
+ return null;
714
726
  }
715
727
 
716
728
  throw error;
@@ -779,6 +791,53 @@ export const startLogcat = (
779
791
  stderr: 'pipe',
780
792
  });
781
793
 
794
+ export const DROPBOX_CRASH_TAGS = [
795
+ 'data_app_crash',
796
+ 'data_app_native_crash',
797
+ ] as const;
798
+
799
+ export const getDropboxPrint = async (
800
+ adbId: string,
801
+ tags: readonly string[] = DROPBOX_CRASH_TAGS,
802
+ ): Promise<string> => {
803
+ // Android treats multiple args after --print as one search string, so each
804
+ // tag must be queried separately and merged on the host.
805
+ const outputs = await Promise.all(
806
+ tags.map(async (tag) => {
807
+ const { stdout } = await spawn(getAdbBinaryPath(), [
808
+ '-s',
809
+ adbId,
810
+ 'shell',
811
+ 'dumpsys',
812
+ 'dropbox',
813
+ '--print',
814
+ tag,
815
+ ]);
816
+
817
+ return stdout;
818
+ }),
819
+ );
820
+
821
+ return outputs.join('\n');
822
+ };
823
+
824
+ export const getActivityExitInfo = async (
825
+ adbId: string,
826
+ bundleId: string,
827
+ ): Promise<string> => {
828
+ const { stdout } = await spawn(getAdbBinaryPath(), [
829
+ '-s',
830
+ adbId,
831
+ 'shell',
832
+ 'dumpsys',
833
+ 'activity',
834
+ 'exit-info',
835
+ bundleId,
836
+ ]);
837
+
838
+ return stdout;
839
+ };
840
+
782
841
  export const getAvds = async (): Promise<string[]> => {
783
842
  try {
784
843
  const emulatorBinaryPath = await ensureEmulatorInstalled();
@@ -0,0 +1,300 @@
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 stopSubprocess = async (child: Subprocess) => {
66
+ try {
67
+ (await child.nodeChildProcess).kill();
68
+ } catch {
69
+ // Ignore termination failures for already-ended background processes.
70
+ }
71
+ };
72
+
73
+ const isExitedState = (
74
+ state: AppSessionState
75
+ ): state is Extract<AppSessionState, { status: 'exited' }> =>
76
+ state.status === 'exited';
77
+
78
+ type CreateAndroidAppSessionOptions = {
79
+ appUid: number;
80
+ bundleId: string;
81
+ startApp: () => Promise<void>;
82
+ stopApp: () => Promise<void>;
83
+ getAppPid: () => Promise<number | null>;
84
+ getLogcatTimestamp: () => Promise<string>;
85
+ startLogcat: (args: readonly string[]) => Subprocess;
86
+ getDropboxOutput?: () => Promise<string>;
87
+ getExitInfo?: () => Promise<string>;
88
+ crashArtifactWriter?: CrashArtifactWriter;
89
+ };
90
+
91
+ export const createAndroidAppSession = async ({
92
+ appUid,
93
+ bundleId,
94
+ startApp,
95
+ stopApp,
96
+ getAppPid,
97
+ getLogcatTimestamp,
98
+ startLogcat,
99
+ getDropboxOutput,
100
+ getExitInfo,
101
+ crashArtifactWriter,
102
+ }: CreateAndroidAppSessionOptions): Promise<AppSession> => {
103
+ const emitter = createAppSessionEmitter();
104
+ const logBuffer = createBoundedLogBuffer();
105
+ let state: AppSessionState = { status: 'running' };
106
+ let disposed = false;
107
+ let stopPolling = false;
108
+ let hasObservedProcess = false;
109
+ let exitNotification: ReturnType<typeof setTimeout> | null = null;
110
+ let pollDelayTimeout: ReturnType<typeof setTimeout> | null = null;
111
+ let resolvePollDelay: (() => void) | null = null;
112
+
113
+ const getCurrentPid = () => ('pid' in state ? state.pid : undefined);
114
+
115
+ const setRunning = (pid?: number) => {
116
+ if (disposed || state.status === 'disposed' || state.status === 'exited') {
117
+ return;
118
+ }
119
+
120
+ if (pid !== undefined) {
121
+ hasObservedProcess = true;
122
+ }
123
+
124
+ state =
125
+ pid === undefined ? { status: 'running' } : { status: 'running', pid };
126
+ };
127
+
128
+ const scheduleExitNotification = () => {
129
+ if (exitNotification) {
130
+ return;
131
+ }
132
+
133
+ exitNotification = setTimeout(() => {
134
+ exitNotification = null;
135
+
136
+ if (!disposed && state.status === 'exited') {
137
+ emitter.emit({ type: 'app_exited' });
138
+ }
139
+ }, 0);
140
+ };
141
+
142
+ const waitForNextPoll = () =>
143
+ new Promise<void>((resolve) => {
144
+ resolvePollDelay = () => {
145
+ resolvePollDelay = null;
146
+ pollDelayTimeout = null;
147
+ resolve();
148
+ };
149
+
150
+ pollDelayTimeout = setTimeout(() => {
151
+ resolvePollDelay?.();
152
+ }, APP_EXIT_POLL_INTERVAL_MS);
153
+ });
154
+
155
+ const cancelPendingPollDelay = () => {
156
+ if (pollDelayTimeout) {
157
+ clearTimeout(pollDelayTimeout);
158
+ pollDelayTimeout = null;
159
+ }
160
+
161
+ resolvePollDelay?.();
162
+ };
163
+
164
+ const setExited = (
165
+ reason: 'observed-exit' | 'process-gone',
166
+ pid?: number
167
+ ) => {
168
+ if (disposed || state.status === 'disposed' || state.status === 'exited') {
169
+ return;
170
+ }
171
+
172
+ state = {
173
+ status: 'exited',
174
+ occurredAt: Date.now(),
175
+ pid: pid ?? getCurrentPid(),
176
+ reason,
177
+ };
178
+ scheduleExitNotification();
179
+ };
180
+
181
+ const setExitedPid = (pid: number) => {
182
+ if (state.status !== 'exited' || state.pid !== undefined) {
183
+ return;
184
+ }
185
+
186
+ state = {
187
+ status: 'exited',
188
+ occurredAt: state.occurredAt,
189
+ reason: state.reason,
190
+ pid,
191
+ };
192
+ };
193
+
194
+ const logcatTimestamp = await getLogcatTimestamp();
195
+ const sessionStartedAt = Date.now();
196
+ const logcatProcess = startLogcat(getLogcatArgs(appUid, logcatTimestamp));
197
+ const crashReporter = createAndroidCrashReporter({
198
+ bundleId,
199
+ crashArtifactWriter,
200
+ getLogs: () => logBuffer.getLogs(),
201
+ getDropboxOutput,
202
+ getExitInfo,
203
+ minOccurredAt: sessionStartedAt,
204
+ });
205
+
206
+ const logTask = (async () => {
207
+ try {
208
+ for await (const rawLine of logcatProcess) {
209
+ const line = String(rawLine);
210
+
211
+ if (!disposed) {
212
+ logBuffer.push(line);
213
+ }
214
+
215
+ const observedPid = getObservedPid(line, bundleId);
216
+
217
+ if (observedPid !== undefined) {
218
+ if (state.status === 'running') {
219
+ setRunning(observedPid);
220
+ } else {
221
+ setExitedPid(observedPid);
222
+ }
223
+ }
224
+
225
+ if (state.status === 'running' && isCrashSignal(line, bundleId)) {
226
+ setExited('observed-exit', observedPid);
227
+ }
228
+ }
229
+ } catch (error) {
230
+ if (!disposed) {
231
+ androidAppSessionLogger.debug('Android logcat stream stopped', error);
232
+ }
233
+ }
234
+ })();
235
+
236
+ try {
237
+ await startApp();
238
+ } catch (error) {
239
+ disposed = true;
240
+ stopPolling = true;
241
+ emitter.clear();
242
+ await stopSubprocess(logcatProcess);
243
+ await Promise.allSettled([logTask]);
244
+ throw error;
245
+ }
246
+
247
+ const pollTask = (async () => {
248
+ await sleep(0);
249
+
250
+ while (!stopPolling) {
251
+ if (isExitedState(state)) {
252
+ return;
253
+ }
254
+
255
+ try {
256
+ const pid = await getAppPid();
257
+
258
+ if (pid != null) {
259
+ setRunning(pid);
260
+ } else if (hasObservedProcess) {
261
+ setExited('process-gone');
262
+ return;
263
+ }
264
+ } catch (error) {
265
+ androidAppSessionLogger.debug('Android app session poll failed', error);
266
+ }
267
+
268
+ await waitForNextPoll();
269
+ }
270
+ })();
271
+
272
+ return {
273
+ dispose: async () => {
274
+ if (disposed) {
275
+ return;
276
+ }
277
+
278
+ disposed = true;
279
+ stopPolling = true;
280
+ state = { status: 'disposed', occurredAt: Date.now() };
281
+
282
+ if (exitNotification) {
283
+ clearTimeout(exitNotification);
284
+ exitNotification = null;
285
+ }
286
+
287
+ cancelPendingPollDelay();
288
+
289
+ emitter.clear();
290
+ await stopSubprocess(logcatProcess);
291
+ await stopApp();
292
+ await Promise.allSettled([logTask, pollTask]);
293
+ },
294
+ getState: async () => state,
295
+ getLogs: () => logBuffer.getLogs(),
296
+ getCrashDetails: crashReporter.getCrashDetails,
297
+ addListener: emitter.addListener,
298
+ removeListener: emitter.removeListener,
299
+ };
300
+ };