mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
5d8fc10799
## Why Three flaky or failing tests were diagnosed separately this week, and all three turned out to be one defect wearing different clothes: **a test binding a machine-wide resource it does not own**, so a second concurrent run — another checkout, a CI matrix cell, a developer's own app — perturbs it. Those three are fixed in #707, #709 and #711. Each of those was found by chasing a symptom, and each audit covered only its own resource type. This is the systematic sweep: all **448 test files across 13 packages**, plus the 4 `node --test` scripts. (The sweep itself ran over the 400 files that existed when it started; merging `main` brought the count to 448, and every category below was re-run over the full set — 25 files assign `env.HOME` and none omit `USERPROFILE`, one test file calls `pkill`/`pgrep` (`vega-cli-timeout.test.ts`, scoped out to #711 below), and no `listen()` names a fixed port.) ## Findings | Category | Files checked | Real findings | |---|---|---| | Network endpoints (bind / probe / unix socket) | 448 | 4 | | Filesystem — shared temp namespace (`os.tmpdir()` and literal `/tmp`) | 448 | 8 | | Filesystem — real `$HOME` / `~/.argent` | 448 | 4 (28 files) | | Ambient environment | 448 + all `src` readers | 12 | | Device / emulator / SDK state | 448 | 2 | | Process table (`pgrep` / `pkill` / fixed-pattern sweeps) | 448 | **0** | | In-process globals leaking across files | 13 packages × 13 modes | **0** | Every finding was reproduced with a concrete instrument before being touched, and the instrument re-run after. Representative: - **`stop-tools.test.ts`** ran the real `stop-metro` against port 59999, which `lsof`s and **SIGTERMs whatever it finds**. With a squatter there: `16 passed` → `1 failed`, and the squatter logged `received SIGTERM #1`. Now port 1. - **`bind-failure-telemetry` / `lens-relay-telemetry`** were gated on the real `~/.argent/flags.json`. Enabling the documented `tool-server-event-log` flag: `4 passed` → **`4 failed`**, and records were appended to the developer's real event log. - **LogFileWriter (12 files)** — its constructor `mkdir -p`s `os.homedir()/.argent/tmp`. Instrument: run each of the 16 test files that reference a writer, or a blueprint that builds one, under a scratch `HOME`/`USERPROFILE`, then test `$HOME/.argent/tmp` for existence. 4 created it; the 12 that actually construct a writer now all call `scopeTempHome`, and none creates it. - **`screen-recording.test.ts`** — the device ids are fixtures and `vi.useFakeTimers()` seeds its clock from the real time, so only the millisecond a run starts in separates two runs' `.mp4` paths, and each test deletes the path it derived. Self-concurrent: **2 of 3 rounds failed** → 0 of 4. Scoping `TMPDIR` removes the window rather than narrowing it. - **`DO_NOT_TRACK=1`** → telemetry `282 passed` → **`13 failed`**. **`npm_config_user_agent=pnpm|yarn|bun`** → installer `535 passed` → 4/5/3 failed. **19 tests required a real `adb`** — PATH shadow + empty HOME: `30 passed` → **`19 failed`**. - A fixed `/tmp/argent-nd-<udid8>.sock` **destroyed a concurrent holder's live socket** (proven by inode) in **3** files that run the native-devtools factory. Two of them also fail outright: `native-devtools-app-state` lost **3 of 3** rounds of two concurrent runs on `expected 'unregistered' to be 'connected'`. All three now derive the UDID from `process.pid`. - `flow-visual`, `react-profiler/dump`, `file-inputs`, `http-upload` — shared `/tmp` paths and prefix scans, each reproduced with a squatter. In `http-upload` the collision surfaced *as a timeout*, inside `vi.waitFor`. (`flow-chromium-boot` was fixed on this branch too, but main's #585 rewrote that file afterwards and the merge kept main's copy, so it is byte-identical to `origin/main` here and carries no fix.) ## Clean, with the evidence - **Process table** — an `LD_PRELOAD` exec interposer over a full run (507 execs) plus all 12 other suites. Excluding `vega-cli-timeout.test.ts`, which is #711's and does sweep the table (`pkill -f '^sleep 6910[0-9]$'` at line 134): no `pgrep`/`pkill`/`killall`/fixed-pattern sweep anywhere, and **no test signals a pid it did not spawn**. - **Real device state** — **zero** exec attempts of real `adb` / `simctl` / `emulator` / `avdmanager` / `vega`; no test asserts a device list. That list was too narrow: `workspace-reader-integration` spawned the real `yarn` / `pnpm` / `bun` / `pod` / `eas` / `expo`, which is the second finding in this row. - **Cross-file in-process leakage** — structurally impossible: vitest 4.1.9 resolves `pool: "forks"` / `isolate: true`, confirmed by a scratch experiment (distinct pids, no env leak, module `Map` = 1,1,1). 120 default-mode runs across 12 packages produced 1 failure. - **`scripts/`** — 4 files, 86 tests: `mkdtemp` throughout, no ports, no sweeps, writes nothing. ## Verification Each fix was mutated back and killed by a **named** test under its instrument — e.g. `× reads a plain absolute path`, `× returns null when only the extensionless binary exists`, `× pins both names os.homedir() consults, at the same directory`. Restores verified with `git diff` / `git status`, never a substring grep. **Pinned by an instrument only:** the socket fix for `ios-only-blueprint-gate`, the `~/.argent/tmp` and `~/.argent/flags.json` fixes, the `/tmp` fixture cleanup, the `stop-metro` port change (the test asserts `stopped: false` / `pids: []`, which hold for either port whenever nothing is listening) and the `workspace-reader` `PATH` pin (only `node` is asserted; the other seven probes are not) are not pinned by any suite assertion — reverting them still passes, and only the external squatter / scratch-`HOME` instruments catch them. They are pollution fixes. The `native-devtools-app-state` socket fix is the exception in the other direction: reverting it turns two concurrent runs red. Gates, unpiped and read by exit code: `eslint .` 0, `prettier --check .` 0, `tsc --build` 0, `typecheck:tests` 0. Suite, run as six shards: tool-server **4287 passed, 1 skipped, 0 failed**; argent-cli 462; telemetry 306; argent-tools-client 191; configuration-core 122; argent-mcp 80; argent-installer 555. All other packages and `test:scripts` green, identical to baseline. `lens-tools-platform-gate.test.ts` times out at the 5s default under full-suite load and passes at `--testTimeout=30000` (24.7s, almost all of it transform). It predates this branch and is untouched by it. ## Scope This branch is **test-only** — no `src/` file is touched. Both production defects the sweep turned up are separated out, so each can be reviewed as the user-facing fix it is: `ps` without `-ww` orphaning live tool-servers is #713, and #852 is `ensureToolsServer` respawning on every call when `ARGENT_HOST` is exported — `buildToolsServerEnv` passes the inherited value to the child while the state records the literal `127.0.0.1` the health check then probes. The second surfaced here as eight stranded `fake-tool-server` processes, and pinning the environment in this branch removes that signal, which is why it is filed rather than merely worked around. A third, outside this branch entirely, is #853: ten `tool-server` test servers omit the host on `listen(0)`, binding every interface where twenty-two sibling sites pass `127.0.0.1`. It is hardening, not a live exposure — `createHttpApp`'s Host allow-list answers a LAN-addressed request with 403, and only a raw client forging `Host: 127.0.0.1` reaches the stub registry behind it, inside the ~100 ms one `it()` holds the socket. It is also not a collision risk: ephemeral ports are kernel-assigned and never reissued while in use, which is why the fixed-port audit above is unaffected. All ten predate this branch. Left untouched on purpose: `boot-electron-spawn-error.test.ts`, `chromium-discovery.test.ts` and `vega-cli-timeout.test.ts` — owned by the still-open #709 and #711, and all three still carrying their defects on this base. (#707 has since merged into this base, so `boot-device-hotboot.test.ts` and `vitest.config.ts` carry its fix rather than a defect; `test/setup/clear-argent-env.ts` is its work, and this branch relies on it.) **Not fixed, deliberately** — an adjacent class (test-*order* dependence, not machine-global resources), reproducible if wanted: `link-config.test.ts` (deterministic: `-t "returns null when required field"` → 4 failed), `network/network-integration.test.ts` and `android-perfetto/dispatch.test.ts` (fail under `--sequence.shuffle` seeds). **Also observed, not addressed:** one green tool-server run leaves **56** entries behind in a scratch `TMPDIR` — **30** in the `argent-screenshot-diff*` family, 4 `argent-events-*`, 3 `argent-flow-crop-*`, 3 `argent-tar-upload-*`, 2 `argent-flow-diff-*`, 2 `argent-file-input-*`, and 12 assorted singletons (`ast-index-*`, `component-source-*`, `argent-profiler-cwd`, `argent-chromium-media`, …). Measured per run against a scoped temp dir, so it ranks the contributors rather than counting one machine's accumulated history — screenshot-diff, not flow-crop, is the dominant one. A separate leak. That instrument is blind to a *literal* path, which is how it missed the two `/tmp/native-profiler-*-report.md` writers the fourth pass found. Re-run across all 13 packages, `argent-mcp` is the only one outside `tool-server` that leaves anything — two entries per run, filed as #854. ## Second pass A review of this branch re-ran every category above with its own instrument rather than reading the diff, and found the sweep had pinned four resources in one file while leaving a sibling that does the same thing live. Fixed in `4289e3d8f` and `8164955b4`; the table and bullets above are the corrected totals. - **`tool-server-event-log`** was mocked off in 2 of the 4 files that call the real `start()`. With the flag on, `startup-telemetry` and `tool-fail-invalid-params-emission` failed 3 tests in `attachRegistryEventLogger` and truncated the real `~/.argent/tool-server-events.jsonl` to 0 bytes first. `ARGENT_EVENT_LOG` cannot redirect that: `test/setup/clear-argent-env.ts` strips every `ARGENT_*` var before the module graph loads. - **`scopeTempHome`** reached 8 files; 4 more built a real `LogFileWriter` through the JS-runtime-debugger blueprint. - **`os.homedir()` on Windows** reads `USERPROFILE`, not `HOME`. `android-binary-windows` and `adb-resolve-avd-path` pinned only `HOME`, and both run on a real windows-latest host. Proven by running the resolver under a `node:os` mock that reproduces the Windows lookup: with `HOME` alone the stock Studio SDK under the profile resolves; with both pinned it misses. `update-checker` had the same gap. - **`getConsentState`** honours a falsy `ARGENT_TELEMETRY` at the same precedence as `DO_NOT_TRACK`, ahead of the config override the suite writes. Only `DO_NOT_TRACK` was pinned: `ARGENT_TELEMETRY=0` gave `17 failed | 6 passed`. - **`workspace-reader-integration`** let the version probe spawn the real package managers off `PATH`; their shims self-install, so the file pulled **~44 MB** into `~/.cache/node/corepack` and `~/Library/Caches/eas-cli`. `PATH` is now a directory holding only `node`, the one version the test asserts — 44 MB → 4 KB. - **`argent-mcp`** snapshotted `ARGENT_AUTO_SCREENSHOT_DELAY_MS` without clearing it, and that package has no `ARGENT_*`-stripping setup file, so 3 tests asserted the developer's exported value. Smaller items in the same commits: the `/tmp` socket fixtures now remove the directories they create per run; `http-upload`'s pre-scan snapshot is gone, since scoping `TMPDIR` to a fresh directory made it permanently empty and its filter inert, as its two siblings already did; and two `detectProjectPackageManager` assertions that enumerated the entire return union are pinned to a single value. The confirmation pass also caught a defect the first round of fixes introduced: pinning `HOME`/`USERPROFILE` to the fixture root in `android-binary-windows` let `<home>/Android/Sdk` shadow the `%LOCALAPPDATA%` root the two positive tests are named after, so deleting `roots.push(join(localAppData, …))` from the resolver left all three green. Home has its own directory in `6aee3ee7a`, and that mutation now fails both tests. A third pass, running the nearest-twin move repo-wide, found the `os.homedir()`-on-Windows gap in one more package: eight `argent-tools-client` test files redirect only `HOME`, and four of them recursively delete `STATE_DIR` — two before each test, two after — which on Windows is the developer's real `%USERPROFILE%\.argent`. Fixed in `139447d4a`, together with the same gap in `avd-snapshot`. Nothing runs that package on Windows CI today, so no current run fires it. ## Fourth pass A fourth pass audited the three fix commits and re-ran the sweep with two instruments the earlier ones did not have. Both holes were in the *measurement*, not the diff — which is why three passes missed what they found. Fixed in `a67c43f8d`. - **`os.tmpdir()` does not read the same variable on every platform.** POSIX takes `TMPDIR`, `TMP`, `TEMP` in that order; Windows takes `TEMP`, then `TMP`, and never looks at `TMPDIR`. The five pins this branch added set `TMPDIR` alone, so on Windows production still materialized into the machine-wide `%TEMP%` while the test scanned a scratch directory nothing ever wrote to — and `file-inputs`, `http-upload` and `flow-visual` each end in a leak assertion over that listing, which then passes whatever the code does. Against `origin/main`, which diffed a before/after listing of the real tmpdir, that is a regression rather than a wash. `redirectTmpdir` (`test/helpers/tmpdir-env.ts`) sets all three and returns the restore; the **7** files that scope a tmpdir use it, including the two `android-perfetto` ones that predate this branch. `test/tmpdir-env.test.ts` pins both lookup chains — reverting the helper to `TMPDIR` alone fails two of its three tests. All three leak assertions were re-confirmed live on POSIX by mutating the production cleanup each one covers. - **A hardcoded absolute path is invisible to a `TMPDIR`-scoped instrument.** `session-correctness` and `ios-instruments/analyze-freshness` passed a literal `/tmp/native-profiler-{x,ios}.trace` as the session's trace file, and the renderer writes its report beside it (`deriveReportPath` = `dirname(traceFile)/<base>-report.md`). Every run left `native-profiler-x-report.md` and `native-profiler-ios-report.md` in the shared `/tmp`; nothing removed them, and two concurrent runs write the same names. Reproduced with `TMPDIR` pointed at a scratch dir — the files still landed in `/tmp`, which is exactly why the `os.tmpdir()` row and the leftover census below both read clean here. Both tests now own the directory. - **A prefix-scanned env var is invisible to a grep for variable names.** `secretSources` enumerates `process.env` by the `ARGENT_SECRET_` prefix and ranks it *above* every file the test writes, so `secrets-command.test.ts` — which already pins `HOME`, `USERPROFILE` and cwd — still read the developer's shell. `ARGENT_SECRET_FOO=bar`, argent's own documented way to supply a secret: `6 passed` → **`3 failed | 3 passed`**, deterministic. `argent-cli` has no `ARGENT_*`-stripping setup file, so the prefix is cleared per test. Its sibling `configuration-core/tests/secrets.test.ts` was already immune — it threads an explicit `env: NO_ENV` into every call. - **`android-binary` and `vega-cli-fallback`** were the last two files redirecting `HOME` without `USERPROFILE` — the former the direct twin of `android-binary-windows`, in the same directory, driving the same resolver. Under a `node:os` mock reproducing the Windows lookup with an SDK planted in the profile, it loses **5 of 11** tests. No test file in the repo redirects `HOME` alone any more. (`6fbb66ce1` later skipped both of this file's describes on win32 — its fixtures are extensionless and the win32 resolver only takes `.exe` — so its own `USERPROFILE` pin is now symmetry with the sibling files that do run there, not a live guard.) Two smaller items in the same commit. `native-devtools-socket-bind`'s ENOENT fixture still rooted at `os.tmpdir()` while the comment three screens above explains why the others do not: with a 142-character ambient `$TMPDIR` that path exceeds `sun_path` and `listen` rejects it for *that* reason, which the assertions cannot tell from the missing-directory one they were written for. And `<home>\AppData\Local\Android\Sdk` — the resolver's fallback for a GUI-spawned server that never inherited `%LOCALAPPDATA%` — could be deleted with the whole suite green; it now has a test, and deleting either win32 root fails exactly the tests named for it. ## Fifth pass A fifth pass took the platform-branch shape the fourth pass exposed and ran it as its own move: *for every function that branches on `process.platform`, does the test's guard cover every arm, or only the one the author's machine takes?* Fixed in `e08b466b6`. - **`init-stale-config.test.ts` deletes the developer's real VS Code MCP config on Windows.** The file mocks `os.homedir()` and says it does so "so hidden-scope probes (`~/.claude.json`, VS Code user profile) never touch the real home directory". `vscodeUserDirs()` branches `darwin → homedir()/Library/Application Support`, `win32 → %APPDATA%`, `else → homedir()/.config` — the win32 arm never calls `homedir()`, so the mock is inert there, and the test's own `userMcpJsonPath()` mirrors the same branch. Both user-profile tests then write a one-entry fixture over `%APPDATA%\Code\User\mcp.json` with a whole-file `writeFileSync`, and assert that `remove()` deleted it. Reproduced by forcing `process.platform` to `win32` with a two-server `mcp.json` planted at a probe `%APPDATA%`: **`3 passed`, and the file and its directory were gone.** With `APPDATA` pinned, the whole file passes under the same instrument — 31 tests — and the planted config is byte-identical. - **`argent-tools-client` hands its ambient `ARGENT_*` to a real spawned child.** The launcher spawns an actual tool-server, and `buildToolsServerEnv` overrides only the keys it is given — everything else rides in on `...baseEnv`. `ARGENT_HOST` is the one that bites: the fixture binds the host it names while the launcher records and health-checks `127.0.0.1`, so reuse misses and a second server is spawned. With six overrides exported: **`10 failed | 181 passed`, and eight stray `fake-tool-server` processes still holding TCP listen sockets after the run.** With the prefix stripped: `191 passed`, none surviving. The package now carries the same setup file `tool-server` has, for the same reason — and it is the second of the two packages whose missing `ARGENT_*` strip this review found. ## Later passes A sixth pass audited the fix commits themselves and bounded the SDK probe (`6fbb66ce1`); a seventh ran the moves against the diff one more time and turned up the two below, plus the `listen(0)` defect filed as #853. - **The `PATH` pin made `workspace-reader-integration` POSIX-only without the guard its siblings got.** The shim it puts on `PATH` is an extensionless `node`, and `symlink()` to a file needs an elevated process or Developer Mode on Windows, so `beforeAll` throws `EPERM` there before any assertion runs; a copy in place of the symlink would not rescue it either, since a bare command name resolves through `PATHEXT`. This sweep already skips `android-binary` and `native-devtools-socket-bind` on win32 for exactly this class, and the Windows job runs a curated list of seven `tool-server` files that excludes this one, so nothing was catching it. Fixed in `095d75bd8` — verified POSIX `2 passed`, and under a forced win32 platform `2 skipped` with the top-level `beforeAll` never entered. - **A rationale that rested on a case the file excludes.** `android-binary`'s header justified pinning `USERPROFILE` by what `os.homedir()` reads on Windows, two sentences before noting that both describes skip on win32. Fixed in `f37b3605e`; the variable stays in the snapshot list, the justification no longer claims a platform this file never reaches. A seventh pass also re-ran the sweep against a hostile machine: every feature flag on in a scratch `~/.argent/flags.json`, no `adb` on `PATH`, and a `TMPDIR` long enough to overrun the 104-byte `sun_path` limit. It found one resource this branch had not closed. - **A red `launcher-version-gate` run strands a real tool-server.** `ensureToolsServer` spawns a fixture server, but its pid only reached the reaper after three or four assertions on the state it returned; any of those throwing left the pid unrecorded, and the same `afterEach` then deleted `STATE_DIR`, the only other record of what to kill. **Eight such processes were alive on the development machine**, spread from 14:33 to 18:23, every one from an `argent-version-gate-test-*` fixture and none from any other family — each still holding a TCP listen socket with its fixture directory long gone. Forcing one assertion to fail took the `tool-server.cjs start` count from 10 to 11. The nearest twin, `launcher-duplicate-spawn`, guards this twice: it records the pid straight after the null check and sets `FAKE_TTL_MS` so an escaped server self-exits. Fixed in `26c2c60ec` with both, the TTL also covering the window between the spawn and the state read where no pid exists yet — the same forced failure now leaves **zero** strays. An eighth pass audited the seventh's own fixes, and found the version-gate fix had been applied to one file when the defect was a property of four. - **Three more files strand a real tool-server on a red run.** `launcher-spawn` and `launcher-exit` set `FAKE_TTL_MS` nowhere; `launcher-duplicate-spawn` — the file `26c2c60ec` cites as the model — armed it inside one test's `try/finally`, so eight of its nine spawns ran without it and the `finally` disarmed it for the remainder of that test. Reproduced per file by forcing a failure before the pid is recorded: a detached node process survives, reparented to init, holding a listening socket, with its `TEST_HOME` removed and its state record already cleared. Fixed in `3bb69ded6` by moving the net to a file-wide `beforeEach` in all three. Verified end to end — the child carries `FAKE_TTL_MS=60000`, and a stranded server spawned at 21:28:36 exited by itself at 21:29:36 with nothing signalling it; three consecutive green runs stay at 191 passed. - **A 29th file writes the real `~/.argent`.** `boot-electron-spawn-error`'s success-path test completes a boot, and a completed boot calls `trackChromiumPort`, persisting to `os.homedir()/.argent/chromium-cdp-ports.json`. The real file on the development machine had accumulated **106 dead ephemeral ports**. Its two siblings in the same directory already mock `chromium-discovery` for exactly this reason; this was the third, with no mock. Fixed in `3306b6f40`. - **`scopeTempHome` was itself unpinned.** Deleting its `USERPROFILE` line — the Windows half, the same property the sibling helper's test pins — left all twelve consumers green at 48 passed. `3306b6f40` adds the missing test; it kills that mutation plus dropping the `HOME` pin and replacing the per-test `mkdtemp` with one shared directory. Two findings from that pass were refuted on verification and are recorded rather than acted on: the tool-server tmpdir leftovers (mechanism real, but they are the separate leak this body already scopes out) and a claim that `26c2c60ec`'s message overstated its twin. Four comments were also corrected against their code: `deriveReportPath` joins `<base>-report.md` *inside* the trace's directory rather than appending to the directory's name, which is what makes the `rm(traceDir)` added in `a67c43f8d` cover it; two `android-perfetto` comments still named `TMPDIR` as the mechanism after the switch to `redirectTmpdir`; and `android-binary`'s header claimed the home pin lets its two positive tests resolve "on either platform", though its fixtures are extensionless and the win32 resolver only accepts `.exe`.
529 lines
23 KiB
TypeScript
529 lines
23 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import * as net from "node:net";
|
|
import type { DeviceInfo } from "@argent/registry";
|
|
|
|
// `restart_required` is derived from a measurement: what the running process
|
|
// was actually launched with, and whether this service's listener existed at the
|
|
// time. That is the difference between "restart-app fixes this" and "restarting
|
|
// the app is a loop".
|
|
|
|
const probe = vi.hoisted(() => ({
|
|
launchctlList: "",
|
|
psOutput: "",
|
|
psFails: false,
|
|
/** `launchctl setenv/getenv` calls seen — i.e. the launchd env being re-applied. */
|
|
envOps: 0,
|
|
/** Socket path to dial (as the injected dylib does) while `ps` is in flight. */
|
|
dialDuringPs: null as string | null,
|
|
/** The exact `ps` invocation, so the argv the derivation depends on is pinned. */
|
|
psInvocation: null as { args: string[]; opts: Record<string, unknown> } | null,
|
|
/** Set when `inspectRunningApp`'s own `launchctl list` should reject. */
|
|
launchctlFails: false,
|
|
/**
|
|
* Fake-clock ms the next launchd env re-apply costs, charged once so the bill
|
|
* does not depend on how many round-trips it makes. Simulated device work is
|
|
* free by default, which is what leaves the ordering below unmeasurable.
|
|
*/
|
|
envClockCostMs: 0,
|
|
/** Fake-clock instant the process exec'd, for a `ps` age read at probe time. */
|
|
psExecAt: null as number | null,
|
|
}));
|
|
|
|
vi.mock("@argent/native-devtools-ios", () => ({
|
|
bootstrapDylibPath: () => "/fake/dylibs/libArgentInjectionBootstrap.dylib",
|
|
bootstrapDylibPathTcp: () => "/fake/dylibs/tcp/libArgentInjectionBootstrap.dylib",
|
|
bootstrapDylibPathTvos: () => "/fake/dylibs/tvos/libArgentInjectionBootstrap.dylib",
|
|
tcpInjectionDylibs: () => [],
|
|
axServiceBinaryPath: () => "/fake/ax-service",
|
|
axServiceBinaryPathTcp: () => "/fake/ax-service-tcp",
|
|
}));
|
|
|
|
type ExecCb = (err: Error | null, out: { stdout: string; stderr: string }) => void;
|
|
|
|
vi.mock("node:child_process", async () => {
|
|
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
|
return {
|
|
...actual,
|
|
execFile: (cmd: string, args: readonly string[], opts: unknown, cb?: ExecCb) => {
|
|
const callback = (typeof opts === "function" ? opts : cb!) as ExecCb;
|
|
const argv = args.join(" ");
|
|
if (/\bps$/.test(cmd)) {
|
|
probe.psInvocation = { args: [...args], opts: (opts ?? {}) as Record<string, unknown> };
|
|
if (probe.psFails) {
|
|
callback(new Error("ps: no such process"), { stdout: "", stderr: "" });
|
|
return;
|
|
}
|
|
if (probe.dialDuringPs) {
|
|
const path = probe.dialDuringPs;
|
|
probe.dialDuringPs = null;
|
|
const sock = net.createConnection(path, () => {
|
|
sock.write(JSON.stringify({ type: "Control", payload: { bundleId: BUNDLE } }) + "\n");
|
|
// Let the server's readline consume the handshake before answering.
|
|
setTimeout(() => callback(null, { stdout: probe.psOutput, stderr: "" }), 50);
|
|
});
|
|
return;
|
|
}
|
|
if (probe.psExecAt !== null) {
|
|
// `ps` reads the age at the instant it runs. A literal would carry the
|
|
// age the test *set*, which is exactly the reading whose timing is
|
|
// under test.
|
|
const execAt = probe.psExecAt;
|
|
// Consumed like `dialDuringPs`, so it cannot outlive its test and
|
|
// silently override a later `psOutput` fixture — this branch is
|
|
// checked first, so a leak would decide the age of every case after it.
|
|
probe.psExecAt = null;
|
|
callback(null, {
|
|
stdout: psLine(formatEtime(Date.now() - execAt), INJECTED_ENV),
|
|
stderr: "",
|
|
});
|
|
return;
|
|
}
|
|
callback(null, { stdout: probe.psOutput, stderr: "" });
|
|
return;
|
|
}
|
|
if (argv.includes("launchctl list")) {
|
|
if (probe.launchctlFails) {
|
|
callback(new Error("Invalid device: UDID"), { stdout: "", stderr: "" });
|
|
return;
|
|
}
|
|
callback(null, { stdout: probe.launchctlList, stderr: "" });
|
|
return;
|
|
}
|
|
if (argv.includes("launchctl setenv") || argv.includes("launchctl getenv")) {
|
|
probe.envOps += 1;
|
|
if (probe.envClockCostMs > 0) {
|
|
vi.setSystemTime(Date.now() + probe.envClockCostMs);
|
|
probe.envClockCostMs = 0;
|
|
}
|
|
callback(null, { stdout: "", stderr: "" });
|
|
return;
|
|
}
|
|
if (argv.includes("simctl list")) {
|
|
callback(null, { stdout: JSON.stringify({ devices: {} }), stderr: "" });
|
|
return;
|
|
}
|
|
callback(null, { stdout: "", stderr: "" });
|
|
},
|
|
};
|
|
});
|
|
|
|
import {
|
|
nativeDevtoolsBlueprint,
|
|
NATIVE_DEVTOOLS_CONNECT_BUDGET_MS,
|
|
type NativeDevtoolsApi,
|
|
} from "../src/blueprints/native-devtools";
|
|
import { NATIVE_READY_TIMEOUT_MS } from "../src/tools/flows/flow-run";
|
|
import { parsePsElapsedSeconds, processCarriesInjection } from "../src/utils/ios-host";
|
|
import { processScopedUdid } from "./helpers/process-scoped-udid";
|
|
|
|
const UDID = processScopedUdid("-1111-2222-3333-444444444444");
|
|
const SOCKET = `/tmp/argent-nd-${UDID.slice(0, 8)}.sock`;
|
|
const BUNDLE = "com.example.app";
|
|
const PID = 4242;
|
|
|
|
const device: DeviceInfo = { id: UDID, platform: "ios", kind: "simulator" };
|
|
|
|
/** One `launchctl list` row in the real `<pid>\t<status>\t<label>` shape. */
|
|
function runningRow(pid: number | "-" = PID, bundleId = BUNDLE): string {
|
|
return `${pid}\t0\tUIKitApplication:${bundleId}[dffa][rb-legacy]\n`;
|
|
}
|
|
|
|
/** `ps eww -p <pid> -o etime=,command=` output: age, argv, then the launch env. */
|
|
function psLine(etime: string, env: string): string {
|
|
return `${etime} /Devices/${UDID}/Bluesky.app/Bluesky ${env}\n`;
|
|
}
|
|
|
|
/** Render an age as `ps -o etime` does, to the whole second it resolves. */
|
|
function formatEtime(ageMs: number): string {
|
|
const total = Math.floor(ageMs / 1000);
|
|
return `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`;
|
|
}
|
|
|
|
const INJECTED_ENV =
|
|
`NATIVE_DEVTOOLS_IOS_CDP_SOCKET=${SOCKET} ` +
|
|
"DYLD_INSERT_LIBRARIES=/fake/dylibs/libArgentInjectionBootstrap.dylib";
|
|
|
|
async function stateFor(options: {
|
|
/** Wall-clock the listener has been up when the state is read. */
|
|
listenerAgeMs?: number;
|
|
}): Promise<string> {
|
|
const instance = await nativeDevtoolsBlueprint.factory({}, device, { device });
|
|
try {
|
|
if (options.listenerAgeMs !== undefined) {
|
|
vi.setSystemTime(Date.now() + options.listenerAgeMs);
|
|
}
|
|
return await (instance.api as NativeDevtoolsApi).appConnectionState(BUNDLE);
|
|
} finally {
|
|
await instance.dispose();
|
|
}
|
|
}
|
|
|
|
describe("parsePsElapsedSeconds", () => {
|
|
// `ps -o etime` drops leading units when they are zero, so a day-old app
|
|
// renders in a shape the common case never exercises. Reading `01-02:03:04` as
|
|
// anything smaller makes an ancient process look younger than the listener and
|
|
// flips it to `unregistered`.
|
|
it.each([
|
|
["00:45", 45],
|
|
["12:30", 750],
|
|
["01:00:00", 3600],
|
|
["2-03:04:05", 183845],
|
|
])("reads %s as %i seconds", (etime, seconds) => {
|
|
expect(parsePsElapsedSeconds(etime)).toBe(seconds);
|
|
});
|
|
|
|
it("returns null for output it cannot read rather than guessing an age", () => {
|
|
for (const junk of ["", "-", "ps: no such process", "45"]) {
|
|
expect(parsePsElapsedSeconds(junk)).toBeNull();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("processCarriesInjection", () => {
|
|
const unix = { transport: "unix", socketPath: SOCKET } as const;
|
|
|
|
it("requires both the bootstrap dylib and this exact endpoint", () => {
|
|
expect(processCarriesInjection(INJECTED_ENV, unix)).toBe(true);
|
|
expect(processCarriesInjection(`NATIVE_DEVTOOLS_IOS_CDP_SOCKET=${SOCKET}`, unix)).toBe(false);
|
|
expect(
|
|
processCarriesInjection("DYLD_INSERT_LIBRARIES=/x/libArgentInjectionBootstrap.dylib", unix)
|
|
).toBe(false);
|
|
});
|
|
|
|
it("accepts the legacy pre-rename bootstrap name", () => {
|
|
expect(
|
|
processCarriesInjection(
|
|
`NATIVE_DEVTOOLS_IOS_CDP_SOCKET=${SOCKET} DYLD_INSERT_LIBRARIES=/x/libInjectionBootstrap.dylib`,
|
|
unix
|
|
)
|
|
).toBe(true);
|
|
});
|
|
|
|
it("does not accept an endpoint that merely starts the same way", () => {
|
|
// A whole-token match, not a substring one: `<our socket>.old` shares a
|
|
// prefix with our path but is another run's socket, and taking it as ours
|
|
// calls a genuinely relaunchable process unregistered.
|
|
const env =
|
|
`NATIVE_DEVTOOLS_IOS_CDP_SOCKET=${SOCKET}.old ` +
|
|
"DYLD_INSERT_LIBRARIES=/x/libArgentInjectionBootstrap.dylib";
|
|
expect(processCarriesInjection(env, unix)).toBe(false);
|
|
});
|
|
|
|
it("matches the port for a TCP endpoint", () => {
|
|
const env =
|
|
"NATIVE_DEVTOOLS_IOS_CDP_PORT=51234 DYLD_INSERT_LIBRARIES=/x/libArgentInjectionBootstrap.dylib";
|
|
expect(processCarriesInjection(env, { transport: "tcp", port: 51234 })).toBe(true);
|
|
expect(processCarriesInjection(env, { transport: "tcp", port: 51235 })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("appConnectionState measures the running process", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers({ toFake: ["Date"] });
|
|
probe.launchctlList = runningRow();
|
|
probe.psOutput = psLine("10:00", INJECTED_ENV);
|
|
probe.psFails = false;
|
|
probe.dialDuringPs = null;
|
|
probe.psInvocation = null;
|
|
probe.launchctlFails = false;
|
|
probe.envClockCostMs = 0;
|
|
probe.psExecAt = null;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("reports not_running when no UIKit job backs the bundle", async () => {
|
|
probe.launchctlList = runningRow(PID, "com.other.app");
|
|
|
|
await expect(stateFor({ listenerAgeMs: 60_000 })).resolves.toBe("not_running");
|
|
});
|
|
|
|
it("reports stale_process when the process carries no bootstrap dylib", async () => {
|
|
probe.psOutput = psLine("00:30", `NATIVE_DEVTOOLS_IOS_CDP_SOCKET=${SOCKET}`);
|
|
|
|
await expect(stateFor({ listenerAgeMs: 600_000 })).resolves.toBe("stale_process");
|
|
});
|
|
|
|
it("reports stale_process when the process points at another run's endpoint", async () => {
|
|
probe.psOutput = psLine(
|
|
"00:30",
|
|
"NATIVE_DEVTOOLS_IOS_CDP_SOCKET=/tmp/argent-nd-BBBBBBBB.sock " +
|
|
"DYLD_INSERT_LIBRARIES=/fake/dylibs/libArgentInjectionBootstrap.dylib"
|
|
);
|
|
|
|
await expect(stateFor({ listenerAgeMs: 600_000 })).resolves.toBe("stale_process");
|
|
});
|
|
|
|
it("reports stale_process for an injected process older than this listener", async () => {
|
|
// A tool-server restart rebinds the same per-udid path to a new inode, so an
|
|
// app that predates it dialed a socket nobody holds — relaunching re-dials.
|
|
probe.psOutput = psLine("01:00:00", INJECTED_ENV);
|
|
|
|
await expect(stateFor({ listenerAgeMs: 30_000 })).resolves.toBe("stale_process");
|
|
});
|
|
|
|
it("reports unregistered for an injected process launched into this listener", async () => {
|
|
probe.psOutput = psLine("00:30", INJECTED_ENV);
|
|
|
|
await expect(stateFor({ listenerAgeMs: 600_000 })).resolves.toBe("unregistered");
|
|
});
|
|
|
|
// Not `indeterminate`: the process WAS inspected, and is injected against this
|
|
// endpoint. Collapsing the two loses the only remedy that works — waiting —
|
|
// for one that resets the very age this verdict reads.
|
|
it("reports connecting while a just-launched process is still dialing", async () => {
|
|
probe.psOutput = psLine("00:01", INJECTED_ENV);
|
|
|
|
await expect(stateFor({ listenerAgeMs: 600_000 })).resolves.toBe("connecting");
|
|
});
|
|
|
|
// `connecting` and `indeterminate` are both "no verdict yet", so nothing else
|
|
// here separates them: without this pair, returning `indeterminate` from the
|
|
// grace branch passes, and the agent is told to relaunch a process whose
|
|
// relaunch is what put it there.
|
|
it("keeps connecting distinct from a process it genuinely could not read", async () => {
|
|
probe.psFails = true;
|
|
|
|
await expect(stateFor({ listenerAgeMs: 600_000 })).resolves.toBe("indeterminate");
|
|
|
|
probe.psFails = false;
|
|
probe.psOutput = psLine("00:01", INJECTED_ENV);
|
|
|
|
await expect(stateFor({ listenerAgeMs: 600_000 })).resolves.toBe("connecting");
|
|
});
|
|
|
|
// Pins the connect budget from above. Every other injected fixture sits clear
|
|
// of it, so the term could grow to any of them and stay green while swallowing
|
|
// real `unregistered` verdicts — the tool-server escape is withheld until a
|
|
// process is older than the budget, so upward creep withholds it for longer.
|
|
it("stops calling a process connecting one second past the connect budget", async () => {
|
|
probe.psOutput = psLine("00:16", INJECTED_ENV);
|
|
|
|
await expect(stateFor({ listenerAgeMs: 600_000 })).resolves.toBe("unregistered");
|
|
});
|
|
|
|
it("stops calling a process connecting the instant the connect budget elapses", async () => {
|
|
// The 16 s fixture above leaves a whole second the budget could grow into
|
|
// unnoticed. A process exactly at the budget is the first whose silence
|
|
// counts as evidence — `processAgeMs < BUDGET` is false at equality — so any
|
|
// upward creep flips this one to `connecting`.
|
|
probe.psOutput = psLine("00:15", INJECTED_ENV);
|
|
|
|
await expect(stateFor({ listenerAgeMs: 600_000 })).resolves.toBe("unregistered");
|
|
});
|
|
|
|
// The budget is the window in which "wait and retry" is the answer. Shorter
|
|
// than the flow gate's, and an app the gate is still waiting out gets the
|
|
// tool-server restart from every other surface instead — the two answer one
|
|
// question and a literal in either place could drift from the other.
|
|
it("gives a dial the same budget the flow launch gate waits out", async () => {
|
|
expect(NATIVE_READY_TIMEOUT_MS).toBe(NATIVE_DEVTOOLS_CONNECT_BUDGET_MS);
|
|
|
|
// A cold start still dialing at 14 s: inside both, so it waits.
|
|
probe.psOutput = psLine("00:14", INJECTED_ENV);
|
|
|
|
await expect(stateFor({ listenerAgeMs: 600_000 })).resolves.toBe("connecting");
|
|
});
|
|
|
|
it("reports indeterminate when the process table cannot be read", async () => {
|
|
probe.psFails = true;
|
|
|
|
await expect(stateFor({ listenerAgeMs: 600_000 })).resolves.toBe("indeterminate");
|
|
});
|
|
|
|
it("reports indeterminate when ps answers but its age column does not parse", async () => {
|
|
// `ps` exiting 0 with an unreadable etime must not become a *measurement*:
|
|
// substituting any age (0 being the tempting one) lets an uninspectable
|
|
// process be judged against the listener and reported as a definite
|
|
// `stale_process`. The env below carries no bootstrap dylib precisely so a
|
|
// fabricated age would show up as that stronger claim.
|
|
probe.psOutput = psLine("not-an-etime", `NATIVE_DEVTOOLS_IOS_CDP_SOCKET=${SOCKET}`);
|
|
|
|
await expect(stateFor({ listenerAgeMs: 600_000 })).resolves.toBe("indeterminate");
|
|
});
|
|
|
|
it("reports indeterminate when the ps line has no column separator", async () => {
|
|
// Age and environment come out of one line split at its first whitespace, so
|
|
// a line without any carries no measurement. Splitting regardless hands the
|
|
// age parser a truncated token it reads happily (`10:00` sliced to `10:0` →
|
|
// 600 s) and calls the remainder an environment — a definite `stale_process`
|
|
// conjured out of a line that said nothing.
|
|
probe.psOutput = "10:00\n";
|
|
|
|
await expect(stateFor({ listenerAgeMs: 600_000 })).resolves.toBe("indeterminate");
|
|
});
|
|
|
|
// The 3 s grace is the difference between "one wasted restart-app" and
|
|
// "restart a tool-server that was never broken". Every other case sits 200x
|
|
// clear of it and passes whatever the term does; these two sit ON it, so
|
|
// dropping the grace or weakening `>=` to `>` cannot stay green while flipping
|
|
// a relaunchable process to `unregistered`.
|
|
it("still calls a process that started exactly at the grace boundary stale", async () => {
|
|
// A 597 s-old process read against a 600 s-old listener: it was exec'd 3 s
|
|
// AFTER the bind, i.e. the full grace and not a millisecond more. The
|
|
// comparison sits exactly on its edge — 597 + 3 == 600.
|
|
probe.psOutput = psLine("09:57", INJECTED_ENV);
|
|
|
|
await expect(stateFor({ listenerAgeMs: 600_000 })).resolves.toBe("stale_process");
|
|
});
|
|
|
|
it("still calls a process launched a second after the listener stale", async () => {
|
|
// 599 s old against the same 600 s listener: exec'd 1 s after the bind, so
|
|
// 2 s inside the slop rather than on its edge.
|
|
probe.psOutput = psLine("09:59", INJECTED_ENV);
|
|
|
|
await expect(stateFor({ listenerAgeMs: 600_000 })).resolves.toBe("stale_process");
|
|
});
|
|
|
|
// The pair above pins the slop only from BELOW — both want `stale_process`, so
|
|
// a grown term keeps them green. Every other injected fixture reads a 600 s
|
|
// listener against a process of seconds, a gap the slop could grow into
|
|
// undetected, swallowing `connecting` and `unregistered` alike into a verdict
|
|
// whose remedy is restart-app. That is the loop: right after the tool-server
|
|
// starts, a freshly launched app sits inside a grown slop, is told to relaunch,
|
|
// and the relaunched process is inside it again. The connect budget is pinned
|
|
// both ways already (the 00:08/00:09 pair from above, the gate equality below).
|
|
it("does not let the slop swallow a young process against a young listener", async () => {
|
|
// 1 s old against a 4.1 s listener: exec'd 3.1 s after the bind, just past
|
|
// the slop, and well inside the connect budget.
|
|
probe.psOutput = psLine("00:01", INJECTED_ENV);
|
|
|
|
await expect(stateFor({ listenerAgeMs: 4_100 })).resolves.toBe("connecting");
|
|
});
|
|
|
|
it("re-applies the launchd env before reading the process, not after", async () => {
|
|
// The two ages this verdict subtracts are read at different moments: `ps`
|
|
// takes the process's, `Date.now()` takes the listener's once the probe is
|
|
// back. Whatever is spent in between shortens the process against the
|
|
// listener by that much, and the env re-apply is several simctl round-trips
|
|
// — enough to spend the whole grace and report a relaunchable process as one
|
|
// no relaunch can help. Running it first leaves both readings past it.
|
|
const instance = await nativeDevtoolsBlueprint.factory({}, device, { device });
|
|
try {
|
|
// The factory stamps the listener as it binds and bills no clock after, so
|
|
// this is that instant.
|
|
const listeningSince = Date.now();
|
|
probe.psExecAt = listeningSince + 1_000;
|
|
vi.setSystemTime(listeningSince + 600_000);
|
|
probe.envClockCostMs = 4_000;
|
|
|
|
// Exec'd 1 s after the bind, so it dialed this listener and a relaunch is
|
|
// what re-dials it — whichever side of the re-apply `ps` is read on.
|
|
await expect((instance.api as NativeDevtoolsApi).appConnectionState(BUNDLE)).resolves.toBe(
|
|
"stale_process"
|
|
);
|
|
} finally {
|
|
await instance.dispose();
|
|
}
|
|
});
|
|
|
|
it("re-applies the launchd env on every read, not just the first", async () => {
|
|
// The factory already latched `envSetup`, so `ensureEnvReady()` here would
|
|
// be a silent no-op. `reverifyEnv` bypasses that latch, which is what repairs
|
|
// a simulator rebooted out of band — without it a process is compared against
|
|
// an env no relaunch would actually get.
|
|
const instance = await nativeDevtoolsBlueprint.factory({}, device, { device });
|
|
try {
|
|
const api = instance.api as NativeDevtoolsApi;
|
|
probe.envOps = 0;
|
|
|
|
await api.appConnectionState(BUNDLE);
|
|
|
|
expect(probe.envOps).toBeGreaterThan(0);
|
|
} finally {
|
|
await instance.dispose();
|
|
}
|
|
});
|
|
|
|
// The entry `connections.has` snapshot predates `reverifyEnv` and a `launchctl
|
|
// list` — several simctl round-trips before the verdict — so a dial landing in
|
|
// that window reads as an app the service never registered, sending the agent
|
|
// to restart a tool-server that had just succeeded. Every unconnected verdict
|
|
// rests on the snapshot, so the re-read sits above all of them.
|
|
it("re-reads the live connection map after the probe, not the entry snapshot", async () => {
|
|
const instance = await nativeDevtoolsBlueprint.factory({}, device, { device });
|
|
try {
|
|
const api = instance.api as NativeDevtoolsApi;
|
|
// Injected against this endpoint, 30 s old, listener far older: without
|
|
// the re-read this is the textbook `unregistered`.
|
|
probe.psOutput = psLine(
|
|
"00:30",
|
|
`NATIVE_DEVTOOLS_IOS_CDP_SOCKET=${api.socketPath} ` +
|
|
"DYLD_INSERT_LIBRARIES=/fake/dylibs/libArgentInjectionBootstrap.dylib"
|
|
);
|
|
// The dylib completes its handshake while the ps probe is in flight.
|
|
probe.dialDuringPs = api.socketPath;
|
|
vi.setSystemTime(Date.now() + 600_000);
|
|
|
|
await expect(api.appConnectionState(BUNDLE)).resolves.toBe("connected");
|
|
expect(api.isConnected(BUNDLE)).toBe(true);
|
|
} finally {
|
|
await instance.dispose();
|
|
}
|
|
});
|
|
|
|
// The `unregistered` derivation rests on `ps` rendering the launch
|
|
// environment, which only the `e` flag does, and on `etime` being the FIRST
|
|
// column, since the age is parsed positionally. Neither shows up in a state
|
|
// assertion: drop the `e` and every app reads `stale_process` — the restart
|
|
// loop restored by one character — and swapping the columns reads
|
|
// `indeterminate`.
|
|
it("asks ps for the environment, with the age first", async () => {
|
|
await stateFor({ listenerAgeMs: 600_000 });
|
|
|
|
expect(probe.psInvocation?.args).toEqual(["eww", "-p", String(PID), "-o", "etime=,command="]);
|
|
});
|
|
|
|
// Advisory probe: its own short budget rather than the simctl one, and the
|
|
// raised buffer its siblings use — an environment can run to kern.argmax
|
|
// (1 MiB), exactly Node's default cap, so the default ENOBUFSes on a maximal
|
|
// one and degrades a readable process to "no evidence".
|
|
it("bounds the ps probe and raises its buffer past a maximal environment", async () => {
|
|
await stateFor({ listenerAgeMs: 600_000 });
|
|
|
|
expect(probe.psInvocation?.opts.maxBuffer).toBe(16 * 1024 * 1024);
|
|
expect(probe.psInvocation?.opts.timeout).toBe(5_000);
|
|
});
|
|
|
|
// A connected app must short-circuit before any device probe: the whole
|
|
// measurement exists for apps that are NOT connected, and running it on one
|
|
// that is costs several simctl round-trips per call.
|
|
it("answers connected off the live map without probing the device", async () => {
|
|
const instance = await nativeDevtoolsBlueprint.factory({}, device, { device });
|
|
try {
|
|
const api = instance.api as NativeDevtoolsApi;
|
|
const sock = net.createConnection(api.socketPath, () => {
|
|
sock.write(JSON.stringify({ type: "Control", payload: { bundleId: BUNDLE } }) + "\n");
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
probe.psInvocation = null;
|
|
|
|
await expect(api.appConnectionState(BUNDLE)).resolves.toBe("connected");
|
|
expect(probe.psInvocation).toBeNull();
|
|
sock.destroy();
|
|
} finally {
|
|
await instance.dispose();
|
|
}
|
|
});
|
|
|
|
// A failing inspection must stay advisory. `precheckNativeDevtools` — the path
|
|
// all six native-* feature tools take — depends on this call not rejecting.
|
|
it("keeps a failed inspection non-fatal", async () => {
|
|
probe.launchctlFails = true;
|
|
|
|
await expect(stateFor({ listenerAgeMs: 600_000 })).resolves.toBe("indeterminate");
|
|
});
|
|
|
|
// A null pid means the row did not parse, not that launchd reported no
|
|
// process: measured on iOS 18.6, a UIKitApplication row is removed outright
|
|
// when the app exits rather than left with a `-`. An unreadable row is no
|
|
// evidence, never a claim about the app.
|
|
it("reports indeterminate for a row whose pid column does not parse", async () => {
|
|
probe.launchctlList = runningRow("-");
|
|
|
|
await expect(stateFor({ listenerAgeMs: 600_000 })).resolves.toBe("indeterminate");
|
|
});
|
|
});
|