Files
software-mansion__argent/packages/tool-server/test/debugger/debugger-status-not-connected.test.ts
Ignacy Łątka 5d8fc10799 test: stop tests binding machine-wide resources they do not own (#714)
## 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`.
2026-08-19 23:06:48 +02:00

602 lines
23 KiB
TypeScript

import { describe, it, expect, afterEach, vi } from "vitest";
import { WebSocketServer } from "ws";
import * as http from "node:http";
import { AddressInfo } from "node:net";
import {
Registry,
ServiceState,
TypedEventEmitter,
getFailureSignal,
FAILURE_CODES,
FailureError,
type ServiceBlueprint,
type ServiceEvents,
} from "@argent/registry";
// Capture telemetry without touching the transport: keep every real export and
// spy only `track` (same pattern as preview-opened-telemetry.test.ts).
vi.mock("@argent/telemetry", async (importOriginal) => {
const actual = await importOriginal<typeof import("@argent/telemetry")>();
return { ...actual, track: vi.fn() };
});
import { track } from "@argent/telemetry";
import {
jsRuntimeDebuggerBlueprint,
JS_RUNTIME_DEBUGGER_NAMESPACE,
type JsRuntimeDebuggerApi,
} from "../../src/blueprints/js-runtime-debugger";
import { chromiumCdpBlueprint } from "../../src/blueprints/chromium-cdp";
import {
chromiumJsRuntimeDebuggerBlueprint,
chromiumJsRuntimeDebuggerRef,
} from "../../src/blueprints/chromium-js-runtime-debugger";
import { resolveDevice } from "../../src/utils/device-info";
import { createDebuggerStatusTool } from "../../src/tools/debugger/debugger-status";
import { resetDeviceAliases } from "../../src/utils/debugger/device-alias";
import type { DebuggerNotConnectedResult } from "../../src/tools/debugger/not-connected";
import { freePort, startMockMetroCdp } from "./metro-cdp-harness";
import { scopeTempHome } from "../helpers/temp-home";
// The JS-runtime-debugger / network blueprints build a real LogFileWriter,
// whose constructor mkdir -p's os.homedir()/.argent/tmp. Keep that out of the
// developer's real home.
scopeTempHome("argent-debugger-status-home-");
const mockTrack = vi.mocked(track);
const outcomeCalls = () =>
mockTrack.mock.calls.filter(([event]) => event === "debugger:tool_outcome");
const INVOCATION_ID = "11111111-2222-4333-8444-555555555555";
// ── Harnesses ────────────────────────────────────────────────────────────────
// freePort / startMockMetroCdp live in ./metro-cdp-harness (shared with the
// log-registry and recovery-reachability suites).
interface MockMetro {
port: number;
close: () => Promise<void>;
}
/** Metro that answers packager-status:running but exposes no CDP targets. */
async function startEmptyMetro(): Promise<MockMetro> {
const server = http.createServer((req, res) => {
if (req.url === "/status") {
res.end("packager-status:running");
return;
}
if (req.url === "/json/list") {
res.setHeader("Content-Type", "application/json");
res.end("[]");
return;
}
res.statusCode = 404;
res.end();
});
await new Promise<void>((resolve) => server.listen(0, () => resolve()));
const port = (server.address() as AddressInfo).port;
return {
port,
close: () => new Promise((resolve) => server.close(() => resolve())),
};
}
/** Fake Chromium CDP endpoint: /json/version + /json/list + a page WS target. */
async function startFakeChromiumCdp(): Promise<MockMetro> {
const server = http.createServer((req, res) => {
res.setHeader("content-type", "application/json");
if (req.url === "/json/version") {
res.end(JSON.stringify({ "Browser": "Chrome/Test", "Protocol-Version": "1.3" }));
return;
}
if (req.url === "/json/list") {
const port = (server.address() as AddressInfo).port;
res.end(
JSON.stringify([
{
id: "page1",
type: "page",
title: "T",
url: "about:blank",
webSocketDebuggerUrl: `ws://127.0.0.1:${port}/devtools/page/page1`,
},
])
);
return;
}
res.statusCode = 404;
res.end();
});
const wss = new WebSocketServer({ server });
wss.on("connection", (ws) => {
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
let result: unknown;
switch (msg.method) {
case "Runtime.evaluate":
// The ChromiumServer viewport probe expects a JSON string back.
result = {
result: { type: "string", value: JSON.stringify({ w: 800, h: 600, dpr: 1 }) },
};
break;
case "DOM.getDocument":
result = { root: { nodeId: 1, backendNodeId: 100 } };
break;
default:
result = {};
}
ws.send(JSON.stringify({ id: msg.id, result }));
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
const port = (server.address() as AddressInfo).port;
return {
port,
close: () =>
new Promise((resolve) => {
for (const c of wss.clients) c.close();
wss.close(() => server.close(() => resolve()));
}),
};
}
// ── Per-test wiring ──────────────────────────────────────────────────────────
interface Setup {
registry: Registry;
completed: string[];
failed: string[];
invoke: (params: Record<string, unknown>) => Promise<unknown>;
}
function makeSetup(...blueprints: ServiceBlueprint<any, any>[]): Setup {
const registry = new Registry();
for (const bp of blueprints) registry.registerBlueprint(bp);
registry.registerTool(createDebuggerStatusTool(registry));
const completed: string[] = [];
const failed: string[] = [];
registry.events.on("toolCompleted", (id) => completed.push(id));
registry.events.on("toolFailed", (id) => failed.push(id));
return {
registry,
completed,
failed,
invoke: (params) =>
registry.invokeTool("debugger-status", params, { toolInvocationId: INVOCATION_ID }),
};
}
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
while (cleanups.length) await cleanups.pop()!();
mockTrack.mockClear();
vi.restoreAllMocks();
resetDeviceAliases();
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("debugger-status not-connected results", () => {
it("(a) nothing listening → not_connected/metro_not_running as a SUCCESS, with tool_outcome", async () => {
const port = await freePort();
const setup = makeSetup(jsRuntimeDebuggerBlueprint);
cleanups.push(() => setup.registry.dispose());
const result = (await setup.invoke({
port,
device_id: "mock-device",
})) as DebuggerNotConnectedResult;
expect(result.status).toBe("not_connected");
expect(result.connected).toBe(false);
expect(result.reason).toBe("metro_not_running");
expect(result.port).toBe(port);
expect(result.detail).toContain("is not running");
expect(result.guidance).toContain("Do not retry in a loop");
// Precondition, not malfunction: completes, never fails.
expect(setup.completed).toEqual(["debugger-status"]);
expect(setup.failed).toEqual([]);
// Exactly one debugger:tool_outcome, joining tool:invoke via the id.
expect(outcomeCalls()).toHaveLength(1);
expect(outcomeCalls()[0][1]).toMatchObject({
tool: "debugger-status",
outcome: "metro_not_running",
tool_invocation_id: INVOCATION_ID,
});
});
it("(b) Metro running with no targets → not_connected/no_app_connected", async () => {
const metro = await startEmptyMetro();
const setup = makeSetup(jsRuntimeDebuggerBlueprint);
cleanups.push(async () => {
await setup.registry.dispose();
await metro.close();
});
const result = (await setup.invoke({
port: metro.port,
device_id: "mock-device",
})) as DebuggerNotConnectedResult;
expect(result.status).toBe("not_connected");
expect(result.reason).toBe("no_app_connected");
expect(result.guidance).toContain("launch-app / restart-app");
expect(setup.failed).toEqual([]);
expect(outcomeCalls()).toHaveLength(1);
expect(outcomeCalls()[0][1]).toMatchObject({ outcome: "no_app_connected" });
});
it("(c) happy path → status:connected with the legacy field superset and a connected outcome", async () => {
const metro = await startMockMetroCdp();
const setup = makeSetup(jsRuntimeDebuggerBlueprint);
cleanups.push(async () => {
await setup.registry.dispose();
await metro.close();
});
const result = (await setup.invoke({
port: metro.port,
device_id: "mock-device",
})) as Record<string, unknown>;
expect(result.status).toBe("connected");
expect(result.connected).toBe(true);
expect(result.port).toBe(metro.port);
expect(result.projectRoot).toBe("/mock/project");
expect(result.deviceName).toBe("MockDevice");
expect(result.appName).toBe("React Native (mock)");
expect(result.isNewDebugger).toBe(true);
expect(result.loadedScripts).toBeGreaterThanOrEqual(1);
expect(result.enabledDomains).toContain("Runtime");
expect(result.sourceMapReady).toBe(true);
expect(setup.completed).toEqual(["debugger-status"]);
expect(outcomeCalls()).toHaveLength(1);
expect(outcomeCalls()[0][1]).toMatchObject({
tool: "debugger-status",
outcome: "connected",
tool_invocation_id: INVOCATION_ID,
});
});
it("(d) unexpected errors fall through and REJECT — plain factory Error", async () => {
// Guard against over-mapping: a genuine fault must keep failing loudly.
const events = new TypedEventEmitter<ServiceEvents>();
void events;
const brokenBlueprint: ServiceBlueprint<JsRuntimeDebuggerApi, string> = {
namespace: JS_RUNTIME_DEBUGGER_NAMESPACE,
getURN: (payload) => `${JS_RUNTIME_DEBUGGER_NAMESPACE}:${payload}`,
factory: async () => {
throw new Error("boom: something unrelated to connectivity");
},
};
const setup = makeSetup(brokenBlueprint);
cleanups.push(() => setup.registry.dispose());
await expect(setup.invoke({ port: 8081, device_id: "mock-device" })).rejects.toThrow(
/boom: something unrelated to connectivity/
);
expect(setup.failed).toEqual(["debugger-status"]);
expect(setup.completed).toEqual([]);
// No outcome event on the rethrow path.
expect(outcomeCalls()).toHaveLength(0);
});
it("(d) unexpected errors fall through and REJECT — JS_RUNTIME_PAYLOAD_* keeps its code", async () => {
const setup = makeSetup(jsRuntimeDebuggerBlueprint);
cleanups.push(() => setup.registry.dispose());
// Empty device_id passes zod but produces an invalid service payload —
// a caller bug, not a connectivity precondition. Must reject, not map.
let thrown: unknown;
try {
await setup.invoke({ port: 8081, device_id: "" });
} catch (err) {
thrown = err;
}
expect(thrown).toBeInstanceOf(Error);
expect(getFailureSignal(thrown)?.error_code).toBe(
FAILURE_CODES.JS_RUNTIME_PAYLOAD_DEVICE_MISSING
);
expect(setup.failed).toEqual(["debugger-status"]);
expect(outcomeCalls()).toHaveLength(0);
});
it("(e) socket-state gate (Metro): stale_connection disposes the node and the next call reconnects", async () => {
const metro = await startMockMetroCdp();
const factorySpy = vi.fn(jsRuntimeDebuggerBlueprint.factory);
const spiedBlueprint: typeof jsRuntimeDebuggerBlueprint = {
...jsRuntimeDebuggerBlueprint,
factory: factorySpy,
};
const setup = makeSetup(spiedBlueprint);
cleanups.push(async () => {
await setup.registry.dispose();
await metro.close();
});
// Happy connect first.
const first = (await setup.invoke({ port: metro.port, device_id: "mock-device" })) as Record<
string,
unknown
>;
expect(first.status).toBe("connected");
expect(factorySpy).toHaveBeenCalledTimes(1);
// Force the gate: the cached client reports its socket is no longer OPEN
// while the node is still RUNNING (no terminated cascade fires).
const urn = `${JS_RUNTIME_DEBUGGER_NAMESPACE}:${metro.port}:mock-device`;
const api = await setup.registry.resolveService<JsRuntimeDebuggerApi>(urn);
vi.spyOn(api.cdp, "isConnected").mockReturnValue(false);
const stale = (await setup.invoke({
port: metro.port,
device_id: "mock-device",
})) as DebuggerNotConnectedResult;
expect(stale.status).toBe("not_connected");
expect(stale.reason).toBe("stale_connection");
expect(stale.port).toBe(metro.port);
expect(stale.guidance).toContain("restart-app");
expect(setup.failed).toEqual([]);
// The dispose side effect is real: the next call re-runs the factory and
// comes back connected on a fresh socket.
const second = (await setup.invoke({ port: metro.port, device_id: "mock-device" })) as Record<
string,
unknown
>;
expect(second.status).toBe("connected");
expect(factorySpy).toHaveBeenCalledTimes(2);
const outcomes = outcomeCalls().map(([, props]) => (props as { outcome: string }).outcome);
expect(outcomes).toEqual(["connected", "stale_connection", "connected"]);
});
it("(f) chromium unreachable → not_connected/cdp_unreachable with NO port field", async () => {
const deadPort = await freePort();
const setup = makeSetup(chromiumCdpBlueprint, chromiumJsRuntimeDebuggerBlueprint);
cleanups.push(() => setup.registry.dispose());
const result = (await setup.invoke({
port: 8081,
device_id: `chromium-cdp-${deadPort}`,
})) as DebuggerNotConnectedResult;
expect(result.status).toBe("not_connected");
expect(result.reason).toBe("cdp_unreachable");
// Claiming port 8081 on a chromium id would misdirect agents — the CDP
// port lives inside the device id and the Metro `port` param is ignored.
expect("port" in result).toBe(false);
expect(setup.failed).toEqual([]);
expect(outcomeCalls()).toHaveLength(1);
expect(outcomeCalls()[0][1]).toMatchObject({ outcome: "cdp_unreachable" });
});
it("(g) chromium happy path → status:connected (proves ref.options is passed through)", async () => {
const fake = await startFakeChromiumCdp();
const setup = makeSetup(chromiumCdpBlueprint, chromiumJsRuntimeDebuggerBlueprint);
cleanups.push(async () => {
await setup.registry.dispose();
await fake.close();
});
// An implementation that drops ref.options fails here: the wrapper factory
// hard-requires options.device once its ChromiumCdp dependency resolves.
const result = (await setup.invoke({
port: 8081,
device_id: `chromium-cdp-${fake.port}`,
})) as Record<string, unknown>;
expect(result.status).toBe("connected");
expect(result.connected).toBe(true);
expect(result.port).toBe(fake.port);
expect(result.isNewDebugger).toBe(true);
expect(setup.failed).toEqual([]);
expect(outcomeCalls()).toHaveLength(1);
expect(outcomeCalls()[0][1]).toMatchObject({ outcome: "connected" });
});
it("(i) chromium socket-state gate: reconnecting, NO dispose, no restart-app guidance", async () => {
const fake = await startFakeChromiumCdp();
const wrapperFactorySpy = vi.fn(chromiumJsRuntimeDebuggerBlueprint.factory);
const spiedWrapper: typeof chromiumJsRuntimeDebuggerBlueprint = {
...chromiumJsRuntimeDebuggerBlueprint,
factory: wrapperFactorySpy,
};
const setup = makeSetup(chromiumCdpBlueprint, spiedWrapper);
cleanups.push(async () => {
await setup.registry.dispose();
await fake.close();
});
const deviceId = `chromium-cdp-${fake.port}`;
const first = (await setup.invoke({ port: 8081, device_id: deviceId })) as Record<
string,
unknown
>;
expect(first.status).toBe("connected");
expect(wrapperFactorySpy).toHaveBeenCalledTimes(1);
// Force isConnected() === false while BOTH nodes stay RUNNING — the
// tab-switch reconnect window.
const ref = chromiumJsRuntimeDebuggerRef(resolveDevice(deviceId));
const api = await setup.registry.resolveService<JsRuntimeDebuggerApi>(ref.urn, ref.options);
vi.spyOn(api.cdp, "isConnected").mockReturnValue(false);
const result = (await setup.invoke({
port: 8081,
device_id: deviceId,
})) as DebuggerNotConnectedResult;
expect(result.status).toBe("not_connected");
expect(result.reason).toBe("reconnecting");
expect("port" in result).toBe(false);
expect(result.guidance).not.toMatch(/restart-app/);
expect(result.guidance).toMatch(/retry/i);
// No dispose happened: both nodes are still RUNNING and a subsequent
// resolve reuses the cached instance (factory count unchanged).
expect(setup.registry.getServiceState(ref.urn)).toBe(ServiceState.RUNNING);
expect(setup.registry.getServiceState(`ChromiumCdp:${deviceId}`)).toBe(ServiceState.RUNNING);
await setup.registry.resolveService(ref.urn, ref.options);
expect(wrapperFactorySpy).toHaveBeenCalledTimes(1);
expect(outcomeCalls().map(([, p]) => (p as { outcome: string }).outcome)).toEqual([
"connected",
"reconnecting",
]);
});
it("(j) runtime_unresponsive: a frozen runtime timing out during connect maps, not throws", async () => {
// A target that accepts the WebSocket but whose JS runtime never answers
// makes the connect pipeline's un-caught enable sends reject with
// DEBUGGER_CDP_REQUEST_TIMEOUT (cdp-client.ts). The tool must return the
// structured result — before this mapping existed, the most-likely state a
// diagnostic tool gets reached for produced an opaque 500 after the full
// CDP timeout.
const timeoutBlueprint: ServiceBlueprint<Record<string, never>, string> = {
namespace: JS_RUNTIME_DEBUGGER_NAMESPACE,
getURN: (payload) => `${JS_RUNTIME_DEBUGGER_NAMESPACE}:${payload}`,
factory: async () => {
throw new FailureError("CDP request Runtime.enable (id=3) timed out", {
error_code: FAILURE_CODES.DEBUGGER_CDP_REQUEST_TIMEOUT,
failure_stage: "debugger_cdp_send",
failure_area: "tool_server",
error_kind: "timeout",
});
},
};
const setup = makeSetup(timeoutBlueprint as ServiceBlueprint);
cleanups.push(() => setup.registry.dispose());
const result = (await setup.invoke({
port: 8081,
device_id: "mock-device",
})) as DebuggerNotConnectedResult;
expect(result.status).toBe("not_connected");
expect(result.reason).toBe("runtime_unresponsive");
expect(result.detail).toContain("timed out");
expect(result.guidance).toContain("Do not retry in a loop");
expect(setup.failed).toEqual([]);
expect(outcomeCalls()).toHaveLength(1);
expect(outcomeCalls()[0][1]).toMatchObject({ outcome: "runtime_unresponsive" });
});
it("(k) chromium non-CDP port occupant → cdp_unreachable, mirroring the Metro arm", async () => {
// A plain HTTP server squatting the debug port answers /json/version with
// 404 → CHROMIUM_CDP_INVALID_RESPONSE. Same precondition class as a
// non-Metro port occupant (which maps to metro_not_running on the Metro
// arm) — must be a structured result, not an HTTP 500.
const server = http.createServer((_req, res) => {
res.statusCode = 404;
res.end("not cdp");
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
const port = (server.address() as AddressInfo).port;
const setup = makeSetup(chromiumCdpBlueprint, chromiumJsRuntimeDebuggerBlueprint);
cleanups.push(async () => {
await setup.registry.dispose();
await new Promise<void>((resolve) => server.close(() => resolve()));
});
const result = (await setup.invoke({
port: 8081,
device_id: `chromium-cdp-${port}`,
})) as DebuggerNotConnectedResult;
expect(result.status).toBe("not_connected");
expect(result.reason).toBe("cdp_unreachable");
expect("port" in result).toBe(false);
expect(result.detail).toContain("HTTP 404");
// Platform-correct guidance: launch-app is a no-op on Chromium and would
// re-resolve the failing service — the guidance must not send agents there.
expect(result.guidance).not.toMatch(/\(launch-app\)/);
expect(result.guidance).toContain("--remote-debugging-port");
expect(setup.failed).toEqual([]);
expect(outcomeCalls()[0][1]).toMatchObject({ outcome: "cdp_unreachable" });
});
it("(l) stale_connection outcome classifies platform BEFORE dispose forgets the alias", async () => {
const LOGICAL = "8b9223b1392be193fa9058e0cef5cefb2bddeb68";
const UDID = "BE1DCAD9-43CE-40C4-B8B2-9CB30BC03227";
const metro = await startMockMetroCdp({ logicalDeviceId: LOGICAL });
const setup = makeSetup(jsRuntimeDebuggerBlueprint);
cleanups.push(async () => {
await setup.registry.dispose();
await metro.close();
});
// Connect with the stable id; the factory learns LOGICAL → UDID.
const first = (await setup.invoke({ port: metro.port, device_id: UDID })) as Record<
string,
unknown
>;
expect(first.status).toBe("connected");
expect(first.logicalDeviceId).toBe(LOGICAL);
// Forward the logicalDeviceId (what the skill instructs) and hit the
// stale gate: the branch disposes the node, and dispose forgets the alias
// — so tracking after the dispose would shape-classify the 40-hex handle
// as android. The outcome must still say ios.
const urn = `${JS_RUNTIME_DEBUGGER_NAMESPACE}:${metro.port}:${UDID}`;
const api = await setup.registry.resolveService<JsRuntimeDebuggerApi>(urn);
vi.spyOn(api.cdp, "isConnected").mockReturnValue(false);
const stale = (await setup.invoke({
port: metro.port,
device_id: LOGICAL,
})) as DebuggerNotConnectedResult;
expect(stale.reason).toBe("stale_connection");
const staleOutcome = outcomeCalls().at(-1)![1];
expect(staleOutcome).toMatchObject({ outcome: "stale_connection", platform: "ios" });
});
it("(h) terminating window: a resolve racing an in-flight teardown maps to reconnecting", async () => {
// Fake JsRuntimeDebugger whose dispose blocks until released, holding the
// node in TERMINATING while the status call lands.
let releaseDispose!: () => void;
const disposeGate = new Promise<void>((resolve) => {
releaseDispose = resolve;
});
const slowDisposeBlueprint: ServiceBlueprint<Record<string, never>, string> = {
namespace: JS_RUNTIME_DEBUGGER_NAMESPACE,
getURN: (payload) => `${JS_RUNTIME_DEBUGGER_NAMESPACE}:${payload}`,
factory: async () => ({
api: {},
dispose: () => disposeGate,
events: new TypedEventEmitter<ServiceEvents>(),
}),
};
const setup = makeSetup(slowDisposeBlueprint);
const urn = `${JS_RUNTIME_DEBUGGER_NAMESPACE}:8081:dev1`;
await setup.registry.resolveService(urn);
const disposing = setup.registry.disposeService(urn); // held open by the gate
cleanups.push(async () => {
releaseDispose();
await disposing;
await setup.registry.dispose();
});
const result = (await setup.invoke({
port: 8081,
device_id: "dev1",
})) as DebuggerNotConnectedResult;
expect(result.status).toBe("not_connected");
expect(result.reason).toBe("reconnecting");
expect(result.detail).toContain("terminating");
expect(result.guidance).toContain("retry once");
expect(setup.failed).toEqual([]);
expect(outcomeCalls()).toHaveLength(1);
expect(outcomeCalls()[0][1]).toMatchObject({
tool: "debugger-status",
outcome: "reconnecting",
tool_invocation_id: INVOCATION_ID,
});
});
});