mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
afd722924f
## Problem
Only one flow could be recorded at a time, anywhere on the machine.
Recording state was three module globals in `flow-utils.ts` — harmless
in a per-client process, but the tool-server is a **host-wide singleton
per install bundle**. Every MCP client, every subagent sharing a
parent's connection, every `argent` CLI call and every project on the
machine attaches to the same process, the same `Registry`, and those
same three globals. HTTP requests are served concurrently with no
serialization.
Three failures followed, all reproduced against the pre-change code:
1. **A second recording clobbered the first.** `startRecordingSession`
just overwrote, so the first agent's next `flow-add-step` silently
appended into the *other* agent's flow file — in a different project.
2. **Any agent could finish any agent's recording.**
`flow-finish-recording`'s schema was literally `z.object({})`.
3. **Replay stomped recording.** `resolveFlowFilePath` called
`setActiveProjectRoot`, so a `flow-execute` in project B rebound the
global root mid-recording of project A. Already worked around, locally
only, in `flow-add-step`.
## Approach
Recording state is now a `Map` keyed by the resolved flow file path,
`<project_root>/.argent/flows/<name>.yaml` — the identity of the
artifact being built, so "same key" means "same output file" and two
sessions on one key are a genuine collision rather than an accident of
scoping. `flow-add-step`, `flow-add-echo` and `flow-finish-recording`
each take `name` + `project_root`, so every call is self-contained;
required rather than optional-with-fallback, since a fallback would
succeed while an agent is alone and fail only once a second recording
exists.
`resolveFlowFilePath` is pure, which fixes defect 3 and retires
`FLOW_PROJECT_ROOT_REQUIRED` (the state it guarded can no longer exist).
Rejected alternatives — keying by device udid (flows are deliberately
device-portable, and `flow-add-echo`/`flow-finish-recording` have no
device at all), an opaque `recordingId` (an agent that loses the token
cannot recover), and per-caller identity from the transport
(`ToolContext` carries no caller identity, and subagents share their
parent's MCP connection, so it would fail the exact scenario being
fixed).
### Locking, and why readers needed more than a lock
Every mutation of one flow file runs under a lock keyed by that path —
appends, `flow-start-recording`'s truncate-and-register, and
`flow-finish-recording`'s read-summarize-clear. Keyed by file rather
than owned by the session, because a restart *replaces* the session: a
session-owned lock cannot exclude the operation that supersedes it. Per
file, not global, so two recordings never queue behind each other. An
append whose session was finished, restarted or evicted meanwhile fails
with `FLOW_NO_ACTIVE_RECORDING` rather than writing into a different
take.
The lock serializes writers only, and no reader of a flow YAML can join
it — `flow-execute`'s own load, its `run:` fragment load,
`flow-read-prerequisite`, `flow-add-step`'s sibling-fragment check, and
the `argent` CLI reading from another process entirely, where an
in-process lock cannot reach. Both writers used a plain `fs.writeFile`,
which opens `O_TRUNC`, so a reader could land between the truncate and
the write. That case is silent: `parseFlow("")` returns `{ steps: [] }`
with no error and `summarize` derives `ok` from "no failures", so a
`flow-execute` racing an append reports a **top-level PASS having
replayed zero steps**. Both writes now go through a sibling temp file
and a rename, so a reader sees either the whole old file or the whole
new one — and unlike the lock, that also holds cross-process.
### `stop-all-simulator-servers` gets a `devices` scope
Every agent is told to call this at session end, and unscoped it walked
the whole registry — so agent A wrapping up tore down agent B's devtools
mid-recording, degrading B's flow to brittle coordinate taps. It now
takes an optional `devices` scope and reports `unmatched` ids, so a
mistyped id doesn't read as a clean machine.
Which namespaces count as "owned by a device" turned out to be the
load-bearing part, because nothing cascades to most of them and
`unmatched` turns any omission into an actively wrong diagnosis — a
correct device id reported as a typo. The set now covers `AXService`
(the in-sim ax daemon, spawned `--timeout 3600`; an iOS session that
only ran boot/launch/describe owns this and nothing else),
`ScreenRecordingSession` (an ffmpeg child plus the touch-visualizer
overlay it enabled on the device), `NativeProfilerSession` (an xctrace
child, or an on-device perfetto process and its trace file) and
`JsRuntimeDebugger` (a bound loopback server, the CDP socket to Metro, a
log handle). The debugger URNs interpose the Metro port —
`<ns>:<port>:<deviceId>` — so the matcher understands both shapes,
consuming only the first colon so a wireless adb serial after the port
still compares whole.
`stop-simulator-server` shared none of this and had drifted: it looked
URNs up with an exact, case-sensitive `services.get()`, so a lower-cased
UDID silently no-op'd there while the scoped stop-all reaped it. Both
tools now share one matcher. Their namespace *sets* stay deliberately
different, documented where they are defined — `stop-simulator-server`
is also the documented recovery for a wedged transport, and widening it
to devtools/AX would make a routine retry drop the native-devtools
connection another agent's recording depends on, which is the hazard the
`devices` scope exists to prevent.
## Verification
**End-to-end, three devices, one shared tool-server** (iPhone 16 Pro,
iPhone 17 Pro, Pixel 3a): three interleaved recordings, plus an
unrelated `flow-execute` in a fourth project while all three were live.
Both iOS devices held their own live native-devtools concurrently (two
per-UDID sockets, `connected: true`). Each YAML contained exactly its
own steps in order, taps captured as selectors rather than coordinates,
and neither picked up another's or the third root's path. All three
replayed green — also through the **released 0.17.0 CLI** against the
modified server, which checks wire compatibility. A device-scoped
teardown of sim 1 left sim 2 and the emulator running.
Re-run against the final build:
- Two interleaved recordings across two project roots on one device,
with a **separate process** polling one of the flow files as fast as it
could for the duration. Zero empty or truncated observations; each YAML
held exactly its own steps; both replayed green (6/6 and 5/5). Against
the pre-change in-place write the same reader observes zero-step reads
interleaved among the real ones — the silent-PASS input.
- A scoped teardown of the iPhone reaped `AXService` and
`NativeDevtools` for that UDID only, reported no `unmatched`, and left
the second sim and the emulator running. Lower-casing the UDID matched;
a genuinely bogus id was still reported.
- With a screen recording live on the Pixel 3a, a scoped teardown reaped
`ScreenRecordingSession` and the **ffmpeg child actually exited** (1 →
0). Before the fix it survived to the 180 s cap while the tool called
the correct serial a typo.
The pre-change repros were re-run afterwards to confirm observed
behavior changed, not just the tests.
`npx vitest run` in `packages/tool-server` (295 files, 3154 tests) and
`packages/argent-cli` (280), `tsc --build`, `tsc --noEmit` on both test
projects, eslint and prettier — all green.
## Review notes
Several rounds of review found real defects in this change; each was
reproduced before fixing and re-verified after. Worth knowing when
reading the diff, since the tests exist to pin them:
- `flow-start-recording` truncated the `.yaml` outside any lock, so a
step from the take being discarded landed in the freshly reset file and
was reported as success (26-29 of 200 runs).
- `flow-finish-recording` never took the lock; its `await fs.readFile`
was a yield during which a concurrent append committed, leaving the
reported summary disagreeing with disk (11-16 of 200).
- A failed finish destroyed the recording. Hand-editing the `.yaml`
mid-recording is a documented workflow, so `parseFlow` can legitimately
throw — and the error told the agent to call `flow-start-recording`,
which truncates the very take it would recover.
- `unmatched` reported a device this session had just stopped, because
`disposeService` returns a node to IDLE and keeps it.
- A bare IP claimed every wireless-adb device at that address, since an
adb serial is itself `ip:port`.
- The eviction backstop stamped with `Date.now()`; ties at millisecond
resolution let it drop the session that was just used.
- `requireRecordingSession` ended with "Call flow-start-recording first"
— reached for a key that was finished, superseded or evicted as well as
one never started, and on those branches the file on disk is fully
populated while that call truncates it and reports no `restarted`.
- The atomic write regressed twice on its own terms: its scratch name
was derived from the flow file's basename, which has no length cap, so a
long flow name that appended fine before failed `ENAMETOOLONG`; and the
cleanup guard began after the temp file was created, leaking a scratch
file into a committed directory on any write that failed after opening.
- Two tests passed against implementations they claimed to reject: the
`stop-simulator-server` narrowness guard used a udid that classifies as
*android*, where the iOS-only services it guards can never appear, so
widening the iOS branch kept it green; and the client-mode finish
asserted only the *shape* of `savedTo`, never its content, though that
directive is the only thing that lands the file in client mode.
## Notes for reviewers
- Client/remote persist mode is the least-exercised path, and now has
its own concurrency tests (overlapping appends, and a restart
superseding an in-flight one) — it previously had none. The
`project_root` key is byte-stable across the file boundary (`kind:
"probe"` passes it through unchanged), which is what keeps a remote
recording addressable from `flow-add-step`, which declares no file
input.
- Two spellings of one flow file still mint two sessions — and two
independent locks — over one file, which bypasses every guarantee here.
Two ways in: the root spelled two ways (a symlink, or a case variant on
APFS), and the likelier one, the *name* cased two ways on a
case-insensitive volume. Neither is normalized away because the correct
normalization is the filesystem's: case-folding the key would wrongly
merge two distinct flows on ext4, and resolving symlinks is impossible
in client mode, where the root does not exist on this host. Documented
at `getFlowPath`.
- No recording tool can detect that another agent took its key.
`flow-add-step`, `flow-add-echo` and `flow-finish-recording` all
re-resolve the key on every call, so after a takeover they act on
whichever take is now live and report success: an append lands in the
other agent's file, and a finish finishes and clears it. The only case
that fails loudly is the narrow one the liveness check covers - an
append already IN FLIGHT when the restart lands. There is no caller
identity to key ownership on (see the rejected alternatives above), so
this is spelled out for the agent under "Pick a name unique to your
task" in `argent-create-flow` rather than mechanized.
- Skew hazard in one direction only: a *new* client against an *older*
tool-server. Zod strips the undeclared `name`/`project_root`, and the
old server falls back to its module globals — so concurrent recordings
silently collapse onto one session. The other direction (released 0.17.0
CLI against this server) was verified end-to-end.
- `packages/argent-private/docs/reference.md` still describes
`flow-add-step` as appending to "the current recording", and names two
tool ids that do not exist (`flow-insert-echo`/`flow-run` are file
names; the ids are `flow-add-echo`/`flow-execute`). It is a submodule,
so the fix cannot ride on this branch.
- A restart reported `discardedSteps` from the superseded session's
in-memory flow while it truncates the *file*. Hand-editing the `.yaml`
mid-recording is documented, so the two diverge: four steps wiped, one
reported. It now counts the file, and reports no number at all when that
file cannot be read or parsed - 0 is the answer a genuinely empty take
gives. Client mode still counts from memory (this host cannot see the
client's file), and the description now says which mode it is promising.
- `ChromiumJsRuntimeDebugger` cascades from `ChromiumCdp` and was
therefore already being torn down - silently, while `NetworkInspector`
and `ReactProfilerSession` cascade the same way and were named.
`stopped` is documented as the services that were live and got shut
down, so it is listed now too.
- The client-mode case named for a superseded in-flight append restarted
*between* calls, so the next append re-resolved the key and succeeded -
the guard was never reached, and neutering it left the whole file green.
It now parks an append in its live sub-tool call across the restart.
- `stop-tools.test.ts`' mock handed the tool the live service map where
the real `getSnapshot` copies, so a cascade rewrote the state the sweep
was still reading and the answer depended on map insertion order. The
mock copies and recurses like `_teardown` now, and the cascade case runs
under both orders.
- Prose the code contradicted, each verified against source before
rewriting: Vega owns `JsRuntimeDebugger`/`NetworkInspector` once the
debugger has run (and can match anything at all through an ERROR node);
"nothing here cascades" was denied twice in its own file; an iOS
boot/launch/describe session owns `NativeDevtools` too; `:tcp` is
mintable but unreached in production; the tool-server is a singleton
*per install bundle*, not per machine; `argent flow list` does enumerate
`.argent/flows`; the stop-tools comments narrated a "before" that only
existed on this branch.
Found while verifying, out of scope, worth their own issues: a `tool:
flow-execute` self-reference recurses past both cycle guards (`run:` is
guarded, a nested `flow-execute` reseeds the stack); react-profiler
artifacts collide host-wide on a second-resolution filename;
`stop-metro` is port-scoped and kills another agent's bundler;
`debugger-reload-metro`'s HTTP fallback broadcasts to every attached
client. A tool's `capability` is enforced only by the HTTP layer, not by
`registry.invokeTool`, so a call that reaches the registry another way -
`flow-add-step` takes `command` as a bare string - can resolve a service
the tool declares it does not support, leaving an ERROR node behind
(e.g. `SimulatorServer:<vega serial>`).
---------
Co-authored-by: Hubert Gancarczyk <claude-hubert.gancarczyk@swmansion.com>
1106 lines
47 KiB
TypeScript
1106 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,
|
|
disposed: false,
|
|
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;
|
|
}
|
|
});
|
|
});
|