@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,214 @@
|
|
|
1
|
+
import { createAppSessionEmitter, createBoundedLogBuffer, } from '@react-native-harness/platforms';
|
|
2
|
+
import { escapeRegExp, logger, } from '@react-native-harness/tools';
|
|
3
|
+
import { createAndroidCrashReporter } from './crash-reporter.js';
|
|
4
|
+
const androidAppSessionLogger = logger.child('android-app-session');
|
|
5
|
+
const APP_EXIT_POLL_INTERVAL_MS = 1000;
|
|
6
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
|
+
const getLogcatArgs = (appUid, fromTime) => [
|
|
8
|
+
'logcat',
|
|
9
|
+
'-v',
|
|
10
|
+
'threadtime',
|
|
11
|
+
'-b',
|
|
12
|
+
'crash',
|
|
13
|
+
`--uid=${appUid}`,
|
|
14
|
+
'-T',
|
|
15
|
+
fromTime,
|
|
16
|
+
];
|
|
17
|
+
const getProcessPattern = (bundleId) => new RegExp(`Process:\\s*${escapeRegExp(bundleId)},\\s*PID:\\s*(\\d+)`);
|
|
18
|
+
const getStartProcPattern = (bundleId) => new RegExp(`Start proc (\\d+):${escapeRegExp(bundleId)}(?:/|\\s)`);
|
|
19
|
+
const getProcessDiedPattern = (bundleId) => new RegExp(`Process\\s+${escapeRegExp(bundleId)}\\s+\\(pid\\s+(\\d+)\\)\\s+has\\s+died`, 'i');
|
|
20
|
+
const getObservedPid = (line, bundleId) => {
|
|
21
|
+
const match = line.match(getProcessPattern(bundleId)) ??
|
|
22
|
+
line.match(getStartProcPattern(bundleId)) ??
|
|
23
|
+
line.match(getProcessDiedPattern(bundleId));
|
|
24
|
+
return match?.[1] ? Number(match[1]) : undefined;
|
|
25
|
+
};
|
|
26
|
+
const isCrashSignal = (line, bundleId) => {
|
|
27
|
+
return (getProcessPattern(bundleId).test(line) ||
|
|
28
|
+
new RegExp(`>>>\\s*${escapeRegExp(bundleId)}\\s*<<<`).test(line) ||
|
|
29
|
+
getProcessDiedPattern(bundleId).test(line) ||
|
|
30
|
+
(line.includes(bundleId) &&
|
|
31
|
+
/fatal|crash|signal 11|signal 6|backtrace/i.test(line)));
|
|
32
|
+
};
|
|
33
|
+
const isExitedState = (state) => state.status === 'exited';
|
|
34
|
+
export const createAndroidAppSession = async ({ appUid, bundleId, startApp, stopApp, getAppPid, getLogcatTimestamp, startLogcat, getDropboxOutput, getExitInfo, crashArtifactWriter, signal, }) => {
|
|
35
|
+
const emitter = createAppSessionEmitter();
|
|
36
|
+
const logBuffer = createBoundedLogBuffer();
|
|
37
|
+
let state = { status: 'running' };
|
|
38
|
+
let disposed = false;
|
|
39
|
+
let stopPolling = false;
|
|
40
|
+
let hasObservedProcess = false;
|
|
41
|
+
let exitNotification = null;
|
|
42
|
+
let pollDelayTimeout = null;
|
|
43
|
+
let resolvePollDelay = null;
|
|
44
|
+
const getCurrentPid = () => ('pid' in state ? state.pid : undefined);
|
|
45
|
+
const setRunning = (pid) => {
|
|
46
|
+
if (disposed || state.status === 'disposed' || state.status === 'exited') {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (pid !== undefined) {
|
|
50
|
+
hasObservedProcess = true;
|
|
51
|
+
}
|
|
52
|
+
state =
|
|
53
|
+
pid === undefined ? { status: 'running' } : { status: 'running', pid };
|
|
54
|
+
};
|
|
55
|
+
const scheduleExitNotification = () => {
|
|
56
|
+
if (exitNotification) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
exitNotification = setTimeout(() => {
|
|
60
|
+
exitNotification = null;
|
|
61
|
+
if (!disposed && state.status === 'exited') {
|
|
62
|
+
emitter.emit({ type: 'app_exited' });
|
|
63
|
+
}
|
|
64
|
+
}, 0);
|
|
65
|
+
};
|
|
66
|
+
const waitForNextPoll = () => new Promise((resolve) => {
|
|
67
|
+
resolvePollDelay = () => {
|
|
68
|
+
resolvePollDelay = null;
|
|
69
|
+
pollDelayTimeout = null;
|
|
70
|
+
resolve();
|
|
71
|
+
};
|
|
72
|
+
pollDelayTimeout = setTimeout(() => {
|
|
73
|
+
resolvePollDelay?.();
|
|
74
|
+
}, APP_EXIT_POLL_INTERVAL_MS);
|
|
75
|
+
});
|
|
76
|
+
const cancelPendingPollDelay = () => {
|
|
77
|
+
if (pollDelayTimeout) {
|
|
78
|
+
clearTimeout(pollDelayTimeout);
|
|
79
|
+
pollDelayTimeout = null;
|
|
80
|
+
}
|
|
81
|
+
resolvePollDelay?.();
|
|
82
|
+
};
|
|
83
|
+
const setExited = (reason, pid) => {
|
|
84
|
+
if (disposed || state.status === 'disposed' || state.status === 'exited') {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
state = {
|
|
88
|
+
status: 'exited',
|
|
89
|
+
occurredAt: Date.now(),
|
|
90
|
+
pid: pid ?? getCurrentPid(),
|
|
91
|
+
reason,
|
|
92
|
+
};
|
|
93
|
+
scheduleExitNotification();
|
|
94
|
+
};
|
|
95
|
+
const setExitedPid = (pid) => {
|
|
96
|
+
if (state.status !== 'exited' || state.pid !== undefined) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
state = {
|
|
100
|
+
status: 'exited',
|
|
101
|
+
occurredAt: state.occurredAt,
|
|
102
|
+
reason: state.reason,
|
|
103
|
+
pid,
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
const logcatTimestamp = await getLogcatTimestamp();
|
|
107
|
+
const sessionStartedAt = Date.now();
|
|
108
|
+
const logcatAbortController = new AbortController();
|
|
109
|
+
const logcatSignal = signal
|
|
110
|
+
? AbortSignal.any([signal, logcatAbortController.signal])
|
|
111
|
+
: logcatAbortController.signal;
|
|
112
|
+
const logcatProcess = startLogcat(getLogcatArgs(appUid, logcatTimestamp), {
|
|
113
|
+
signal: logcatSignal,
|
|
114
|
+
});
|
|
115
|
+
// Aborting is nano-spawn's own cancellation path, so the resulting
|
|
116
|
+
// SubprocessError is settled the same way regardless of which of the
|
|
117
|
+
// merged stdout/stderr iterators observes it first. Without this, killing
|
|
118
|
+
// the underlying process directly can make both iterators reject at once,
|
|
119
|
+
// and nano-spawn's internal `Promise.race` only reports one of them,
|
|
120
|
+
// leaving the other an unhandled rejection.
|
|
121
|
+
logcatProcess.catch(() => undefined);
|
|
122
|
+
const crashReporter = createAndroidCrashReporter({
|
|
123
|
+
bundleId,
|
|
124
|
+
crashArtifactWriter,
|
|
125
|
+
getLogs: () => logBuffer.getLogs(),
|
|
126
|
+
getDropboxOutput,
|
|
127
|
+
getExitInfo,
|
|
128
|
+
minOccurredAt: sessionStartedAt,
|
|
129
|
+
});
|
|
130
|
+
const logTask = (async () => {
|
|
131
|
+
try {
|
|
132
|
+
for await (const rawLine of logcatProcess) {
|
|
133
|
+
const line = String(rawLine);
|
|
134
|
+
if (!disposed) {
|
|
135
|
+
logBuffer.push(line);
|
|
136
|
+
}
|
|
137
|
+
const observedPid = getObservedPid(line, bundleId);
|
|
138
|
+
if (observedPid !== undefined) {
|
|
139
|
+
if (state.status === 'running') {
|
|
140
|
+
setRunning(observedPid);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
setExitedPid(observedPid);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (state.status === 'running' && isCrashSignal(line, bundleId)) {
|
|
147
|
+
setExited('observed-exit', observedPid);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (!disposed) {
|
|
153
|
+
androidAppSessionLogger.debug('Android logcat stream stopped', error);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
})();
|
|
157
|
+
try {
|
|
158
|
+
await startApp();
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
disposed = true;
|
|
162
|
+
stopPolling = true;
|
|
163
|
+
emitter.clear();
|
|
164
|
+
logcatAbortController.abort();
|
|
165
|
+
await Promise.allSettled([logTask]);
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
const pollTask = (async () => {
|
|
169
|
+
await sleep(0);
|
|
170
|
+
while (!stopPolling) {
|
|
171
|
+
if (isExitedState(state)) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
const pid = await getAppPid();
|
|
176
|
+
if (pid != null) {
|
|
177
|
+
setRunning(pid);
|
|
178
|
+
}
|
|
179
|
+
else if (hasObservedProcess) {
|
|
180
|
+
setExited('process-gone');
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
androidAppSessionLogger.debug('Android app session poll failed', error);
|
|
186
|
+
}
|
|
187
|
+
await waitForNextPoll();
|
|
188
|
+
}
|
|
189
|
+
})();
|
|
190
|
+
return {
|
|
191
|
+
dispose: async () => {
|
|
192
|
+
if (disposed) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
disposed = true;
|
|
196
|
+
stopPolling = true;
|
|
197
|
+
state = { status: 'disposed', occurredAt: Date.now() };
|
|
198
|
+
if (exitNotification) {
|
|
199
|
+
clearTimeout(exitNotification);
|
|
200
|
+
exitNotification = null;
|
|
201
|
+
}
|
|
202
|
+
cancelPendingPollDelay();
|
|
203
|
+
emitter.clear();
|
|
204
|
+
logcatAbortController.abort();
|
|
205
|
+
await stopApp();
|
|
206
|
+
await Promise.allSettled([logTask, pollTask]);
|
|
207
|
+
},
|
|
208
|
+
getState: async () => state,
|
|
209
|
+
getLogs: () => logBuffer.getLogs(),
|
|
210
|
+
getCrashDetails: crashReporter.getCrashDetails,
|
|
211
|
+
addListener: emitter.addListener,
|
|
212
|
+
removeListener: emitter.removeListener,
|
|
213
|
+
};
|
|
214
|
+
};
|
package/dist/avd-config.d.ts
CHANGED
package/dist/avd-config.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"avd-config.d.ts","sourceRoot":"","sources":["../src/avd-config.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AAC/D,OAAO,KAAK,EAAE,eAAe,EAAE,wBAAwB,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"avd-config.d.ts","sourceRoot":"","sources":["../src/avd-config.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AAC/D,OAAO,KAAK,EAAE,eAAe,EAAE,wBAAwB,EAAE,MAAM,aAAa,CAAC;AAG7E,MAAM,MAAM,SAAS,GAAG;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAC9B;IAAE,UAAU,EAAE,IAAI,CAAA;CAAE,GACpB;IAAE,UAAU,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE1C,eAAO,MAAM,eAAe,GAAI,MAAM,MAAM,KAAG,MAI9C,CAAC;AAEF,eAAO,MAAM,gBAAgB,GAAI,MAAM,MAAM,KAAG,MAE/C,CAAC;AAkEF,eAAO,MAAM,cAAc,GAAI,UAAU,MAAM,KAAG,SA4CjD,CAAC;AAEF,eAAO,MAAM,aAAa,GACxB,MAAM,MAAM,KACX,OAAO,CAAC,SAAS,GAAG,IAAI,CAU1B,CAAC;AAEF,eAAO,MAAM,eAAe,GAAI,oCAI7B;IACD,QAAQ,EAAE,eAAe,CAAC;IAC1B,SAAS,EAAE,SAAS,CAAC;IACrB,QAAQ,EAAE,sBAAsB,CAAC;CAClC,KAAG,sBA+FH,CAAC;AAEF,eAAO,MAAM,2BAA2B,GAAI,yBAGzC;IACD,QAAQ,EAAE,eAAe,CAAC;IAC1B,QAAQ,EAAE,sBAAsB,CAAC;CAClC,KAAG;IACF,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,sBAAsB,CAAC;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB,GAAG,IAeH,CAAC;AAEF,eAAO,MAAM,wBAAwB,GAAI,8BAItC;IACD,GAAG,CAAC,EAAE,wBAAwB,CAAC;IAC/B,aAAa,EAAE,OAAO,CAAC;IACvB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CACzB,KAAG,OAWH,CAAC"}
|
package/dist/avd-config.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { access, readFile } from 'node:fs/promises';
|
|
2
|
+
import { getEmulatorCpuCores } from './emulator-startup.js';
|
|
2
3
|
export const getAvdDirectory = (name) => {
|
|
3
4
|
return `${process.env.ANDROID_AVD_HOME ?? `${process.env.HOME}/.android/avd`}/${name}.avd`;
|
|
4
5
|
};
|
|
@@ -81,6 +82,9 @@ export const parseAvdConfig = (contents) => {
|
|
|
81
82
|
case 'vm.heapSize':
|
|
82
83
|
config.vmHeapSize = value;
|
|
83
84
|
break;
|
|
85
|
+
case 'hw.cpu.ncore':
|
|
86
|
+
config.hwCpuNcore = value;
|
|
87
|
+
break;
|
|
84
88
|
default:
|
|
85
89
|
break;
|
|
86
90
|
}
|
|
@@ -146,6 +150,13 @@ export const isAvdCompatible = ({ emulator, avdConfig, hostArch, }) => {
|
|
|
146
150
|
reason: `Heap size mismatch: expected ${requestedAvdConfig.heapSize}, got ${avdConfig.vmHeapSize ?? 'missing'}.`,
|
|
147
151
|
};
|
|
148
152
|
}
|
|
153
|
+
if (normalizeConfigValue(avdConfig.hwCpuNcore ?? '') !==
|
|
154
|
+
normalizeConfigValue(String(getEmulatorCpuCores()))) {
|
|
155
|
+
return {
|
|
156
|
+
compatible: false,
|
|
157
|
+
reason: `CPU core count mismatch: expected ${getEmulatorCpuCores()}, got ${avdConfig.hwCpuNcore ?? 'missing'}.`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
149
160
|
return { compatible: true };
|
|
150
161
|
};
|
|
151
162
|
export const getNormalizedAvdCacheConfig = ({ emulator, hostArch, }) => {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AppCrashDetails, CrashArtifactWriter, CrashDetailsLookupOptions, CrashEnrichmentArtifact } from '@react-native-harness/platforms';
|
|
2
|
+
export declare const DROPBOX_CRASH_TAGS: readonly ["data_app_crash", "data_app_native_crash"];
|
|
3
|
+
export type DropboxEntry = {
|
|
4
|
+
tag: string;
|
|
5
|
+
timestamp: string;
|
|
6
|
+
content: string;
|
|
7
|
+
pid?: number;
|
|
8
|
+
processName?: string;
|
|
9
|
+
};
|
|
10
|
+
export type DropboxCrashArtifact = AppCrashDetails & {
|
|
11
|
+
artifactType: 'dropbox-crash' | 'dropbox-native-crash';
|
|
12
|
+
dropboxTag: string;
|
|
13
|
+
occurredAt: number;
|
|
14
|
+
score?: number;
|
|
15
|
+
};
|
|
16
|
+
type CollectDropboxArtifactsOptions = {
|
|
17
|
+
bundleId: string;
|
|
18
|
+
crashArtifactWriter?: CrashArtifactWriter;
|
|
19
|
+
getDropboxOutput: () => Promise<string>;
|
|
20
|
+
minOccurredAt?: number;
|
|
21
|
+
};
|
|
22
|
+
type WaitForDropboxArtifactsOptions = CollectDropboxArtifactsOptions & CrashDetailsLookupOptions;
|
|
23
|
+
export declare const parseDropboxOutput: (output: string) => DropboxEntry[];
|
|
24
|
+
export declare const collectDropboxArtifacts: ({ bundleId, crashArtifactWriter, getDropboxOutput, minOccurredAt, ...lookup }: WaitForDropboxArtifactsOptions) => Promise<DropboxCrashArtifact[]>;
|
|
25
|
+
export declare const waitForDropboxArtifacts: (options: WaitForDropboxArtifactsOptions) => Promise<DropboxCrashArtifact[]>;
|
|
26
|
+
export declare const filterExitInfo: ({ output, bundleId, pid, }: {
|
|
27
|
+
output: string;
|
|
28
|
+
bundleId: string;
|
|
29
|
+
pid?: number;
|
|
30
|
+
}) => string | null;
|
|
31
|
+
export declare const collectExitInfoArtifact: ({ bundleId, crashArtifactWriter, getExitInfo, pid, testFilePath, }: {
|
|
32
|
+
bundleId: string;
|
|
33
|
+
crashArtifactWriter?: CrashArtifactWriter;
|
|
34
|
+
getExitInfo: () => Promise<string>;
|
|
35
|
+
pid?: number;
|
|
36
|
+
testFilePath?: string;
|
|
37
|
+
}) => Promise<CrashEnrichmentArtifact | null>;
|
|
38
|
+
export declare const getBestDropboxArtifact: (artifacts: DropboxCrashArtifact[]) => DropboxCrashArtifact | null;
|
|
39
|
+
export {};
|
|
40
|
+
//# sourceMappingURL=crash-diagnostics.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crash-diagnostics.d.ts","sourceRoot":"","sources":["../src/crash-diagnostics.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,eAAe,EACf,mBAAmB,EACnB,yBAAyB,EACzB,uBAAuB,EACxB,MAAM,iCAAiC,CAAC;AAWzC,eAAO,MAAM,kBAAkB,sDAGrB,CAAC;AAEX,MAAM,MAAM,YAAY,GAAG;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG,eAAe,GAAG;IACnD,YAAY,EAAE,eAAe,GAAG,sBAAsB,CAAC;IACvD,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,KAAK,8BAA8B,GAAG;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,mBAAmB,CAAC,EAAE,mBAAmB,CAAC;IAC1C,gBAAgB,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IACxC,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF,KAAK,8BAA8B,GAAG,8BAA8B,GAClE,yBAAyB,CAAC;AA2D5B,eAAO,MAAM,kBAAkB,GAAI,QAAQ,MAAM,KAAG,YAAY,EAgD/D,CAAC;AAkKF,eAAO,MAAM,uBAAuB,GAAU,+EAM3C,8BAA8B,KAAG,OAAO,CAAC,oBAAoB,EAAE,CA6BjE,CAAC;AAEF,eAAO,MAAM,uBAAuB,GAClC,SAAS,8BAA8B,KACtC,OAAO,CAAC,oBAAoB,EAAE,CAqBhC,CAAC;AAEF,eAAO,MAAM,cAAc,GAAI,4BAI5B;IACD,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,KAAG,MAAM,GAAG,IAgCZ,CAAC;AAEF,eAAO,MAAM,uBAAuB,GAAU,oEAM3C;IACD,QAAQ,EAAE,MAAM,CAAC;IACjB,mBAAmB,CAAC,EAAE,mBAAmB,CAAC;IAC1C,WAAW,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IACnC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,KAAG,OAAO,CAAC,uBAAuB,GAAG,IAAI,CAmCzC,CAAC;AAEF,eAAO,MAAM,sBAAsB,GACjC,WAAW,oBAAoB,EAAE,KAChC,oBAAoB,GAAG,IAEpB,CAAC"}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { escapeRegExp, logger } from '@react-native-harness/tools';
|
|
2
|
+
import { androidCrashParser } from './crash-parser.js';
|
|
3
|
+
const crashDiagnosticsLogger = logger.child('android-crash-diagnostics');
|
|
4
|
+
const DROPBOX_ENTRY_HEADER = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d{3})?) (\S+) \(text, \d+ bytes\)/;
|
|
5
|
+
const DROPBOX_POLL_INTERVAL_MS = 1500;
|
|
6
|
+
const DROPBOX_WAIT_TIMEOUT_MS = 10000;
|
|
7
|
+
export const DROPBOX_CRASH_TAGS = [
|
|
8
|
+
'data_app_crash',
|
|
9
|
+
'data_app_native_crash',
|
|
10
|
+
];
|
|
11
|
+
const getDropboxArtifactType = (tag) => tag === 'data_app_native_crash' ? 'dropbox-native-crash' : 'dropbox-crash';
|
|
12
|
+
const getDropboxFileName = (tag) => tag === 'data_app_native_crash'
|
|
13
|
+
? 'dropbox-native-crash.txt'
|
|
14
|
+
: 'dropbox-crash.txt';
|
|
15
|
+
const parseDropboxTimestamp = (timestamp) => {
|
|
16
|
+
const match = timestamp.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})(?:\.(\d{3}))?$/);
|
|
17
|
+
if (!match) {
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
const [, year, month, day, hour, minute, second, millis = '0'] = match;
|
|
21
|
+
return Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second), Number(millis));
|
|
22
|
+
};
|
|
23
|
+
const extractDropboxPid = (content) => {
|
|
24
|
+
const match = content.match(/^PID:\s*(\d+)/m) ?? content.match(/\bpid:\s*(\d+)/im);
|
|
25
|
+
return match ? Number(match[1]) : undefined;
|
|
26
|
+
};
|
|
27
|
+
const extractDropboxProcessName = (content, bundleId) => {
|
|
28
|
+
const processMatch = content.match(/^Process:\s*(\S+)/m);
|
|
29
|
+
const packageMatch = content.match(/^Package:\s*(\S+)/m);
|
|
30
|
+
if (processMatch?.[1] === bundleId) {
|
|
31
|
+
return processMatch[1];
|
|
32
|
+
}
|
|
33
|
+
if (packageMatch?.[1]?.startsWith(bundleId)) {
|
|
34
|
+
return bundleId;
|
|
35
|
+
}
|
|
36
|
+
return content.includes(bundleId) ? bundleId : undefined;
|
|
37
|
+
};
|
|
38
|
+
export const parseDropboxOutput = (output) => {
|
|
39
|
+
if (output.trim() === '') {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
const sections = output.split(/^={10,}\s*$/m);
|
|
43
|
+
const entries = [];
|
|
44
|
+
for (const section of sections) {
|
|
45
|
+
const trimmedSection = section.trim();
|
|
46
|
+
if (trimmedSection === '') {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const lines = trimmedSection.split('\n');
|
|
50
|
+
const headerIndex = lines.findIndex((line) => DROPBOX_ENTRY_HEADER.test(line.trim()));
|
|
51
|
+
if (headerIndex === -1) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const headerMatch = lines[headerIndex].trim().match(DROPBOX_ENTRY_HEADER);
|
|
55
|
+
if (!headerMatch) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const [, timestamp, tag] = headerMatch;
|
|
59
|
+
const content = lines.slice(headerIndex + 1).join('\n').trim();
|
|
60
|
+
if (content === '') {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
entries.push({
|
|
64
|
+
tag,
|
|
65
|
+
timestamp,
|
|
66
|
+
content,
|
|
67
|
+
pid: extractDropboxPid(content),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return entries;
|
|
71
|
+
};
|
|
72
|
+
const matchesDropboxEntry = ({ entry, bundleId, pid, }) => {
|
|
73
|
+
const processName = extractDropboxProcessName(entry.content, bundleId);
|
|
74
|
+
if (!processName) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (pid !== undefined && entry.pid !== undefined && entry.pid !== pid) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
};
|
|
82
|
+
const scoreDropboxEntry = ({ entry, bundleId, pid, occurredAt, }) => {
|
|
83
|
+
let score = 0;
|
|
84
|
+
if (extractDropboxProcessName(entry.content, bundleId) === bundleId) {
|
|
85
|
+
score += 100;
|
|
86
|
+
}
|
|
87
|
+
if (pid !== undefined && entry.pid === pid) {
|
|
88
|
+
score += 200;
|
|
89
|
+
}
|
|
90
|
+
const entryTimestamp = parseDropboxTimestamp(entry.timestamp);
|
|
91
|
+
if (entryTimestamp > 0) {
|
|
92
|
+
score -= Math.min(Math.abs(entryTimestamp - occurredAt) / 1000, 300);
|
|
93
|
+
}
|
|
94
|
+
if (entry.tag === 'data_app_native_crash') {
|
|
95
|
+
score += 25;
|
|
96
|
+
}
|
|
97
|
+
return score;
|
|
98
|
+
};
|
|
99
|
+
const toDropboxCrashArtifact = ({ entry, bundleId, pid, occurredAt, lookup, }) => {
|
|
100
|
+
const parsed = androidCrashParser.parse({
|
|
101
|
+
contents: entry.content,
|
|
102
|
+
bundleId,
|
|
103
|
+
pid: pid ?? entry.pid,
|
|
104
|
+
});
|
|
105
|
+
const artifactType = getDropboxArtifactType(entry.tag);
|
|
106
|
+
return {
|
|
107
|
+
...parsed,
|
|
108
|
+
source: 'logs',
|
|
109
|
+
summary: entry.content,
|
|
110
|
+
rawLines: entry.content.split(/\r?\n/),
|
|
111
|
+
artifactType,
|
|
112
|
+
dropboxTag: entry.tag,
|
|
113
|
+
occurredAt,
|
|
114
|
+
score: scoreDropboxEntry({
|
|
115
|
+
entry,
|
|
116
|
+
bundleId,
|
|
117
|
+
pid: lookup?.pid ?? pid,
|
|
118
|
+
occurredAt: lookup?.occurredAt ?? occurredAt,
|
|
119
|
+
}),
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
const getMatchingDropboxEntries = ({ output, bundleId, pid, occurredAt, minOccurredAt, }) => parseDropboxOutput(output)
|
|
123
|
+
.filter((entry) => matchesDropboxEntry({ entry, bundleId, pid }))
|
|
124
|
+
.filter((entry) => {
|
|
125
|
+
const entryOccurredAt = parseDropboxTimestamp(entry.timestamp);
|
|
126
|
+
return (minOccurredAt === undefined ||
|
|
127
|
+
entryOccurredAt === 0 ||
|
|
128
|
+
entryOccurredAt >= minOccurredAt);
|
|
129
|
+
})
|
|
130
|
+
.map((entry) => toDropboxCrashArtifact({
|
|
131
|
+
entry,
|
|
132
|
+
bundleId,
|
|
133
|
+
pid,
|
|
134
|
+
occurredAt: parseDropboxTimestamp(entry.timestamp) || occurredAt,
|
|
135
|
+
}))
|
|
136
|
+
.sort((left, right) => {
|
|
137
|
+
if ((right.score ?? 0) !== (left.score ?? 0)) {
|
|
138
|
+
return (right.score ?? 0) - (left.score ?? 0);
|
|
139
|
+
}
|
|
140
|
+
return right.occurredAt - left.occurredAt;
|
|
141
|
+
});
|
|
142
|
+
const persistDropboxArtifact = ({ artifact, crashArtifactWriter, testFilePath, }) => {
|
|
143
|
+
if (!crashArtifactWriter) {
|
|
144
|
+
return artifact;
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
...artifact,
|
|
148
|
+
artifactPath: crashArtifactWriter.persistArtifact({
|
|
149
|
+
artifactKind: artifact.artifactType,
|
|
150
|
+
testFilePath,
|
|
151
|
+
source: {
|
|
152
|
+
kind: 'text',
|
|
153
|
+
fileName: getDropboxFileName(artifact.dropboxTag),
|
|
154
|
+
text: `${artifact.summary ?? ''}\n`,
|
|
155
|
+
},
|
|
156
|
+
}),
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
export const collectDropboxArtifacts = async ({ bundleId, crashArtifactWriter, getDropboxOutput, minOccurredAt, ...lookup }) => {
|
|
160
|
+
crashDiagnosticsLogger.debug('collecting dropbox crash artifacts: %o', {
|
|
161
|
+
bundleId,
|
|
162
|
+
pid: lookup.pid,
|
|
163
|
+
occurredAt: lookup.occurredAt,
|
|
164
|
+
});
|
|
165
|
+
let output = '';
|
|
166
|
+
try {
|
|
167
|
+
output = await getDropboxOutput();
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
crashDiagnosticsLogger.debug('failed to read dropbox entries', error);
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
return getMatchingDropboxEntries({
|
|
174
|
+
output,
|
|
175
|
+
bundleId,
|
|
176
|
+
pid: lookup.pid,
|
|
177
|
+
occurredAt: lookup.occurredAt,
|
|
178
|
+
minOccurredAt,
|
|
179
|
+
}).map((artifact) => persistDropboxArtifact({
|
|
180
|
+
artifact,
|
|
181
|
+
crashArtifactWriter,
|
|
182
|
+
testFilePath: lookup.testFilePath,
|
|
183
|
+
}));
|
|
184
|
+
};
|
|
185
|
+
export const waitForDropboxArtifacts = async (options) => {
|
|
186
|
+
const deadline = Date.now() + DROPBOX_WAIT_TIMEOUT_MS;
|
|
187
|
+
let latestArtifacts = [];
|
|
188
|
+
while (Date.now() < deadline) {
|
|
189
|
+
latestArtifacts = await collectDropboxArtifacts(options);
|
|
190
|
+
if (latestArtifacts.length > 0) {
|
|
191
|
+
return latestArtifacts;
|
|
192
|
+
}
|
|
193
|
+
if (Date.now() >= deadline) {
|
|
194
|
+
return latestArtifacts;
|
|
195
|
+
}
|
|
196
|
+
await new Promise((resolve) => setTimeout(resolve, DROPBOX_POLL_INTERVAL_MS));
|
|
197
|
+
}
|
|
198
|
+
return latestArtifacts;
|
|
199
|
+
};
|
|
200
|
+
export const filterExitInfo = ({ output, bundleId, pid, }) => {
|
|
201
|
+
if (output.trim() === '' || /No exit info records/i.test(output)) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
const packagePattern = new RegExp(`\\bpackage=${escapeRegExp(bundleId)}\\b`, 'i');
|
|
205
|
+
if (!packagePattern.test(output)) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
const sections = output.split(/(?=ApplicationExitInfo\b)/);
|
|
209
|
+
const matchingSections = sections.filter((section) => {
|
|
210
|
+
if (!packagePattern.test(section)) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
if (pid === undefined) {
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
return new RegExp(`\\bpid=${pid}\\b`).test(section);
|
|
217
|
+
});
|
|
218
|
+
if (matchingSections.length === 0) {
|
|
219
|
+
return packagePattern.test(output) ? output.trim() : null;
|
|
220
|
+
}
|
|
221
|
+
return matchingSections.join('\n').trim();
|
|
222
|
+
};
|
|
223
|
+
export const collectExitInfoArtifact = async ({ bundleId, crashArtifactWriter, getExitInfo, pid, testFilePath, }) => {
|
|
224
|
+
let output = '';
|
|
225
|
+
try {
|
|
226
|
+
output = await getExitInfo();
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
crashDiagnosticsLogger.debug('failed to read activity exit-info', error);
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
const filtered = filterExitInfo({ output, bundleId, pid });
|
|
233
|
+
if (!filtered) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
if (!crashArtifactWriter) {
|
|
237
|
+
return {
|
|
238
|
+
artifactType: 'exit-info',
|
|
239
|
+
artifactPath: filtered,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
artifactType: 'exit-info',
|
|
244
|
+
artifactPath: crashArtifactWriter.persistArtifact({
|
|
245
|
+
artifactKind: 'exit-info',
|
|
246
|
+
testFilePath,
|
|
247
|
+
source: {
|
|
248
|
+
kind: 'text',
|
|
249
|
+
fileName: 'exit-info.txt',
|
|
250
|
+
text: `${filtered}\n`,
|
|
251
|
+
},
|
|
252
|
+
}),
|
|
253
|
+
};
|
|
254
|
+
};
|
|
255
|
+
export const getBestDropboxArtifact = (artifacts) => [...artifacts].sort((left, right) => (right.score ?? 0) - (left.score ?? 0))[0] ??
|
|
256
|
+
null;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AppCrashDetails, AppSessionLog, CrashArtifactWriter, CrashDetailsLookupOptions } from '@react-native-harness/platforms';
|
|
2
|
+
export declare const createAndroidCrashReporter: ({ bundleId, crashArtifactWriter, getLogs, getDropboxOutput, getExitInfo, minOccurredAt, }: {
|
|
3
|
+
bundleId: string;
|
|
4
|
+
crashArtifactWriter?: CrashArtifactWriter;
|
|
5
|
+
getLogs: () => AppSessionLog[];
|
|
6
|
+
getDropboxOutput?: () => Promise<string>;
|
|
7
|
+
getExitInfo?: () => Promise<string>;
|
|
8
|
+
minOccurredAt?: number;
|
|
9
|
+
}) => {
|
|
10
|
+
getCrashDetails: ({ occurredAt, pid, testFilePath, }: CrashDetailsLookupOptions) => Promise<AppCrashDetails | null>;
|
|
11
|
+
};
|
|
12
|
+
//# sourceMappingURL=crash-reporter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crash-reporter.d.ts","sourceRoot":"","sources":["../src/crash-reporter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,eAAe,EACf,aAAa,EACb,mBAAmB,EACnB,yBAAyB,EAE1B,MAAM,iCAAiC,CAAC;AAsKzC,eAAO,MAAM,0BAA0B,GAAI,2FAOxC;IACD,QAAQ,EAAE,MAAM,CAAC;IACjB,mBAAmB,CAAC,EAAE,mBAAmB,CAAC;IAC1C,OAAO,EAAE,MAAM,aAAa,EAAE,CAAC;IAC/B,gBAAgB,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IACzC,WAAW,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IACpC,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;0DAKI,yBAAyB,KAAG,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC;CAuE9D,CAAC"}
|