Skip to content

Commit 9d233dc

Browse files
committed
test: harden app monitor command seams
1 parent 5747dda commit 9d233dc

8 files changed

Lines changed: 174 additions & 158 deletions

File tree

packages/platform-android/src/__tests__/adb.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
getStartAppArgs,
1212
hasAvd,
1313
installApp,
14+
startLogcat,
1415
startEmulator,
1516
uninstallApp,
1617
waitForBoot,
@@ -122,6 +123,34 @@ describe('getStartAppArgs', () => {
122123
]);
123124
});
124125

126+
it('starts logcat using the resolved adb binary path', async () => {
127+
const subprocess = {} as Awaited<ReturnType<typeof tools.spawn>>;
128+
const spawnSpy = vi.spyOn(tools, 'spawn').mockReturnValue(subprocess);
129+
130+
expect(
131+
startLogcat('emulator-5554', [
132+
'logcat',
133+
'-v',
134+
'threadtime',
135+
'-b',
136+
'crash',
137+
])
138+
).toBe(subprocess);
139+
140+
expect(spawnSpy).toHaveBeenCalledWith(expect.stringMatching(/adb$/), [
141+
'-s',
142+
'emulator-5554',
143+
'logcat',
144+
'-v',
145+
'threadtime',
146+
'-b',
147+
'crash',
148+
], {
149+
stdout: 'pipe',
150+
stderr: 'pipe',
151+
});
152+
});
153+
125154
it('checks whether an AVD exists', async () => {
126155
vi.spyOn(tools, 'spawn').mockResolvedValueOnce({
127156
stdout: 'Pixel_6_API_33\nPixel_8_API_35\n',

packages/platform-android/src/__tests__/app-monitor.test.ts

Lines changed: 59 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,22 @@ import fs from 'node:fs';
33
import path from 'node:path';
44
import { tmpdir } from 'node:os';
55
import { createAndroidAppMonitor, createAndroidLogEvent } from '../app-monitor.js';
6-
import * as tools from '@react-native-harness/tools';
76
import { createCrashArtifactWriter } from '@react-native-harness/tools';
7+
import type { Subprocess } from '@react-native-harness/tools';
8+
import * as adb from '../adb.js';
89

9-
const createMockSubprocess = (): tools.Subprocess =>
10+
const createMockSubprocess = (): Subprocess =>
1011
({
1112
nodeChildProcess: Promise.resolve({
1213
kill: vi.fn(),
1314
}),
1415
// eslint-disable-next-line @typescript-eslint/no-empty-function
1516
[Symbol.asyncIterator]: async function* () {},
16-
}) as unknown as tools.Subprocess;
17+
}) as unknown as Subprocess;
1718

1819
const createStreamingSubprocess = (
1920
chunks: Array<{ line: string; delayMs?: number }>
20-
): tools.Subprocess =>
21+
): Subprocess =>
2122
({
2223
nodeChildProcess: Promise.resolve({
2324
kill: vi.fn(),
@@ -31,7 +32,7 @@ const createStreamingSubprocess = (
3132
yield line;
3233
}
3334
},
34-
}) as unknown as tools.Subprocess;
35+
}) as unknown as Subprocess;
3536

3637
const artifactRoot = fs.mkdtempSync(
3738
path.join(tmpdir(), 'rn-harness-android-monitor-artifacts-')
@@ -78,19 +79,12 @@ describe('createAndroidLogEvent', () => {
7879
});
7980

8081
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-
);
82+
const getLogcatTimestampSpy = vi
83+
.spyOn(adb, 'getLogcatTimestamp')
84+
.mockResolvedValue('03-12 11:35:08.000');
85+
const startLogcatSpy = vi
86+
.spyOn(adb, 'startLogcat')
87+
.mockReturnValue(createMockSubprocess());
9488

