Files
software-mansion__argent/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts
Ignacy Łątka a5c6730d05 feat(ios-profiler): attributable memory leaks via malloc_stack_logging (#351)
## What

Opt-in `malloc_stack_logging` flag on `native-profiler-start` that makes
iOS memory leaks **attributable** — they come back with a real
responsible frame + library instead of `<Call stack limit reached>`.

## Why

`native-profiler-start` attaches to the already-running app. iOS records
malloc allocation backtraces only when `MallocStackLogging` is set **at
process launch**, so an attached app has none: Instruments' Leaks
scanner finds the leaked blocks but can't attribute them and emits the
placeholder `<Call stack limit reached>` (that string is Instruments',
not ours).

## How

When `malloc_stack_logging: true`, the profiler cold-launches the app
**under** xctrace instead of attaching:

```
xctrace record --template <Argent.tracetemplate> --device <udid> \
  --env MallocStackLogging=1 --output <…> --no-prompt \
  --launch -- <App.app>
```

- `.app` path resolved via `simctl get_app_container`; the running
instance is terminated first for a clean cold start.
- The existing Leaks export / parser / render path is reused
**unchanged** — no parser changes.
- Default behaviour is untouched: attach to the running app (no
relaunch, no overhead). The flag is opt-in because it restarts the app
(loses state) and adds allocator overhead — for leak-attribution passes,
not CPU/hang work.
- The report relabels unattributable leaks with a hint to re-run with
`malloc_stack_logging: true`, instead of surfacing the raw placeholder.

## Evidence (real iPhone 16 sim, same scroll workload)

Before (attach) — every leak row:
```
responsible-frame="<Call stack limit reached>" responsible-library=""
```
After (`--env MallocStackLogging=1 --launch`) — same Leaks export:

| leaked-object | responsible-frame | library |
|---|---|---|
| Malloc 1008 B ×3 | `itanium_demangle::OutputBuffer::grow(...)` |
libc++abi |
| Malloc 48 B ×1 | `hermes::vm::JSTypedArrayBase::createBuffer(...)` |
hermes |

## Tests

- `test/ios-instruments/malloc-stack-logging.test.ts` — the malloc
launch argv (`--env MallocStackLogging=1`, `--launch -- <app>` last,
with and without `--notify-tracing-started`, `--no-prompt`) vs default
`--attach`; terminate-first / get_app_container ordering; failed-start
behavior; per-test `ARGENT_IOS_CAPTURE` scrubbing.
- `test/ios-instruments/leak-attribution-render.test.ts` —
unattributable vs attributed leak rendering, capture-mode-aware hints.
- `test/ios-instruments/load-freshness.test.ts` — per-capture residue
clearing on `profiler-load`, live/recovery-pending load refusals.
- `test/ios-instruments/stop-recovery.test.ts` — capture-mode pairing at
stop (clean and recovery paths).
- `test/profiler-combined-leaks.test.ts`,
`test/profiler-leak-stacks.test.ts`, `test/ios-leak-pipeline.test.ts` —
shared caveat line, evidence-first hints, attribution-normalized
grouping.
- Full `test/ios-instruments/` suite green (9 files / 70 tests); full
tool-server suite green; `tsc --noEmit` + `tsc -p tsconfig.test.json`
clean.

## Notes

E2E'd live twice during review (real iPhone 16 Pro / iPhone 17 Pro sims
on Xcode 26.6): guard refusals fire before the app is touched, the
`--device` cold launch's deadlock on degraded Xcode is confirmed (so the
guard is warranted), and the malloc argv/terminate/relaunch behavior
checked out on-device. Docs updated: `IOS_PROFILER_REFERENCE.md` and the
`argent-native-profiler` skill.

## Review follow-ups (post-approval hardening)

Later commits address the remaining review threads and several
subsequent multi-lens review passes:

- **Capture-mode is a real signal, end to end.** The session records the
mode of the in-flight recording at start
(`recordingMallocStackLogging`), pairs it with `exportedFiles` at stop
(`mallocStackLogging`), and freezes it into `parsedData` at analyze/load
— so the analyze report, the combined report, and the `leak_stacks`
drill-down all name the capture mode of the data they actually render
instead of inferring it (or mislabeling it after a newer capture
starts). One shared `renderUnattributedLeaksNote()` keeps the wording
identical across reports, and attribution evidence outranks the flag: a
resolved frame proves the target ran under malloc stack logging however
it was launched.
- **`profiler-load` restores a clean slate.** Loading clears all
per-capture residue (capture mode, `cpuFilterPid`, `wallClockStartMs`,
`traceFile`) so a loaded trace is never analyzed with a previous live
capture's PID filter, freshness anchor, or report path — and refuses to
load while a recording is in flight or a crashed capture awaits
`native-profiler-stop`'s partial-trace recovery, which the load would
otherwise orphan.
- **Failed starts are non-destructive.** Per-capture descriptors — and
the recovery flags that gate `native-profiler-stop`'s partial-trace
export — are stamped/reset only on a successful start, so a failed
attempt leaves the previous capture's still-loaded exports fully
described and a pending abnormal-end recovery intact.
- **Guards keep session state coherent.** `profiler-load`,
`native-profiler-analyze`, and `profiler-combined-report` all refuse
while a recording is in flight or a crashed capture awaits recovery, via
one shared `isCaptureInFlight`/`inFlightGuardMessage` helper whose
message distinguishes recording vs 10-min-cap timeout vs unexpected exit
(so the stated cause matches what `native-profiler-stop` reports next).
Proceeding would otherwise wedge the session (load) or render one
capture's frozen data under another's live fields (analyze/combined).
- **The combined report anchors on a frozen clock.** The recording's
start time is frozen into `parsedData` at analyze, and the iOS combined
report anchors its hangs to that frozen value rather than the live
session field — so a capture started after analyze can never shift a
real hang↔commit correlation into "unmatched". Android keeps its live
anchor (it re-derives hangs from the live trace). The combined-report
guard's recovery advice routes through `native-profiler-analyze`, the
only step that rebuilds `parsedData`.
- **Leak grouping is attribution-normalized.** `aggregateLeaks` keys on
`(objectType, frame-normalized-the-way-isLeakAttributed-matches)`, so
mixed unattributed spellings (`Unknown`, the sentinel, whitespace
variants) can't split one group per type.
- **The malloc launch target is unambiguous.** Since the cold launch
terminates the resolved app, `app_process` resolution prefers an exact
`CFBundleExecutable` match and, when builds still tie (dev + prod
sharing an executable/display name), refuses and points at the
globally-unique `CFBundleIdentifier` — which it also accepts as an
`app_process` — instead of killing whichever app the plist enumerates
first.
- **Report tables escape `|`.** Demangled C++ frames (`operator|`
included) are real headline content under malloc mode; every free-form
cell across the leak, CPU-hotspot, function caller/callee, and
thread-breakdown tables — symbol *and* thread/queue-name columns — is
escaped so a stray pipe can't shift the row's columns.
- **Android start is non-destructive too.** `startNativeProfilerAndroid`
now starts perfetto before mutating session state, mirroring iOS, so a
failed start no longer burns a prior capped capture's pending
partial-trace recovery.
2026-07-12 13:42:23 +02:00