9589
const monitor = createAndroidAppMonitor({
9690
adbId: 'emulator-5554',
@@ -101,9 +95,8 @@ describe('createAndroidLogEvent', () => {
10195
await monitor.start();
10296
await monitor.stop();
10397

104-
expect(spawnSpy).toHaveBeenNthCalledWith(2, 'adb', [
105-
'-s',
106-
'emulator-5554',
98+
expect(getLogcatTimestampSpy).toHaveBeenCalledWith('emulator-5554');
99+
expect(startLogcatSpy).toHaveBeenCalledWith('emulator-5554', [
107100
'logcat',
108101
'-v',
109102
'threadtime',
@@ -112,38 +105,26 @@ describe('createAndroidLogEvent', () => {
112105
'--uid=10234',
113106
'-T',
114107
'03-12 11:35:08.000',
115-
], {
116-
stdout: 'pipe',
117-
stderr: 'pipe',
118-
});
108+
]);
119109
});
120110

121111
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
112+
vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue('03-12 10:44:40.000');
113+
vi.spyOn(adb, 'startLogcat').mockReturnValue(
114+
createStreamingSubprocess([
115+
{ line: '--------- beginning of crash' },
116+
{
117+
line: '03-12 10:44:40.420 13861 13861 E AndroidRuntime: Process: com.harnessplayground, PID: 13861',
118+
},
119+
{
120+
line: '03-12 10:44:40.421 13861 13861 E AndroidRuntime: java.lang.RuntimeException: boom',
121+
delayMs: 25,
122+
},
123+
{
124+
line: '03-12 10:44:40.422 13861 13861 E AndroidRuntime: at com.harnessplayground.MainActivity.onCreate(MainActivity.kt:42)',
125+
delayMs: 25,
126+
},
127+
])
147128
);
148129

149130
const monitor = createAndroidAppMonitor({
@@ -171,27 +152,18 @@ describe('createAndroidLogEvent', () => {
171152
});
172153

173154
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
155+
vi.spyOn(adb, 'getLogcatTimestamp').mockResolvedValue('03-12 10:44:40.000');
156+
vi.spyOn(adb, 'startLogcat').mockReturnValue(
157+
createStreamingSubprocess([
158+
{ line: '--------- beginning of crash' },
159+
{
160+
line: '03-12 10:44:40.420 13861 13861 E AndroidRuntime: Process: com.harnessplayground, PID: 13861',
161+
},
162+
{
163+
line: '03-12 10:44:40.421 13861 13861 E AndroidRuntime: java.lang.RuntimeException: boom',
164+
delayMs: 20,
165+
},
166+
])
195167
);
196168

197169
const monitor = createAndroidAppMonitor({
@@ -224,31 +196,14 @@ describe('createAndroidLogEvent', () => {
224196
});
225197

226198
it('can be started again after timestamp lookup fails', async () => {
227-
const spawnSpy = vi.spyOn(tools, 'spawn');
228199
const timestampError = new Error('date failed');
229200

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-
);
201+
vi.spyOn(adb, 'getLogcatTimestamp')
202+
.mockRejectedValueOnce(timestampError)
203+
.mockResolvedValueOnce('03-12 11:35:08.000');
204+
const startLogcatSpy = vi
205+
.spyOn(adb, 'startLogcat')
206+
.mockReturnValue(createMockSubprocess());
252207

253208
const monitor = createAndroidAppMonitor({
254209
adbId: 'emulator-5554',
@@ -260,14 +215,15 @@ describe('createAndroidLogEvent', () => {
260215
await expect(monitor.start()).resolves.toBeUndefined();
261216
await monitor.stop();
262217

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);
218+
expect(startLogcatSpy).toHaveBeenCalledWith('emulator-5554', [
219+
'logcat',
220+
'-v',
221+
'threadtime',
222+
'-b',
223+
'crash',
224+
'--uid=10234',
225+
'-T',
226+
'03-12 11:35:08.000',
227+
]);
272228
});
273229
});

packages/platform-android/src/adb.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { type AndroidAppLaunchOptions } from '@react-native-harness/platforms';
2-
import { logger, spawn, SubprocessError } from '@react-native-harness/tools';
2+
import {
3+
logger,
4+
spawn,
5+
SubprocessError,
6+
type Subprocess,
7+
} from '@react-native-harness/tools';
38
import { spawn as nodeSpawn } from 'node:child_process';
49
import type { ChildProcessByStdio } from 'node:child_process';
510
import { access, rm } from 'node:fs/promises';
@@ -765,6 +770,15 @@ export const getLogcatTimestamp = async (adbId: string): Promise<string> => {
765770
return stdout.trim().replace(/^'+|'+$/g, '');
766771
};
767772

773+
export const startLogcat = (
774+
adbId: string,
775+
args: readonly string[],
776+
): Subprocess =>
777+
spawn(getAdbBinaryPath(), ['-s', adbId, ...args], {
778+
stdout: 'pipe',
779+
stderr: 'pipe',
780+
});
781+
768782
export const getAvds = async (): Promise<string[]> => {
769783
try {
770784
const emulatorBinaryPath = await ensureEmulatorInstalled();

packages/platform-android/src/app-monitor.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
escapeRegExp,
1111
getEmitter,
1212
logger,
13-
spawn,
1413
SubprocessError,
1514
type Subprocess,
1615
} from '@react-native-harness/tools';
@@ -445,13 +444,9 @@ export const createAndroidAppMonitor = ({
445444
const startLogcat = async () => {
446445
const logcatTimestamp = await adb.getLogcatTimestamp(adbId);
447446

448-
logcatProcess = spawn(
449-
'adb',
450-
['-s', adbId, ...getLogcatArgs(appUid, logcatTimestamp)],
451-
{
452-
stdout: 'pipe',
453-
stderr: 'pipe',
454-
}
447+
logcatProcess = adb.startLogcat(
448+
adbId,
449+
getLogcatArgs(appUid, logcatTimestamp)
455450
);
456451

457452
const currentProcess = logcatProcess;

packages/platform-ios/src/__tests__/app-monitor.test.ts

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
import * as simctl from '../xcrun/simctl.js';
1111
import * as devicectl from '../xcrun/devicectl.js';
1212
import * as diagnostics from '../crash-diagnostics.js';
13-
import * as tools from '@react-native-harness/tools';
1413
import { createCrashArtifactWriter } from '@react-native-harness/tools';
1514
import type { Subprocess } from '@react-native-harness/tools';
1615

@@ -95,8 +94,8 @@ describe('createIosSimulatorAppMonitor', () => {
9594
});
9695

9796
it('starts simctl log stream', async () => {
98-
const spawnSpy = vi
99-
.spyOn(tools, 'spawn')
97+
const streamLogsSpy = vi
98+
.spyOn(simctl, 'streamLogs')
10099
.mockReturnValue(createStreamingSubprocess([]));
101100

102101
vi.spyOn(simctl, 'getAppInfo').mockResolvedValue({
@@ -116,30 +115,14 @@ describe('createIosSimulatorAppMonitor', () => {
116115
await monitor.start();
117116
await monitor.stop();
118117

119-
expect(spawnSpy).toHaveBeenCalledWith(
120-
'xcrun',
121-
[
122-
'simctl',
123-
'spawn',
124-
'sim-udid',
125-
'log',
126-
'stream',
127-
'--style',
128-
'compact',
129-
'--level',
130-
'info',
131-
'--predicate',
132-
'process == "HarnessPlayground" OR process == "com.harnessplayground"',
133-
],
134-
{
135-
stdout: 'pipe',
136-
stderr: 'pipe',
137-
}
118+
expect(streamLogsSpy).toHaveBeenCalledWith(
119+
'sim-udid',
120+
'process == "HarnessPlayground" OR process == "com.harnessplayground"'
138121
);
139122
});
140123

141124
it('returns best-effort simulator crash details from recent log blocks', async () => {
142-
vi.spyOn(tools, 'spawn').mockReturnValue(
125+
vi.spyOn(simctl, 'streamLogs').mockReturnValue(
143126
createStreamingSubprocess([
144127
{
145128
line: '2026-03-12 11:35:08.000 HarnessPlayground[1234:abcd] Terminating app due to uncaught exception: NSInternalInconsistencyException',
@@ -193,7 +176,7 @@ describe('createIosSimulatorAppMonitor', () => {
193176
});
194177

195178
it('prefers a matched simulator crash report when one is found', async () => {
196-
vi.spyOn(tools, 'spawn').mockReturnValue(
179+
vi.spyOn(simctl, 'streamLogs').mockReturnValue(
197180
createStreamingSubprocess([
198181
{
199182
line: '2026-03-12 11:35:08.000 HarnessPlayground[1234:abcd] Terminating app due to uncaught exception: NSInternalInconsistencyException',

0 commit comments

Comments
 (0)