1105 lines
47 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "events";
import { FAILURE_CODES, getFailureSignal } from "@argent/registry";
import type { NativeProfilerSessionApi } from "../../src/blueprints/native-profiler-session";
// Exercises the malloc_stack_logging launch path in startNativeProfilerIos
// directly (no blueprint/native-devtools imports) so we can assert the exact
// xctrace argv. Default mode must still `--attach`; malloc_stack_logging mode
// must cold-`--launch` the resolved .app with `--env MallocStackLogging=1`.
class StartFakeChild extends EventEmitter {
pid = 4242;
stdout = new EventEmitter();
stderr = new EventEmitter();
kill = vi.fn();
}
const LISTAPPS_JSON = JSON.stringify({
"com.example.myapp": {
CFBundleExecutable: "MyApp",
CFBundleIdentifier: "com.example.myapp",
ApplicationType: "User",
},
});
function fakeApi(): NativeProfilerSessionApi {
return {
deviceId: "DEVICE-UDID",
platform: "ios",
appProcess: null,
capturePid: null,
captureProcess: null,
cpuFilterPid: null,
recordingMallocStackLogging: null,
mallocStackLogging: null,
traceFile: null,
exportedFiles: null,
profilingActive: false,
wallClockStartMs: null,
parsedData: null,
recordingTimeout: null,
recordingTimedOut: false,
recordingExitedUnexpectedly: false,
lastExitInfo: null,
androidOnDeviceTracePath: null,
};
}
// Every simctl/xcode subprocess the profiler runs goes through execFileSync (discrete
// argv, shell-injection-hardened) — launchctl and `xcodebuild -version` included, since
// the capture-strategy selector reads the Xcode version the same hardened way. So the
// mock keys off the bin/argv rather than a command string. `simctl listapps` returns a
// plist that `plutil` converts to JSON; `xcodebuild` defaults to unmocked ("") so the
// version reads as undetermined unless a caller opts in via the `xcodebuild` option.
interface ExecFileSyncOpts {
maxBuffer?: number;
input?: string;
encoding?: string;
timeout?: number;
}
function makeExecFileSyncFn(opts?: { xcodebuild?: string; listappsJson?: string }) {
return vi.fn((bin: string, args: string[] = [], _opts?: ExecFileSyncOpts) => {
if (bin === "xcodebuild") return opts?.xcodebuild ?? ""; // capture-strategy reads `xcodebuild -version`
if (bin === "plutil") return opts?.listappsJson ?? LISTAPPS_JSON; // plutil converts the listapps plist to JSON
if (args.includes("listapps")) return "<plist/>"; // raw plist, piped into plutil
if (args.includes("launchctl"))
return "1\t0\tUIKitApplication:com.example.myapp[abcd][rb-legacy]\n"; // `simctl spawn <udid> launchctl list`
if (args.includes("get_app_container")) return "/Users/x/Library/.../MyApp.app\n";
if (args.includes("terminate")) return "";
return "";
});
}
function mockChildProcess() {
const spawnFn = vi.fn(() => new StartFakeChild());
// Nothing routes through execSync anymore (launchctl/xcodebuild are hardened to
// execFileSync); keep a stub so a stray call no-ops rather than crashing.
const execSyncFn = vi.fn(() => "");
const execFileSyncFn = makeExecFileSyncFn();
return { spawnFn, execSyncFn, execFileSyncFn };
}
async function importStart() {
const mod = await import("../../src/tools/profiler/native-profiler/platforms/ios");
return mod.startNativeProfilerIos;
}
// startNativeProfilerIos reads ARGENT_IOS_CAPTURE (via the capture-strategy
// resolver), and exporting it globally is a documented operator setup for
// degraded Xcodes — scrub it for every test so an ambient value can't flip the
// resolved strategy (=all-processes would refuse every malloc start here), and
// restore the ambient value afterwards. Tests that exercise the override still
// set it themselves after the scrub.
const AMBIENT_IOS_CAPTURE = process.env.ARGENT_IOS_CAPTURE;
describe("native-profiler-start malloc_stack_logging", () => {
beforeEach(() => {
delete process.env.ARGENT_IOS_CAPTURE;
vi.resetModules();
vi.useFakeTimers();
});
afterEach(() => {
if (AMBIENT_IOS_CAPTURE === undefined) {
delete process.env.ARGENT_IOS_CAPTURE;
} else {
process.env.ARGENT_IOS_CAPTURE = AMBIENT_IOS_CAPTURE;
}
vi.useRealTimers();
vi.doUnmock("child_process");
vi.doUnmock("../../src/utils/react-profiler/debug/dump");
vi.doUnmock("../../src/utils/ios-profiler/notify");
vi.doUnmock("../../src/utils/ios-profiler/startup");
});
function applyCommonMocks(spawnFn: unknown, execSyncFn: unknown, execFileSyncFn: unknown) {
vi.doMock("child_process", () => ({
spawn: spawnFn,
execSync: execSyncFn,
execFile: vi.fn(),
execFileSync: execFileSyncFn,
}));
vi.doMock("../../src/utils/react-profiler/debug/dump", () => ({
getDebugDir: vi.fn(async () => "/tmp/argent-profiler-cwd"),
}));
vi.doMock("../../src/utils/ios-profiler/notify", () => ({
listenForDarwinNotification: vi.fn(() => {
throw new Error("notifyutil unavailable in tests");
}),
}));
vi.doMock("../../src/utils/ios-profiler/startup", () => ({
waitForXctraceReady: vi.fn(async () => ({ stderrBuffer: "" })),
}));
}
it("cold-launches the .app with MallocStackLogging when malloc_stack_logging is true", async () => {
const { spawnFn, execSyncFn, execFileSyncFn } = mockChildProcess();
applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn);
const startNativeProfilerIos = await importStart();
const api = fakeApi();
const result = await startNativeProfilerIos(api, {
device_id: "DEVICE-UDID",
app_process: "MyApp",
malloc_stack_logging: true,
});
expect(result.status).toBe("recording");
expect(spawnFn).toHaveBeenCalledTimes(1);
const [bin, args] = spawnFn.mock.calls[0] as unknown as [string, string[]];
expect(bin).toBe("xctrace");
// launch + env, not attach
expect(args).toContain("--launch");
expect(args).toContain("--env");
expect(args[args.indexOf("--env") + 1]).toBe("MallocStackLogging=1");
expect(args).not.toContain("--attach");
// --device with the correct udid is the whole premise of the degraded-Xcode
// guard (the cold launch can only run on the device path), so assert it is
// present and threaded through — a regression that dropped it or passed the
// wrong device would otherwise slip past.
expect(args).toContain("--device");
expect(args[args.indexOf("--device") + 1]).toBe("DEVICE-UDID");
// the launched target is the resolved .app bundle, and must be the LAST args
// (everything after `--` is the launched command).
const dashIdx = args.indexOf("--");
expect(dashIdx).toBeGreaterThan(args.indexOf("--launch"));
expect(args[dashIdx + 1]).toBe("/Users/x/Library/.../MyApp.app");
expect(dashIdx + 1).toBe(args.length - 1);
// the malloc branch hand-rolls its argv apart from the strategy builder, so
// --no-prompt must be asserted here separately or a regression dropping it
// (xctrace then blocks on an interactive prompt) would slip past.
expect(args).toContain("--no-prompt");
// the running instance is terminated first for a clean cold start
const efsArgs = execFileSyncFn.mock.calls.map((c) => (c[1] as string[]) ?? []);
expect(efsArgs.some((a) => a.includes("terminate") && a.includes("com.example.myapp"))).toBe(
true
);
expect(efsArgs.some((a) => a.includes("get_app_container"))).toBe(true);
});
it("a failed start leaves the previous capture's descriptors fully intact", async () => {
// Per-capture descriptors (appProcess, traceFile, cpuFilterPid, capture
// mode) are stamped only on a SUCCESSFUL start. A failed attempt must not
// clobber them: the previous capture's exports are still loaded, and
// analyze pairs them with these fields — nulling cpuFilterPid would render
// a host-wide all-processes capture unfiltered, and nulling traceFile
// would strip the report's label and file.
const { spawnFn, execSyncFn, execFileSyncFn } = mockChildProcess();
vi.doMock("child_process", () => ({
spawn: spawnFn,
execSync: execSyncFn,
execFile: vi.fn(),
execFileSync: execFileSyncFn,
}));
vi.doMock("../../src/utils/react-profiler/debug/dump", () => ({
getDebugDir: vi.fn(async () => "/tmp/argent-profiler-cwd"),
}));
vi.doMock("../../src/utils/ios-profiler/notify", () => ({
listenForDarwinNotification: vi.fn(() => {
throw new Error("notifyutil unavailable in tests");
}),
}));
vi.doMock("../../src/utils/ios-profiler/startup", () => ({
waitForXctraceReady: vi.fn(async () => {
throw new Error("xctrace failed to start");
}),
}));
const startNativeProfilerIos = await importStart();
const api = fakeApi();
api.appProcess = "PrevApp";
api.traceFile = "/tmp/argent-profiler-cwd/previous.trace";
api.cpuFilterPid = 777; // previous capture ran via the all-processes fallback
api.mallocStackLogging = true;
api.exportedFiles = { cpu: "/tmp/prev_cpu.xml", hangs: null, leaks: null };
await expect(
startNativeProfilerIos(api, { device_id: "DEVICE-UDID", app_process: "MyApp" })
).rejects.toThrow(/xctrace failed to start/);
expect(api.appProcess).toBe("PrevApp");
expect(api.traceFile).toBe("/tmp/argent-profiler-cwd/previous.trace");
expect(api.cpuFilterPid).toBe(777);
expect(api.mallocStackLogging).toBe(true);
expect(api.capturePid).toBeNull();
expect(api.captureProcess).toBeNull();
});
it("a failed start leaves a pending partial-trace recovery intact", async () => {
// Capture A ended abnormally (timeout / unexpected xctrace exit): stop's
// recovery branch gates on `(recovery flags) && traceFile` to export the
// partial trace. Nothing in a start attempt reads those flags, so a FAILED
// restart must not burn them — clearing them forfeits up to 10 minutes of
// captured data (the .trace bundle cannot be re-ingested by profiler-load).
const { spawnFn, execSyncFn, execFileSyncFn } = mockChildProcess();
vi.doMock("child_process", () => ({
spawn: spawnFn,
execSync: execSyncFn,
execFile: vi.fn(),
execFileSync: execFileSyncFn,
}));
vi.doMock("../../src/utils/react-profiler/debug/dump", () => ({
getDebugDir: vi.fn(async () => "/tmp/argent-profiler-cwd"),
}));
vi.doMock("../../src/utils/ios-profiler/notify", () => ({
listenForDarwinNotification: vi.fn(() => {
throw new Error("notifyutil unavailable in tests");
}),
}));
vi.doMock("../../src/utils/ios-profiler/startup", () => ({
waitForXctraceReady: vi.fn(async () => {
throw new Error("xctrace failed to start");
}),
}));
const startNativeProfilerIos = await importStart();
const api = fakeApi();
api.recordingExitedUnexpectedly = true;
api.lastExitInfo = { code: 1, signal: null };
api.traceFile = "/tmp/argent-profiler-cwd/native-profiler-A.trace";
await expect(
startNativeProfilerIos(api, { device_id: "DEVICE-UDID", app_process: "MyApp" })
).rejects.toThrow(/xctrace failed to start/);
expect(api.recordingExitedUnexpectedly).toBe(true);
expect(api.lastExitInfo).toEqual({ code: 1, signal: null });
expect(api.traceFile).toBe("/tmp/argent-profiler-cwd/native-profiler-A.trace");
});
it("malloc mode prefers the exact CFBundleExecutable match over an earlier display-name match", async () => {
// Two installed apps can share a display name (dev + prod builds both
// shown as "MyApp"). The malloc path TERMINATES the resolved app before
// cold-launching it, so first-match-in-plist-order is not acceptable: an
// exact executable match must win regardless of enumeration order.
const twoApps = JSON.stringify({
"com.example.staging": {
CFBundleExecutable: "MyAppStaging",
CFBundleIdentifier: "com.example.staging",
CFBundleDisplayName: "MyApp",
ApplicationType: "User",
},
"com.example.myapp": {
CFBundleExecutable: "MyApp",
CFBundleIdentifier: "com.example.myapp",
ApplicationType: "User",
},
});
const spawnFn = vi.fn(() => new StartFakeChild());
const execSyncFn = vi.fn(() => "");
const execFileSyncFn = makeExecFileSyncFn({ listappsJson: twoApps });
applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn);
const startNativeProfilerIos = await importStart();
const result = await startNativeProfilerIos(fakeApi(), {
device_id: "DEVICE-UDID",
app_process: "MyApp",
malloc_stack_logging: true,
});
expect(result.status).toBe("recording");
const terminateCalls = execFileSyncFn.mock.calls
.map((c) => (c[1] as string[]) ?? [])
.filter((a) => a.includes("terminate"));
expect(terminateCalls).toHaveLength(1);
expect(terminateCalls[0]).toContain("com.example.myapp");
expect(terminateCalls[0]).not.toContain("com.example.staging");
});
it("malloc mode refuses an app_process that matches several installed apps ambiguously", async () => {
// Display-name-only ambiguity (no exact executable match): terminating an
// arbitrary one of them would kill the wrong app. Refuse before touching
// anything, naming the candidates.
const twoApps = JSON.stringify({
"com.example.staging": {
CFBundleExecutable: "MyAppStaging",
CFBundleIdentifier: "com.example.staging",
CFBundleDisplayName: "MyApp",
ApplicationType: "User",
},
"com.example.prod": {
CFBundleExecutable: "MyAppProd",
CFBundleIdentifier: "com.example.prod",
CFBundleDisplayName: "MyApp",
ApplicationType: "User",
},
});
const spawnFn = vi.fn(() => new StartFakeChild());
const execSyncFn = vi.fn(() => "");
const execFileSyncFn = makeExecFileSyncFn({ listappsJson: twoApps });
applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn);
const startNativeProfilerIos = await importStart();
await expect(
startNativeProfilerIos(fakeApi(), {
device_id: "DEVICE-UDID",
app_process: "MyApp",
malloc_stack_logging: true,
})
).rejects.toThrow(/matches multiple installed user apps/);
const efsArgs = execFileSyncFn.mock.calls.map((c) => (c[1] as string[]) ?? []);
expect(efsArgs.some((a) => a.includes("terminate"))).toBe(false);
expect(spawnFn).not.toHaveBeenCalled();
});
// Dev + prod builds of one Xcode target share CFBundleExecutable "MyApp"
// (exact.length === 2). Advising "pass the exact CFBundleExecutable" would be a
// dead end — the user already passed it. The refusal must point at the
// globally-unique CFBundleIdentifier, and passing one must actually resolve.
const SHARED_EXECUTABLE_BUILDS = JSON.stringify({
"com.example.dev": {
CFBundleExecutable: "MyApp",
CFBundleIdentifier: "com.example.dev",
CFBundleDisplayName: "MyApp Dev",
ApplicationType: "User",
},
"com.example.prod": {
CFBundleExecutable: "MyApp",
CFBundleIdentifier: "com.example.prod",
CFBundleDisplayName: "MyApp",
ApplicationType: "User",
},
});
it("refusal for a shared CFBundleExecutable names the bundle id as the disambiguator", async () => {
const spawnFn = vi.fn(() => new StartFakeChild());
const execFileSyncFn = makeExecFileSyncFn({ listappsJson: SHARED_EXECUTABLE_BUILDS });
applyCommonMocks(
spawnFn,
vi.fn(() => ""),
execFileSyncFn
);
const startNativeProfilerIos = await importStart();
await expect(
startNativeProfilerIos(fakeApi(), {
device_id: "DEVICE-UDID",
app_process: "MyApp",
malloc_stack_logging: true,
})
).rejects.toThrow(/CFBundleIdentifier/);
expect(spawnFn).not.toHaveBeenCalled();
});
it("passing the exact CFBundleIdentifier resolves and cold-launches that specific build", async () => {
const spawnFn = vi.fn(() => new StartFakeChild());
const execFileSyncFn = makeExecFileSyncFn({ listappsJson: SHARED_EXECUTABLE_BUILDS });
applyCommonMocks(
spawnFn,
vi.fn(() => ""),
execFileSyncFn
);
const startNativeProfilerIos = await importStart();
const result = await startNativeProfilerIos(fakeApi(), {
device_id: "DEVICE-UDID",
app_process: "com.example.prod",
malloc_stack_logging: true,
});
expect(result.status).toBe("recording");
const terminateCalls = execFileSyncFn.mock.calls
.map((c) => (c[1] as string[]) ?? [])
.filter((a) => a.includes("terminate"));
expect(terminateCalls).toHaveLength(1);
expect(terminateCalls[0]).toContain("com.example.prod");
expect(terminateCalls[0]).not.toContain("com.example.dev");
});
it("stamps only the in-flight capture mode — never the report-facing flag", async () => {
// api.mallocStackLogging describes the data in exportedFiles/parsedData
// and is stamped at STOP; a new start stamping it directly would re-label
// the previous capture's still-loaded data with the new capture's mode
// (e.g. an attach start flipping a malloc capture's report to "--attach").
const { spawnFn, execSyncFn, execFileSyncFn } = mockChildProcess();
applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn);
const startNativeProfilerIos = await importStart();
const api = fakeApi();
api.mallocStackLogging = true; // previous capture (stop-stamped) was malloc-mode
await startNativeProfilerIos(api, {
device_id: "DEVICE-UDID",
app_process: "MyApp",
// default attach mode
});
expect(api.recordingMallocStackLogging).toBe(false);
expect(api.mallocStackLogging).toBe(true); // untouched until the next stop
});
it("keeps `--launch -- <app>` last when --notify-tracing-started is present", async () => {
// Every other test lets the notify mock throw, so registerStartupNotify
// returns null and the argv never contains --notify-tracing-started — on a
// real host it IS present. Exercise that argv shape: the notify args must
// precede --launch (xctrace treats everything after `--` as the launched
// command plus ITS args, so a notify arg ordered after it would be handed
// to the app instead of xctrace).
const { spawnFn, execSyncFn, execFileSyncFn } = mockChildProcess();
vi.doMock("child_process", () => ({
spawn: spawnFn,
execSync: execSyncFn,
execFile: vi.fn(),
execFileSync: execFileSyncFn,
}));
vi.doMock("../../src/utils/react-profiler/debug/dump", () => ({
getDebugDir: vi.fn(async () => "/tmp/argent-profiler-cwd"),
}));
vi.doMock("../../src/utils/ios-profiler/notify", () => ({
listenForDarwinNotification: vi.fn(() => ({
ready: Promise.resolve(),
fired: new Promise<void>(() => {}),
cancel: vi.fn(),
})),
}));
vi.doMock("../../src/utils/ios-profiler/startup", () => ({
waitForXctraceReady: vi.fn(async () => ({ stderrBuffer: "" })),
}));
const startNativeProfilerIos = await importStart();
const api = fakeApi();
const result = await startNativeProfilerIos(api, {
device_id: "DEVICE-UDID",
app_process: "MyApp",
malloc_stack_logging: true,
});
expect(result.status).toBe("recording");
const [bin, args] = spawnFn.mock.calls[0] as unknown as [string, string[]];
expect(bin).toBe("xctrace");
const notifyIdx = args.indexOf("--notify-tracing-started");
expect(notifyIdx).toBeGreaterThan(-1);
expect(args[notifyIdx + 1]).toMatch(/^com\.argent\.ios-profiler\.started\./);
expect(notifyIdx).toBeLessThan(args.indexOf("--launch"));
expect(args).toContain("--no-prompt");
// `--launch -- <app>` must be the final three args even with the notify
// pair in the argv.
expect(args.slice(-3)).toEqual(["--launch", "--", "/Users/x/Library/.../MyApp.app"]);
});
it("captures the listapps plist with a large buffer so a big simulator can't overflow it", async () => {
// getInstalledApps buffers the FULL plist into Node (it's larger than the JSON
// plutil emits), so the listapps capture must raise maxBuffer well above Node's
// 1 MiB default — otherwise a well-populated simulator throws ENOBUFS where the
// old shell pipe (which only buffered the smaller JSON) worked.
const { spawnFn, execSyncFn, execFileSyncFn } = mockChildProcess();
applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn);
const startNativeProfilerIos = await importStart();
const api = fakeApi();
await startNativeProfilerIos(api, {
device_id: "DEVICE-UDID",
app_process: "MyApp",
malloc_stack_logging: true,
});
const listappsCall = execFileSyncFn.mock.calls.find(
(c) => Array.isArray(c[1]) && c[1].includes("listapps")
);
expect(listappsCall).toBeTruthy();
expect(listappsCall?.[2]?.maxBuffer).toBeGreaterThan(8 * 1024 * 1024);
});
it("does not terminate the app if the debug dir can't be created (malloc mode)", async () => {
// getDebugDir()'s mkdir runs BEFORE the terminate, so a failure (e.g. ENOSPC)
// must leave the running app untouched — never killed-without-relaunch.
const { spawnFn, execSyncFn, execFileSyncFn } = mockChildProcess();
vi.doMock("child_process", () => ({
spawn: spawnFn,
execSync: execSyncFn,
execFile: vi.fn(),
execFileSync: execFileSyncFn,
}));
vi.doMock("../../src/utils/react-profiler/debug/dump", () => ({
getDebugDir: vi.fn(async () => {
throw new Error("ENOSPC: no space left on device");
}),
}));
vi.doMock("../../src/utils/ios-profiler/notify", () => ({
listenForDarwinNotification: vi.fn(() => {
throw new Error("notifyutil unavailable in tests");
}),
}));
vi.doMock("../../src/utils/ios-profiler/startup", () => ({
waitForXctraceReady: vi.fn(async () => ({ stderrBuffer: "" })),
}));
const startNativeProfilerIos = await importStart();
const api = fakeApi();
await expect(
startNativeProfilerIos(api, {
device_id: "DEVICE-UDID",
app_process: "MyApp",
malloc_stack_logging: true,
})
).rejects.toThrow(/ENOSPC/);
const efsArgs = execFileSyncFn.mock.calls.map((c) => (c[1] as string[]) ?? []);
expect(efsArgs.some((a) => a.includes("terminate"))).toBe(false);
expect(spawnFn).not.toHaveBeenCalled();
});
it("does not terminate the app if the .app bundle path can't be resolved (malloc mode)", async () => {
// getAppBundlePath (simctl get_app_container) runs BEFORE the destructive terminate,
// exactly like the debug-dir mkdir guard above — so a failed container resolution must
// leave the running app untouched, never killed-without-relaunch. Also pins the
// FailureError code so a reachable user-facing failure stays telemetry-classified.
const prev = process.env.ARGENT_IOS_CAPTURE;
delete process.env.ARGENT_IOS_CAPTURE;
try {
const spawnFn = vi.fn(() => new StartFakeChild());
const execSyncFn = vi.fn(() => "");
const execFileSyncFn = vi.fn((bin: string, args: string[] = []) => {
// resolveAppForLaunch succeeds (valid listapps) so we reach getAppBundlePath...
if (bin === "plutil") return LISTAPPS_JSON;
if (args.includes("listapps")) return "<plist/>";
// ...where get_app_container fails BEFORE any terminate.
if (args.includes("get_app_container")) throw new Error("No such file or directory");
if (args.includes("terminate")) return "";
return "";
});
applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn);
const startNativeProfilerIos = await importStart();
const api = fakeApi();
const err = await startNativeProfilerIos(api, {
device_id: "DEVICE-UDID",
app_process: "MyApp",
malloc_stack_logging: true,
}).then(
() => null,
(e: unknown) => e
);
expect(err).toBeTruthy();
expect(getFailureSignal(err)?.error_code).toBe(
FAILURE_CODES.NATIVE_PROFILER_APP_BUNDLE_PATH_FAILED
);
// Bundle-path resolution failed BEFORE the destructive terminate → app untouched.
const efsArgs = execFileSyncFn.mock.calls.map((c) => (c[1] as string[]) ?? []);
expect(efsArgs.some((a) => a.includes("terminate"))).toBe(false);
expect(spawnFn).not.toHaveBeenCalled();
} finally {
if (prev === undefined) delete process.env.ARGENT_IOS_CAPTURE;
else process.env.ARGENT_IOS_CAPTURE = prev;
}
});
it("rejects an empty resolved .app bundle path before terminating (malloc mode)", async () => {
// simctl returning a blank container path must be refused up front (otherwise the argv
// becomes `xctrace --launch -- ""`), and — like the throw case — before the terminate.
const prev = process.env.ARGENT_IOS_CAPTURE;
delete process.env.ARGENT_IOS_CAPTURE;
try {
const spawnFn = vi.fn(() => new StartFakeChild());
const execSyncFn = vi.fn(() => "");
const execFileSyncFn = vi.fn((bin: string, args: string[] = []) => {
if (bin === "plutil") return LISTAPPS_JSON;
if (args.includes("listapps")) return "<plist/>";
// Whitespace-only container path → empty after the .trim() in getAppBundlePath.
if (args.includes("get_app_container")) return " \n";
if (args.includes("terminate")) return "";
return "";
});
applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn);
const startNativeProfilerIos = await importStart();
const api = fakeApi();
const err = await startNativeProfilerIos(api, {
device_id: "DEVICE-UDID",
app_process: "MyApp",
malloc_stack_logging: true,
}).then(
() => null,
(e: unknown) => e
);
expect(err).toBeTruthy();
expect(getFailureSignal(err)?.error_code).toBe(
FAILURE_CODES.NATIVE_PROFILER_APP_BUNDLE_PATH_FAILED
);
const efsArgs = execFileSyncFn.mock.calls.map((c) => (c[1] as string[]) ?? []);
expect(efsArgs.some((a) => a.includes("terminate"))).toBe(false);
expect(spawnFn).not.toHaveBeenCalled();
} finally {
if (prev === undefined) delete process.env.ARGENT_IOS_CAPTURE;
else process.env.ARGENT_IOS_CAPTURE = prev;
}
});
it("attaches (no launch/env) by default", async () => {
const { spawnFn, execSyncFn, execFileSyncFn } = mockChildProcess();
applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn);
const startNativeProfilerIos = await importStart();
const api = fakeApi();
await startNativeProfilerIos(api, {
device_id: "DEVICE-UDID",
app_process: "MyApp",
});
const [, args] = spawnFn.mock.calls[0] as unknown as [string, string[]];
expect(args).toContain("--attach");
// default mode attaches by host PID (Xcode 26.5 compat); the mock's launchctl yields pid 1
expect(args[args.indexOf("--attach") + 1]).toBe("1");
expect(args).not.toContain("--launch");
expect(args).not.toContain("--env");
// default attach mode never terminates the running app
const efsArgs = execFileSyncFn.mock.calls.map((c) => (c[1] as string[]) ?? []);
expect(efsArgs.some((a) => a.includes("terminate"))).toBe(false);
});
// A degraded Xcode (26.4 and later) is reported by `xcodebuild -version`, which the
// capture-strategy selector reads. The malloc cold launch needs `--device`, which is
// broken on those versions, so the start must be refused UP FRONT — before the running
// app is terminated and before any xctrace spawn.
function mockChildProcessDegraded() {
const spawnFn = vi.fn(() => new StartFakeChild());
const execSyncFn = vi.fn(() => "");
// A degraded Xcode (26.4 and later) as reported by the hardened `xcodebuild -version`
// read (execFileSync) that the capture-strategy selector performs.
const execFileSyncFn = makeExecFileSyncFn({
xcodebuild: "Xcode 26.5\nBuild version 17F42",
});
return { spawnFn, execSyncFn, execFileSyncFn };
}
it("refuses malloc_stack_logging on a degraded Xcode before terminating the app", async () => {
const prev = process.env.ARGENT_IOS_CAPTURE;
delete process.env.ARGENT_IOS_CAPTURE;
// The guard must not first emit the "using the all-processes capture fallback"
// stderr line (that fallback never runs here — the block throws instead), which
// would read as though the fallback is about to run, directly followed by an abort.
const stderrWrites: string[] = [];
const stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation((chunk: string | Uint8Array) => {
stderrWrites.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString());
return true;
});
try {
const { spawnFn, execSyncFn, execFileSyncFn } = mockChildProcessDegraded();
applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn);
const startNativeProfilerIos = await importStart();
const api = fakeApi();
const err = await startNativeProfilerIos(api, {
device_id: "DEVICE-UDID",
app_process: "MyApp",
malloc_stack_logging: true,
}).then(
() => null,
(e: unknown) => e
);
// It must fail, with the degraded-Xcode failure code (telemetry-classified).
expect(err).toBeTruthy();
expect(getFailureSignal(err)?.error_code).toBe(
FAILURE_CODES.NATIVE_PROFILER_MALLOC_DEGRADED_XCODE
);
// And critically: it never touched the app or xctrace — no terminate, no
// bundle-path resolution, no spawn. The app the user had running is intact.
const efsArgs = execFileSyncFn.mock.calls.map((c) => (c[1] as string[]) ?? []);
expect(efsArgs.some((a) => a.includes("terminate"))).toBe(false);
expect(efsArgs.some((a) => a.includes("get_app_container"))).toBe(false);
expect(spawnFn).not.toHaveBeenCalled();
// No misleading "fallback is about to run" log preceded the refusal.
expect(stderrWrites.some((w) => w.includes("capture fallback"))).toBe(false);
} finally {
stderrSpy.mockRestore();
if (prev === undefined) delete process.env.ARGENT_IOS_CAPTURE;
else process.env.ARGENT_IOS_CAPTURE = prev;
}
});
it("ARGENT_IOS_CAPTURE=device forces the malloc cold launch through on a degraded Xcode", async () => {
const prev = process.env.ARGENT_IOS_CAPTURE;
process.env.ARGENT_IOS_CAPTURE = "device";
try {
const { spawnFn, execSyncFn, execFileSyncFn } = mockChildProcessDegraded();
applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn);
const startNativeProfilerIos = await importStart();
const api = fakeApi();
const result = await startNativeProfilerIos(api, {
device_id: "DEVICE-UDID",
app_process: "MyApp",
malloc_stack_logging: true,
});
// The override bypasses the guard, so the cold launch proceeds as usual.
expect(result.status).toBe("recording");
const [, args] = spawnFn.mock.calls[0] as unknown as [string, string[]];
expect(args).toContain("--launch");
expect(args).toContain("--env");
} finally {
if (prev === undefined) delete process.env.ARGENT_IOS_CAPTURE;
else process.env.ARGENT_IOS_CAPTURE = prev;
}
});
it("warns about an unrecognised ARGENT_IOS_CAPTURE in malloc mode instead of dropping it silently", async () => {
// The malloc guard resolves the strategy via the side-effect-free resolver, so a
// typo'd override would previously vanish without a word — unlike the normal record
// flow, which warns. On a healthy Xcode the typo is ignored and malloc still runs,
// but the user must be told their value was dropped (otherwise a later degraded-Xcode
// refusal can even advise "set ARGENT_IOS_CAPTURE=device" while the typo sits ignored).
const prev = process.env.ARGENT_IOS_CAPTURE;
process.env.ARGENT_IOS_CAPTURE = "devise"; // typo for "device"
const stderrWrites: string[] = [];
const stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation((chunk: string | Uint8Array) => {
stderrWrites.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString());
return true;
});
try {
// Default mock leaves `xcodebuild` unmocked → version undetermined → device
// strategy, so the guard proceeds (does not refuse) despite the bad override.
const { spawnFn, execSyncFn, execFileSyncFn } = mockChildProcess();
applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn);
const startNativeProfilerIos = await importStart();
const api = fakeApi();
const result = await startNativeProfilerIos(api, {
device_id: "DEVICE-UDID",
app_process: "MyApp",
malloc_stack_logging: true,
});
// The typo neither blocks the healthy device path nor passes silently.
expect(result.status).toBe("recording");
expect(spawnFn).toHaveBeenCalledTimes(1);
expect(
stderrWrites.some((w) => w.includes('ignoring unrecognised ARGENT_IOS_CAPTURE="devise"'))
).toBe(true);
} finally {
stderrSpy.mockRestore();
if (prev === undefined) delete process.env.ARGENT_IOS_CAPTURE;
else process.env.ARGENT_IOS_CAPTURE = prev;
}
});
it("best-effort relaunches the app when the malloc cold launch fails after terminate", async () => {
const prev = process.env.ARGENT_IOS_CAPTURE;
delete process.env.ARGENT_IOS_CAPTURE;
try {
const spawnFn = vi.fn(() => new StartFakeChild());
// Nothing routes through execSync anymore (xcodebuild/listapps/launchctl are all
// hardened to execFileSync); a bare stub is all that's needed.
const execSyncFn = vi.fn(() => "");
// A non-degraded Xcode, read via the hardened execFileSync `xcodebuild -version`
// (NOT execSync), so the guard genuinely passes and the path reaches the cold
// launch. terminate succeeds (empty return) → the app was running → relaunch armed.
const execFileSyncFn = makeExecFileSyncFn({ xcodebuild: "Xcode 16.4\nBuild version 16F6" });
vi.doMock("child_process", () => ({
spawn: spawnFn,
execSync: execSyncFn,
execFile: vi.fn(),
execFileSync: execFileSyncFn,
}));
vi.doMock("../../src/utils/react-profiler/debug/dump", () => ({
getDebugDir: vi.fn(async () => "/tmp/argent-profiler-cwd"),
}));
vi.doMock("../../src/utils/ios-profiler/notify", () => ({
listenForDarwinNotification: vi.fn(() => {
throw new Error("notifyutil unavailable in tests");
}),
}));
// Force the capture start to fail *after* the app has been terminated.
vi.doMock("../../src/utils/ios-profiler/startup", () => ({
waitForXctraceReady: vi.fn(async () => {
throw new Error("xctrace exited before recording started");
}),
}));
const startNativeProfilerIos = await importStart();
const api = fakeApi();
const err = await startNativeProfilerIos(api, {
device_id: "DEVICE-UDID",
app_process: "MyApp",
malloc_stack_logging: true,
}).then(
() => null,
(e: unknown) => e
);
// The start failed (error surfaced) AND the terminated app was relaunched.
expect(err).toBeTruthy();
const relaunch = execFileSyncFn.mock.calls.find(
(c) => Array.isArray(c[1]) && (c[1] as string[]).includes("launch")
);
expect(relaunch).toBeTruthy();
expect(relaunch![1]).toEqual(["simctl", "launch", "DEVICE-UDID", "com.example.myapp"]);
} finally {
if (prev === undefined) delete process.env.ARGENT_IOS_CAPTURE;
else process.env.ARGENT_IOS_CAPTURE = prev;
}
});
it("does NOT relaunch a not-running named app when the malloc cold launch fails", async () => {
// resolveAppForLaunch accepts an installed-but-not-running app_process, so the
// preceding `simctl terminate` is a no-op. A failed start must then NOT foreground
// an app the user never had open — only an app we actually killed gets restored.
const prev = process.env.ARGENT_IOS_CAPTURE;
delete process.env.ARGENT_IOS_CAPTURE;
try {
const spawnFn = vi.fn(() => new StartFakeChild());
const execSyncFn = vi.fn(() => "");
const execFileSyncFn = vi.fn((bin: string, args: string[] = []) => {
// Non-degraded Xcode (execFileSync `xcodebuild -version`) so the guard passes
// and the path actually reaches app resolution + terminate.
if (bin === "xcodebuild") return "Xcode 16.4\nBuild version 16F6";
// getInstalledApps buffers the listapps plist and runs it through plutil → JSON,
// so resolveAppForLaunch finds the installed-but-not-running MyApp and the path
// REACHES terminate. Returning "" for listapps/plutil would make getInstalledApps
// hit JSON.parse("") and throw SyntaxError before terminate ever ran — which is
// exactly the illusory-coverage bug this test must avoid.
if (bin === "plutil") return LISTAPPS_JSON;
if (args.includes("listapps")) return "<plist/>";
if (args.includes("get_app_container")) return "/Users/x/Library/.../MyApp.app\n";
// Installed but NOT running → terminate fails exactly like real simctl does, so
// mallocRelaunchBundleId is never armed.
if (args.includes("terminate")) throw new Error("found nothing to terminate");
return "";
});
vi.doMock("child_process", () => ({
spawn: spawnFn,
execSync: execSyncFn,
execFile: vi.fn(),
execFileSync: execFileSyncFn,
}));
vi.doMock("../../src/utils/react-profiler/debug/dump", () => ({
getDebugDir: vi.fn(async () => "/tmp/argent-profiler-cwd"),
}));
vi.doMock("../../src/utils/ios-profiler/notify", () => ({
listenForDarwinNotification: vi.fn(() => {
throw new Error("notifyutil unavailable in tests");
}),
}));
// Cold launch fails AFTER the (no-op) terminate.
vi.doMock("../../src/utils/ios-profiler/startup", () => ({
waitForXctraceReady: vi.fn(async () => {
throw new Error("xctrace exited before recording started");
}),
}));
const startNativeProfilerIos = await importStart();
const api = fakeApi();
const err = await startNativeProfilerIos(api, {
device_id: "DEVICE-UDID",
app_process: "MyApp",
malloc_stack_logging: true,
}).then(
() => null,
(e: unknown) => e
);
// The start failure still surfaces — and it is the cold-launch failure, NOT an
// earlier resolution error. (With an empty listapps mock, getInstalledApps used to
// throw "SyntaxError: Unexpected end of JSON input" before terminate ran, so the
// no-op-terminate → no-relaunch scenario named here was never actually exercised.)
expect(err).toBeTruthy();
expect(err).not.toBeInstanceOf(SyntaxError);
// The path genuinely reached the (no-op) terminate for the not-running app...
const efsArgs = execFileSyncFn.mock.calls.map((c) => (c[1] as string[]) ?? []);
expect(efsArgs.some((a) => a.includes("terminate"))).toBe(true);
// ...but no best-effort relaunch fired, because we never actually killed it.
const relaunched = execFileSyncFn.mock.calls.some(
(c) => Array.isArray(c[1]) && (c[1] as string[]).includes("launch")
);
expect(relaunched).toBe(false);
} finally {
if (prev === undefined) delete process.env.ARGENT_IOS_CAPTURE;
else process.env.ARGENT_IOS_CAPTURE = prev;
}
});
it("attributes the malloc refusal to a forced override, not a degraded Xcode, under ARGENT_IOS_CAPTURE=all-processes", async () => {
// On a perfectly healthy Xcode, the only reason the strategy isn't `device` is
// the forced override — so the refusal must say so (and carry a distinct code),
// not blame a --device deadlock that isn't present.
const prev = process.env.ARGENT_IOS_CAPTURE;
process.env.ARGENT_IOS_CAPTURE = "all-processes";
try {
const spawnFn = vi.fn(() => new StartFakeChild());
// ARGENT_IOS_CAPTURE=all-processes short-circuits before any `xcodebuild -version`
// read, so the Xcode version is irrelevant here; execSync is unused (every
// subprocess goes through execFileSync), so a bare stub suffices.
const execSyncFn = vi.fn(() => "");
const execFileSyncFn = makeExecFileSyncFn();
applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn);
const startNativeProfilerIos = await importStart();
const api = fakeApi();
const err = await startNativeProfilerIos(api, {
device_id: "DEVICE-UDID",
app_process: "MyApp",
malloc_stack_logging: true,
}).then(
() => null,
(e: unknown) => e
);
expect(err).toBeTruthy();
// Distinct telemetry code for the forced-override cause, not degraded-Xcode.
expect(getFailureSignal(err)?.error_code).toBe(
FAILURE_CODES.NATIVE_PROFILER_MALLOC_STRATEGY_OVERRIDE
);
const msg = err instanceof Error ? err.message : String(err);
expect(msg).toContain("ARGENT_IOS_CAPTURE");
expect(msg).not.toMatch(/deadlock|26\.4/);
// Refused up front — the healthy app is untouched.
const efsArgs = execFileSyncFn.mock.calls.map((c) => (c[1] as string[]) ?? []);
expect(efsArgs.some((a) => a.includes("terminate"))).toBe(false);
expect(efsArgs.some((a) => a.includes("get_app_container"))).toBe(false);
expect(spawnFn).not.toHaveBeenCalled();
} finally {
if (prev === undefined) delete process.env.ARGENT_IOS_CAPTURE;
else process.env.ARGENT_IOS_CAPTURE = prev;
}
});
it("quotes the operator's literal ARGENT_IOS_CAPTURE value (alias/mixed-case) in the override refusal", async () => {
// parseEnvOverride accepts the `all_processes`/`allprocesses` aliases and lower-cases
// input, so the canonical strategy name ("all-processes") diverges from what the user
// set. The refusal must echo the LITERAL value they configured, not the canonical name,
// otherwise it tells them to fix a variable that reads differently from what they typed.
const prev = process.env.ARGENT_IOS_CAPTURE;
process.env.ARGENT_IOS_CAPTURE = "All_Processes"; // alias + mixed case
try {
const spawnFn = vi.fn(() => new StartFakeChild());
const execSyncFn = vi.fn(() => "");
const execFileSyncFn = makeExecFileSyncFn();
applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn);
const startNativeProfilerIos = await importStart();
const api = fakeApi();
const err = await startNativeProfilerIos(api, {
device_id: "DEVICE-UDID",
app_process: "MyApp",
malloc_stack_logging: true,
}).then(
() => null,
(e: unknown) => e
);
expect(err).toBeTruthy();
expect(getFailureSignal(err)?.error_code).toBe(
FAILURE_CODES.NATIVE_PROFILER_MALLOC_STRATEGY_OVERRIDE
);
const msg = err instanceof Error ? err.message : String(err);
// Echoes the operator's literal value (case preserved, trimmed)...
expect(msg).toContain(`ARGENT_IOS_CAPTURE="All_Processes"`);
// ...while still naming the canonical strategy it resolved to.
expect(msg).toContain(`forces the "all-processes" capture`);
// Regression guard: must NOT quote the canonical name as if it were the env value.
expect(msg).not.toContain(`ARGENT_IOS_CAPTURE="all-processes"`);
// Refused up front — the healthy app is untouched.
const efsArgs = execFileSyncFn.mock.calls.map((c) => (c[1] as string[]) ?? []);
expect(efsArgs.some((a) => a.includes("terminate"))).toBe(false);
expect(spawnFn).not.toHaveBeenCalled();
} finally {
if (prev === undefined) delete process.env.ARGENT_IOS_CAPTURE;
else process.env.ARGENT_IOS_CAPTURE = prev;
}
});
it("throws LAUNCH_APP_NOT_FOUND when a malloc app_process matches no installed user app", async () => {
const prev = process.env.ARGENT_IOS_CAPTURE;
delete process.env.ARGENT_IOS_CAPTURE;
try {
// Default mock leaves `xcodebuild` unmocked → version undetermined → device
// strategy, so the degraded-Xcode guard passes and we reach app resolution.
const { spawnFn, execSyncFn, execFileSyncFn } = mockChildProcess();
applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn);
const startNativeProfilerIos = await importStart();
const api = fakeApi();
const err = await startNativeProfilerIos(api, {
device_id: "DEVICE-UDID",
app_process: "GhostApp", // not present in LISTAPPS_JSON
malloc_stack_logging: true,
}).then(
() => null,
(e: unknown) => e
);
expect(err).toBeTruthy();
expect(getFailureSignal(err)?.error_code).toBe(
FAILURE_CODES.NATIVE_PROFILER_LAUNCH_APP_NOT_FOUND
);
// Resolution failed before any destructive/side-effecting action.
const efsArgs = execFileSyncFn.mock.calls.map((c) => (c[1] as string[]) ?? []);
expect(efsArgs.some((a) => a.includes("terminate"))).toBe(false);
expect(efsArgs.some((a) => a.includes("get_app_container"))).toBe(false);
expect(spawnFn).not.toHaveBeenCalled();
} finally {
if (prev === undefined) delete process.env.ARGENT_IOS_CAPTURE;
else process.env.ARGENT_IOS_CAPTURE = prev;
}
});
it("throws MULTIPLE_RUNNING_USER_APPS in malloc mode when no app_process and several apps run", async () => {
const prev = process.env.ARGENT_IOS_CAPTURE;
delete process.env.ARGENT_IOS_CAPTURE;
try {
const twoApps = JSON.stringify({
"com.example.myapp": {
CFBundleExecutable: "MyApp",
CFBundleIdentifier: "com.example.myapp",
ApplicationType: "User",
},
"com.example.other": {
CFBundleExecutable: "OtherApp",
CFBundleIdentifier: "com.example.other",
ApplicationType: "User",
},
});
const spawnFn = vi.fn(() => new StartFakeChild());
const execSyncFn = vi.fn(() => "");
// getInstalledApps resolves the installed set via execFileSync(listapps) piped
// through plutil; the running set comes from execFileSync(launchctl list). Both the
// two-app plist and the two running PIDs are delivered through the execFileSync mock.
const execFileSyncFn = vi.fn((bin: string, args: string[] = []) => {
if (bin === "plutil") return twoApps;
if (args.includes("listapps")) return "<plist/>";
if (args.includes("launchctl"))
return (
"1\t0\tUIKitApplication:com.example.myapp[a][rb]\n" +
"2\t0\tUIKitApplication:com.example.other[b][rb]\n"
);
if (args.includes("get_app_container")) return "/Users/x/Library/.../MyApp.app\n";
if (args.includes("terminate")) return "";
return "";
});
applyCommonMocks(spawnFn, execSyncFn, execFileSyncFn);
const startNativeProfilerIos = await importStart();
const api = fakeApi();
const err = await startNativeProfilerIos(api, {
device_id: "DEVICE-UDID",
malloc_stack_logging: true, // no app_process → auto-detect → ambiguous
}).then(
() => null,
(e: unknown) => e
);
expect(err).toBeTruthy();
expect(getFailureSignal(err)?.error_code).toBe(
FAILURE_CODES.NATIVE_PROFILER_MULTIPLE_RUNNING_USER_APPS
);
const efsArgs = execFileSyncFn.mock.calls.map((c) => (c[1] as string[]) ?? []);
expect(efsArgs.some((a) => a.includes("terminate"))).toBe(false);
expect(spawnFn).not.toHaveBeenCalled();
} finally {
if (prev === undefined) delete process.env.ARGENT_IOS_CAPTURE;
else process.env.ARGENT_IOS_CAPTURE = prev;
}
});
});