Files
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

1234 lines
49 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
// ── Module mocks ─────────────────────────────────────────────────────────────
// These are hoisted so `vi.mock` can reference them. They let the network-
// dependent helpers (`isOnline`, `isSkillsCliAvailable`) be tested
// deterministically without touching DNS or spawning `npx`.
const { dnsLookupMock, execSyncMock } = vi.hoisted(() => ({
dnsLookupMock: vi.fn(),
execSyncMock: vi.fn(),
}));
vi.mock("node:dns", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:dns")>();
return {
...actual,
default: { ...actual, lookup: dnsLookupMock },
lookup: dnsLookupMock,
};
});
vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return {
...actual,
default: { ...actual, execSync: execSyncMock },
execSync: execSyncMock,
};
});
import {
readJson,
writeJson,
copyDir,
dirExists,
detectPackageManager,
globalInstallCommand,
globalUninstallCommand,
formatShellCommand,
getGlobalSkillLockPath,
getProjectSkillLockPath,
listArgentSkillsInLock,
isNewerVersion,
isOnline,
isSkillsCliAvailable,
listBundledSkills,
resolveProjectRoot,
SKILLS_DIR,
RULES_DIR,
AGENTS_DIR,
localInstallCommand,
localUninstallCommand,
detectProjectPackageManager,
hasProjectPackageJson,
isYarnPnp,
isLocallyInstalled,
getLocallyInstalledVersion,
getLocalArgentBinRelPath,
probeLocalInstall,
isDeclaredLocally,
getInstallRecordPath,
readInstallRecord,
writeInstallRecord,
removeInstallRecord,
resolveInstallMode,
resolveInstallModeFromFlags,
InstallModeFlagError,
} from "../src/utils.js";
import { NPM_REGISTRY, PACKAGE_NAME } from "../src/constants.js";
// Stage a fake project-local @swmansion/argent install under `root`, returning
// the bin entry's project-relative POSIX path.
function stageLocalArgent(
root: string,
opts: { version?: string; bin?: unknown; withBinFile?: boolean } = {}
): string {
const pkgDir = path.join(root, "node_modules", PACKAGE_NAME);
fs.mkdirSync(path.join(pkgDir, "dist"), { recursive: true });
const bin = opts.bin ?? { "argent": "dist/cli.js", "argent-simulator-server": "bin/x.cjs" };
fs.writeFileSync(
path.join(pkgDir, "package.json"),
JSON.stringify({ name: PACKAGE_NAME, version: opts.version ?? "1.2.3", bin })
);
if (opts.withBinFile !== false) {
fs.writeFileSync(path.join(pkgDir, "dist", "cli.js"), "#!/usr/bin/env node\n");
}
return `node_modules/${PACKAGE_NAME}/dist/cli.js`;
}
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "argent-utils-test-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
// ── readJson ──────────────────────────────────────────────────────────────────
describe("readJson", () => {
it("returns empty object for non-existent file", () => {
expect(readJson(path.join(tmpDir, "nope.json"))).toEqual({});
});
it("returns empty object for invalid JSON", () => {
const file = path.join(tmpDir, "bad.json");
fs.writeFileSync(file, "not json");
expect(readJson(file)).toEqual({});
});
it("reads valid JSON", () => {
const file = path.join(tmpDir, "ok.json");
fs.writeFileSync(file, JSON.stringify({ foo: "bar" }));
expect(readJson(file)).toEqual({ foo: "bar" });
});
});
// ── writeJson ─────────────────────────────────────────────────────────────────
describe("writeJson", () => {
it("creates parent directories", () => {
const file = path.join(tmpDir, "a", "b", "c.json");
writeJson(file, { x: 1 });
expect(fs.existsSync(file)).toBe(true);
expect(JSON.parse(fs.readFileSync(file, "utf8"))).toEqual({ x: 1 });
});
it("overwrites existing files", () => {
const file = path.join(tmpDir, "x.json");
writeJson(file, { v: 1 });
writeJson(file, { v: 2 });
expect(JSON.parse(fs.readFileSync(file, "utf8"))).toEqual({ v: 2 });
});
it("pretty-prints with 2-space indent and trailing newline", () => {
const file = path.join(tmpDir, "pretty.json");
writeJson(file, { a: 1 });
const raw = fs.readFileSync(file, "utf8");
expect(raw).toBe('{\n "a": 1\n}\n');
});
});
// ── copyDir ───────────────────────────────────────────────────────────────────
describe("copyDir", () => {
// Two files and a nested directory, matching the shape of the bundled
// rules/agents trees this copies in practice.
function stageSource(): string {
const src = path.join(tmpDir, "src");
fs.mkdirSync(path.join(src, "sub"), { recursive: true });
fs.writeFileSync(path.join(src, "a.txt"), "hello");
fs.writeFileSync(path.join(src, "sub", "b.txt"), "world");
return src;
}
it("returns null when source does not exist", () => {
expect(copyDir(path.join(tmpDir, "nope"), path.join(tmpDir, "dest"))).toBeNull();
});
it("copies directory recursively and reports where it wrote", () => {
const src = stageSource();
const dest = path.join(tmpDir, "dest");
expect(copyDir(src, dest)).toBe(dest);
expect(fs.readFileSync(path.join(dest, "a.txt"), "utf8")).toBe("hello");
expect(fs.readFileSync(path.join(dest, "sub", "b.txt"), "utf8")).toBe("world");
});
// Symlinked destinations (issue #701): the canonical copy lives in one
// harness-neutral directory and each vendor path is a link at it, so the copy
// has to write *through* the link instead of trying to replace it.
// Skipped on Windows, where creating symlinks needs admin rights.
describe.skipIf(process.platform === "win32")("symlinked destinations", () => {
it("writes through a symlinked destination, leaving the link intact", () => {
const src = stageSource();
const real = path.join(tmpDir, "canonical");
const link = path.join(tmpDir, "dest");
fs.mkdirSync(real);
fs.symlinkSync("canonical", link);
expect(copyDir(src, link)).toBe(fs.realpathSync(real));
expect(fs.readFileSync(path.join(real, "a.txt"), "utf8")).toBe("hello");
expect(fs.readFileSync(path.join(real, "sub", "b.txt"), "utf8")).toBe("world");
expect(fs.lstatSync(link).isSymbolicLink()).toBe(true);
});
it("writes through a symlinked subdirectory of the destination", () => {
const src = stageSource();
const dest = path.join(tmpDir, "dest");
const real = path.join(tmpDir, "canonical-sub");
fs.mkdirSync(dest);
fs.mkdirSync(real);
fs.symlinkSync("../canonical-sub", path.join(dest, "sub"));
copyDir(src, dest);
expect(fs.readFileSync(path.join(real, "b.txt"), "utf8")).toBe("world");
expect(fs.readFileSync(path.join(dest, "a.txt"), "utf8")).toBe("hello");
expect(fs.lstatSync(path.join(dest, "sub")).isSymbolicLink()).toBe(true);
});
// A single symlinked file is the layout a dotfile manager produces when the
// directory has to stay real because it holds the user's own agents too.
it("writes through a symlinked file in the destination", () => {
const src = stageSource();
const dest = path.join(tmpDir, "dest");
const canonical = path.join(tmpDir, "canonical", "a.txt");
fs.mkdirSync(path.join(tmpDir, "canonical"));
fs.mkdirSync(dest);
fs.writeFileSync(canonical, "stale");
fs.symlinkSync(path.join("..", "canonical", "a.txt"), path.join(dest, "a.txt"));
copyDir(src, dest);
expect(fs.readFileSync(canonical, "utf8")).toBe("hello");
expect(fs.lstatSync(path.join(dest, "a.txt")).isSymbolicLink()).toBe(true);
});
it("creates the target of a dangling destination link", () => {
const src = stageSource();
const link = path.join(tmpDir, "dest");
fs.symlinkSync("canonical", link);
copyDir(src, link);
expect(fs.readFileSync(path.join(tmpDir, "canonical", "a.txt"), "utf8")).toBe("hello");
expect(fs.lstatSync(link).isSymbolicLink()).toBe(true);
});
// The case that aborted the whole process before: fs.cp does not throw on a
// dangling link it must turn into a directory, it terminates the runtime.
it("creates the target of a dangling link nested in the destination", () => {
const src = stageSource();
const dest = path.join(tmpDir, "dest");
fs.mkdirSync(dest);
fs.symlinkSync(path.join("..", "canonical-sub"), path.join(dest, "sub"));
copyDir(src, dest);
expect(fs.readFileSync(path.join(tmpDir, "canonical-sub", "b.txt"), "utf8")).toBe("world");
});
// A relative link target resolves against the directory the link really
// lives in — here `dotfiles/claude`, not the `home/.claude` path we were
// handed — so `..` has to be walked from the real parent.
it("resolves a dangling relative target from behind a symlinked parent", () => {
const src = stageSource();
fs.mkdirSync(path.join(tmpDir, "dotfiles", "claude"), { recursive: true });
fs.mkdirSync(path.join(tmpDir, "dotfiles", "shared"), { recursive: true });
fs.mkdirSync(path.join(tmpDir, "home"), { recursive: true });
fs.symlinkSync(path.join("..", "dotfiles", "claude"), path.join(tmpDir, "home", ".claude"));
fs.symlinkSync(
path.join("..", "shared", "rules"),
path.join(tmpDir, "dotfiles", "claude", "rules")
);
const written = copyDir(src, path.join(tmpDir, "home", ".claude", "rules"));
expect(written).toBe(path.join(fs.realpathSync(tmpDir), "dotfiles", "shared", "rules"));
expect(fs.existsSync(path.join(tmpDir, "home", ".claude", "rules", "a.txt"))).toBe(true);
});
it("follows a chain of destination links to its end", () => {
const src = stageSource();
const real = path.join(tmpDir, "canonical");
fs.mkdirSync(real);
fs.symlinkSync("canonical", path.join(tmpDir, "hop"));
fs.symlinkSync("hop", path.join(tmpDir, "dest"));
expect(copyDir(src, path.join(tmpDir, "dest"))).toBe(fs.realpathSync(real));
expect(fs.readFileSync(path.join(real, "a.txt"), "utf8")).toBe("hello");
});
// Completing a link is a courtesy for a canonical directory the user has
// already made — not a licence to build a tree wherever a link points.
it("refuses a dangling link whose parent does not exist, creating nothing", () => {
const src = stageSource();
const link = path.join(tmpDir, "dest");
fs.symlinkSync(path.join("nowhere", "canonical"), link);
expect(() => copyDir(src, link)).toThrow();
expect(fs.existsSync(path.join(tmpDir, "nowhere"))).toBe(false);
});
// Guards a design that resolved these itself and handed the result to
// fs.cp, which terminates the runtime on a dangling link rather than
// throwing. Left to fs.cp, a leaf link is a plain catchable error.
it("reports a dangling chain on a destination file without crashing", () => {
const src = stageSource();
const dest = path.join(tmpDir, "dest");
fs.mkdirSync(path.join(tmpDir, "neutral"), { recursive: true });
fs.mkdirSync(dest);
fs.symlinkSync(path.join("..", "neutral", "a.txt"), path.join(dest, "a.txt"));
fs.symlinkSync(path.join("..", "gone", "a.txt"), path.join(tmpDir, "neutral", "a.txt"));
expect(() => copyDir(src, dest)).toThrow();
});
it("refuses a destination link pointing at a file, without touching it", () => {
const src = stageSource();
const file = path.join(tmpDir, "occupied");
const link = path.join(tmpDir, "dest");
fs.writeFileSync(file, "mine");
fs.symlinkSync("occupied", link);
expect(() => copyDir(src, link)).toThrow(expect.objectContaining({ code: "EEXIST" }));
expect(fs.readFileSync(file, "utf8")).toBe("mine");
});
});
it("overwrites an existing destination on a repeat copy", () => {
const src = stageSource();
const dest = path.join(tmpDir, "dest");
copyDir(src, dest);
fs.writeFileSync(path.join(src, "a.txt"), "second");
copyDir(src, dest);
expect(fs.readFileSync(path.join(dest, "a.txt"), "utf8")).toBe("second");
expect(fs.readFileSync(path.join(dest, "sub", "b.txt"), "utf8")).toBe("world");
});
// Nothing sensible asks for this, but the walk must not chase its own output
// down an unbounded path, and must not truncate a file by copying it over
// itself.
it("does not burrow when the destination is inside the source", () => {
const src = stageSource();
copyDir(src, path.join(src, "sub"));
expect(fs.existsSync(path.join(src, "sub", "sub", "sub"))).toBe(false);
expect(fs.readFileSync(path.join(src, "a.txt"), "utf8")).toBe("hello");
expect(fs.readFileSync(path.join(src, "sub", "b.txt"), "utf8")).toBe("world");
});
// Creating a directory is what fs.cp cannot survive, so the copy does it.
it("reports an unwritable destination instead of dying", () => {
const src = stageSource();
const dest = path.join(tmpDir, "dest");
fs.mkdirSync(dest);
fs.chmodSync(dest, 0o555);
try {
expect(() => copyDir(src, dest)).toThrow(expect.objectContaining({ code: "EACCES" }));
} finally {
fs.chmodSync(dest, 0o755);
}
});
});
// ── dirExists ─────────────────────────────────────────────────────────────────
describe("dirExists", () => {
it("returns true for existing directory", () => {
expect(dirExists(tmpDir)).toBe(true);
});
it("returns false for non-existent path", () => {
expect(dirExists(path.join(tmpDir, "nope"))).toBe(false);
});
it("returns false for a file", () => {
const file = path.join(tmpDir, "file.txt");
fs.writeFileSync(file, "");
expect(dirExists(file)).toBe(false);
});
});
// ── resolveProjectRoot ────────────────────────────────────────────────────────
describe("resolveProjectRoot", () => {
it("returns the nearest managed project root", () => {
const projectRoot = path.join(tmpDir, "project");
const nestedDir = path.join(projectRoot, "src", "deep");
fs.mkdirSync(path.join(projectRoot, ".claude"), { recursive: true });
fs.mkdirSync(nestedDir, { recursive: true });
expect(resolveProjectRoot(nestedDir)).toBe(projectRoot);
});
it("prefers a nearer managed root over an ancestor git root", () => {
const repoRoot = path.join(tmpDir, "repo");
const nestedProjectRoot = path.join(repoRoot, "packages", "app");
const nestedDir = path.join(nestedProjectRoot, "src");
fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true });
fs.mkdirSync(path.join(nestedProjectRoot, ".cursor"), { recursive: true });
fs.mkdirSync(nestedDir, { recursive: true });
expect(resolveProjectRoot(nestedDir)).toBe(nestedProjectRoot);
});
it("falls back to git root when no managed markers exist", () => {
const repoRoot = path.join(tmpDir, "repo");
const nestedDir = path.join(repoRoot, "src");
fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true });
fs.mkdirSync(nestedDir, { recursive: true });
expect(resolveProjectRoot(nestedDir)).toBe(repoRoot);
});
it("falls back to the starting directory when no markers exist", () => {
const nestedDir = path.join(tmpDir, "plain", "src");
fs.mkdirSync(nestedDir, { recursive: true });
expect(resolveProjectRoot(nestedDir)).toBe(nestedDir);
});
it("recognizes opencode.jsonc as a project root marker", () => {
const projectRoot = path.join(tmpDir, "project");
const nestedDir = path.join(projectRoot, "src");
fs.mkdirSync(nestedDir, { recursive: true });
fs.writeFileSync(path.join(projectRoot, "opencode.jsonc"), "{}");
expect(resolveProjectRoot(nestedDir)).toBe(projectRoot);
});
});
// ── detectPackageManager ──────────────────────────────────────────────────────
describe("detectPackageManager", () => {
const original = process.env.npm_config_user_agent;
afterEach(() => {
if (original === undefined) delete process.env.npm_config_user_agent;
else process.env.npm_config_user_agent = original;
});
it("returns npm when agent is unset", () => {
delete process.env.npm_config_user_agent;
expect(detectPackageManager()).toBe("npm");
});
it("returns yarn for yarn agent", () => {
process.env.npm_config_user_agent = "yarn/4.0.0";
expect(detectPackageManager()).toBe("yarn");
});
it("returns pnpm for pnpm agent", () => {
process.env.npm_config_user_agent = "pnpm/9.0.0";
expect(detectPackageManager()).toBe("pnpm");
});
it("returns bun for bun agent", () => {
process.env.npm_config_user_agent = "bun/1.0.0";
expect(detectPackageManager()).toBe("bun");
});
});
// ── globalInstallCommand / globalUninstallCommand ─────────────────────────────
describe("globalInstallCommand", () => {
it("npm", () => {
expect(globalInstallCommand("npm", "pkg")).toEqual({
bin: "npm",
args: ["install", "-g", "pkg"],
});
});
it("yarn", () => {
expect(globalInstallCommand("yarn", "pkg")).toEqual({
bin: "yarn",
args: ["global", "add", "pkg"],
});
});
it("pnpm", () => {
expect(globalInstallCommand("pnpm", "pkg")).toEqual({
bin: "pnpm",
args: ["add", "-g", "pkg"],
});
});
it("bun", () => {
expect(globalInstallCommand("bun", "pkg")).toEqual({ bin: "bun", args: ["add", "-g", "pkg"] });
});
it("preserves paths with spaces", () => {
const cmd = globalInstallCommand("npm", "/path/with spaces/pkg.tgz");
expect(cmd.args[2]).toBe("/path/with spaces/pkg.tgz");
});
});
describe("globalUninstallCommand", () => {
it("npm", () => {
expect(globalUninstallCommand("npm", "pkg")).toEqual({
bin: "npm",
args: ["uninstall", "-g", "pkg"],
});
});
it("yarn", () => {
expect(globalUninstallCommand("yarn", "pkg")).toEqual({
bin: "yarn",
args: ["global", "remove", "pkg"],
});
});
it("pnpm", () => {
expect(globalUninstallCommand("pnpm", "pkg")).toEqual({
bin: "pnpm",
args: ["remove", "-g", "pkg"],
});
});
it("bun", () => {
expect(globalUninstallCommand("bun", "pkg")).toEqual({
bin: "bun",
args: ["remove", "-g", "pkg"],
});
});
});
// ── formatShellCommand ───────────────────────────────────────────────────────
describe("formatShellCommand", () => {
it("joins bin and args", () => {
expect(formatShellCommand({ bin: "npm", args: ["install", "-g", "pkg"] })).toBe(
"npm install -g pkg"
);
});
it("quotes args that contain spaces", () => {
expect(
formatShellCommand({ bin: "npm", args: ["install", "-g", "/path/with spaces/pkg.tgz"] })
).toBe('npm install -g "/path/with spaces/pkg.tgz"');
});
});
// ── Bundled paths ─────────────────────────────────────────────────────────────
describe("bundled paths", () => {
it("SKILLS_DIR is a string ending with skills", () => {
expect(SKILLS_DIR).toMatch(/skills$/);
});
it("RULES_DIR is a string ending with rules", () => {
expect(RULES_DIR).toMatch(/rules$/);
});
it("AGENTS_DIR is a string ending with agents", () => {
expect(AGENTS_DIR).toMatch(/agents$/);
});
});
// ── isNewerVersion ───────────────────────────────────────────────────────────
describe("isNewerVersion", () => {
it("returns true when candidate is a higher patch", () => {
expect(isNewerVersion("0.5.3", "0.5.2")).toBe(true);
});
it("returns true when candidate is a higher minor", () => {
expect(isNewerVersion("0.6.0", "0.5.9")).toBe(true);
});
it("returns true when candidate is a higher major", () => {
expect(isNewerVersion("1.0.0", "0.9.9")).toBe(true);
});
it("returns false when versions are equal", () => {
expect(isNewerVersion("0.5.3", "0.5.3")).toBe(false);
});
it("returns false when candidate is older — the bug fix", () => {
// Before the fix init.ts used `latest !== version`, which prompted a
// "downgrade" when running a local prerelease newer than npm's latest.
expect(isNewerVersion("0.5.2", "0.5.3")).toBe(false);
});
it("treats a prerelease as older than the matching release", () => {
expect(isNewerVersion("0.5.3-alpha.1", "0.5.3")).toBe(false);
expect(isNewerVersion("0.5.3", "0.5.3-alpha.1")).toBe(true);
});
it("still allows upgrades from a prerelease to a newer release", () => {
expect(isNewerVersion("0.5.4", "0.5.4-beta.0")).toBe(true);
});
});
// ── listBundledSkills ────────────────────────────────────────────────────────
describe("listBundledSkills", () => {
it("returns an empty list for a non-existent directory", () => {
expect(listBundledSkills(path.join(tmpDir, "does-not-exist"))).toEqual([]);
});
it("returns only subdirectories that contain a SKILL.md", () => {
const skillsDir = path.join(tmpDir, "skills");
fs.mkdirSync(path.join(skillsDir, "argent-alpha"), { recursive: true });
fs.writeFileSync(path.join(skillsDir, "argent-alpha", "SKILL.md"), "# alpha");
fs.mkdirSync(path.join(skillsDir, "argent-beta"), { recursive: true });
fs.writeFileSync(path.join(skillsDir, "argent-beta", "SKILL.md"), "# beta");
// An orphan directory without SKILL.md must be excluded — it is not a skill.
fs.mkdirSync(path.join(skillsDir, "not-a-skill"), { recursive: true });
// Stray files at the top level must also be excluded.
fs.writeFileSync(path.join(skillsDir, "README.md"), "");
expect(listBundledSkills(skillsDir)).toEqual(["argent-alpha", "argent-beta"]);
});
it("returns results in a stable sorted order", () => {
const skillsDir = path.join(tmpDir, "skills");
for (const name of ["zulu", "alpha", "mike"]) {
fs.mkdirSync(path.join(skillsDir, name), { recursive: true });
fs.writeFileSync(path.join(skillsDir, name, "SKILL.md"), "");
}
expect(listBundledSkills(skillsDir)).toEqual(["alpha", "mike", "zulu"]);
});
});
// ── skills lock helpers ──────────────────────────────────────────────────────
describe("getProjectSkillLockPath", () => {
it("resolves to skills-lock.json under the provided cwd", () => {
expect(getProjectSkillLockPath("/some/project")).toBe("/some/project/skills-lock.json");
});
});
describe("getGlobalSkillLockPath", () => {
const originalXdg = process.env.XDG_STATE_HOME;
afterEach(() => {
if (originalXdg === undefined) delete process.env.XDG_STATE_HOME;
else process.env.XDG_STATE_HOME = originalXdg;
});
it("falls back to ~/.agents/.skill-lock.json when XDG_STATE_HOME is unset", () => {
delete process.env.XDG_STATE_HOME;
expect(getGlobalSkillLockPath()).toBe(path.join(os.homedir(), ".agents", ".skill-lock.json"));
});
it("uses $XDG_STATE_HOME/skills/.skill-lock.json when set", () => {
process.env.XDG_STATE_HOME = "/tmp/xdg";
expect(getGlobalSkillLockPath()).toBe("/tmp/xdg/skills/.skill-lock.json");
});
});
describe("listArgentSkillsInLock", () => {
it("returns an empty list when the lock file does not exist", () => {
expect(listArgentSkillsInLock(path.join(tmpDir, "missing.json"))).toEqual([]);
});
it("returns an empty list for a malformed JSON lock", () => {
const lockPath = path.join(tmpDir, "bad.json");
fs.writeFileSync(lockPath, "not json");
expect(listArgentSkillsInLock(lockPath)).toEqual([]);
});
it("returns only skills whose name starts with argent-", () => {
const lockPath = path.join(tmpDir, "lock.json");
fs.writeFileSync(
lockPath,
JSON.stringify({
version: 1,
skills: {
"argent-create-flow": {},
"argent-old-workflow": {}, // still in lock even if no longer bundled
"some-other-skill": {},
"vercel-labs/agent-skills": {},
},
})
);
// Result is sorted so callers can rely on a stable order.
expect(listArgentSkillsInLock(lockPath)).toEqual(["argent-create-flow", "argent-old-workflow"]);
});
it("returns an empty list when the lock has no skills object", () => {
const lockPath = path.join(tmpDir, "empty.json");
fs.writeFileSync(lockPath, JSON.stringify({ version: 1 }));
expect(listArgentSkillsInLock(lockPath)).toEqual([]);
});
it("returns an empty list when no argent-prefixed entry is tracked", () => {
const lockPath = path.join(tmpDir, "lock.json");
fs.writeFileSync(lockPath, JSON.stringify({ version: 1, skills: { "other-skill": {} } }));
expect(listArgentSkillsInLock(lockPath)).toEqual([]);
});
});
// ── isOnline ──────────────────────────────────────────────────────────────────
// `isOnline` wraps `dns.lookup` with a timeout. All tests below run against
// the mocked `dns.lookup` set up at the top of this file — they never touch
// real DNS, which keeps them deterministic on offline runners and CI machines
// that deny outbound network access.
describe("isOnline", () => {
beforeEach(() => {
dnsLookupMock.mockReset();
});
it("returns true when DNS resolution succeeds", async () => {
dnsLookupMock.mockImplementation((_host: string, callback: (err: Error | null) => void) => {
setImmediate(() => callback(null));
});
await expect(isOnline()).resolves.toBe(true);
expect(dnsLookupMock).toHaveBeenCalledTimes(1);
});
it("returns false when DNS resolution errors", async () => {
dnsLookupMock.mockImplementation((_host: string, callback: (err: Error | null) => void) => {
setImmediate(() => callback(Object.assign(new Error("ENOTFOUND"), { code: "ENOTFOUND" })));
});
await expect(isOnline()).resolves.toBe(false);
});
it("returns false when DNS never responds before the timeout", async () => {
dnsLookupMock.mockImplementation(() => {
// Never invoke the callback — simulate a hanging DNS query.
});
const start = Date.now();
const result = await isOnline(30);
const elapsed = Date.now() - start;
expect(result).toBe(false);
expect(elapsed).toBeGreaterThanOrEqual(25);
expect(elapsed).toBeLessThan(500);
});
it("looks up the hostname from NPM_REGISTRY", async () => {
const expectedHost = new URL(NPM_REGISTRY).hostname;
dnsLookupMock.mockImplementation((_host: string, callback: (err: Error | null) => void) => {
setImmediate(() => callback(null));
});
await isOnline();
expect(dnsLookupMock).toHaveBeenCalledWith(expectedHost, expect.any(Function));
});
it("settles once even if DNS responds after the timeout has already fired", async () => {
let dnsCallback: ((err: Error | null) => void) | null = null;
dnsLookupMock.mockImplementation((_host: string, callback: (err: Error | null) => void) => {
dnsCallback = callback;
});
const result = await isOnline(10);
expect(result).toBe(false);
// Late DNS callback must not throw, log, or re-resolve the already-
// settled promise. This mirrors what happens when DNS responds after
// we have already given up waiting.
expect(() => dnsCallback?.(null)).not.toThrow();
});
});
// ── isSkillsCliAvailable ─────────────────────────────────────────────────────
describe("isSkillsCliAvailable", () => {
beforeEach(() => {
execSyncMock.mockReset();
});
it("probes `npx --force --no-install skills --version` and returns true on success", () => {
execSyncMock.mockReturnValue(Buffer.from("0.1.0\n"));
expect(isSkillsCliAvailable()).toBe(true);
expect(execSyncMock).toHaveBeenCalledTimes(1);
const [cmd] = execSyncMock.mock.calls[0]!;
// `--force` softens the host project's npm engine gate so the probe can't
// hard-fail with EBADDEVENGINES in a devEngines-pinned repo (#298).
expect(cmd).toBe("npx --force --no-install skills --version");
});
it("returns false when the probe throws (skills CLI not in npx cache)", () => {
execSyncMock.mockImplementation(() => {
throw new Error("command failed");
});
expect(isSkillsCliAvailable()).toBe(false);
});
it("fully silences stdio so nothing leaks to the terminal", () => {
execSyncMock.mockReturnValue(Buffer.from(""));
isSkillsCliAvailable();
const opts = execSyncMock.mock.calls[0]![1] as
| { stdio?: [unknown, unknown, unknown] }
| undefined;
expect(opts?.stdio).toEqual(["ignore", "ignore", "ignore"]);
});
it("passes a timeout so a wedged npx cannot hang init forever", () => {
execSyncMock.mockReturnValue(Buffer.from(""));
isSkillsCliAvailable();
const opts = execSyncMock.mock.calls[0]![1] as { timeout?: number } | undefined;
expect(typeof opts?.timeout).toBe("number");
expect(opts!.timeout!).toBeGreaterThan(0);
});
});
// ── localInstallCommand / localUninstallCommand ──────────────────────────────
describe("localInstallCommand", () => {
it("uses --save-dev for npm", () => {
expect(localInstallCommand("npm", "pkg")).toEqual({
bin: "npm",
args: ["install", "--save-dev", "pkg"],
});
});
it("uses add --dev for yarn", () => {
expect(localInstallCommand("yarn", "pkg")).toEqual({
bin: "yarn",
args: ["add", "--dev", "pkg"],
});
});
it("uses add -D for pnpm", () => {
expect(localInstallCommand("pnpm", "pkg")).toEqual({ bin: "pnpm", args: ["add", "-D", "pkg"] });
});
it("uses add -d for bun", () => {
expect(localInstallCommand("bun", "pkg")).toEqual({ bin: "bun", args: ["add", "-d", "pkg"] });
});
});
describe("localUninstallCommand", () => {
it("uses uninstall (project) for npm — not -g", () => {
expect(localUninstallCommand("npm", "pkg")).toEqual({ bin: "npm", args: ["uninstall", "pkg"] });
});
it("uses remove for yarn/pnpm/bun without -g", () => {
expect(localUninstallCommand("yarn", "pkg")).toEqual({ bin: "yarn", args: ["remove", "pkg"] });
expect(localUninstallCommand("pnpm", "pkg")).toEqual({ bin: "pnpm", args: ["remove", "pkg"] });
expect(localUninstallCommand("bun", "pkg")).toEqual({ bin: "bun", args: ["remove", "pkg"] });
});
});
// ── detectProjectPackageManager ──────────────────────────────────────────────
describe("detectProjectPackageManager", () => {
// With no marker found the walk falls back to detectPackageManager(), which
// reads npm_config_user_agent — the agent of whichever package manager is
// running the suite. Pin it so the boundary case below tests the walk rather
// than the contributor's package manager.
const originalAgent = process.env.npm_config_user_agent;
beforeEach(() => {
delete process.env.npm_config_user_agent;
});
afterEach(() => {
if (originalAgent === undefined) delete process.env.npm_config_user_agent;
else process.env.npm_config_user_agent = originalAgent;
});
it("detects pnpm from pnpm-lock.yaml", () => {
fs.writeFileSync(path.join(tmpDir, "pnpm-lock.yaml"), "");
expect(detectProjectPackageManager(tmpDir)).toBe("pnpm");
});
it("detects yarn from yarn.lock", () => {
fs.writeFileSync(path.join(tmpDir, "yarn.lock"), "");
expect(detectProjectPackageManager(tmpDir)).toBe("yarn");
});
it("detects bun from bun.lockb", () => {
fs.writeFileSync(path.join(tmpDir, "bun.lockb"), "");
expect(detectProjectPackageManager(tmpDir)).toBe("bun");
});
it("detects npm from package-lock.json", () => {
fs.writeFileSync(path.join(tmpDir, "package-lock.json"), "{}");
expect(detectProjectPackageManager(tmpDir)).toBe("npm");
});
it("pnpm wins over yarn when both lockfiles exist", () => {
fs.writeFileSync(path.join(tmpDir, "pnpm-lock.yaml"), "");
fs.writeFileSync(path.join(tmpDir, "yarn.lock"), "");
expect(detectProjectPackageManager(tmpDir)).toBe("pnpm");
});
it("falls back to a valid PM when no lockfile is present", () => {
expect(detectProjectPackageManager(tmpDir)).toBe("npm");
});
it("honors the corepack packageManager field over lockfiles", () => {
fs.writeFileSync(path.join(tmpDir, "yarn.lock"), "");
fs.writeFileSync(
path.join(tmpDir, "package.json"),
JSON.stringify({ packageManager: "pnpm@9.1.0" })
);
expect(detectProjectPackageManager(tmpDir)).toBe("pnpm");
});
it("honors devEngines.packageManager (pnpm init default, pre-first-install)", () => {
// `pnpm init` (pnpm 10+) writes ONLY devEngines — no packageManager
// field, and no lockfile exists before the first install.
fs.writeFileSync(
path.join(tmpDir, "package.json"),
JSON.stringify({
devEngines: { packageManager: { name: "pnpm", version: "^11.0.0", onFail: "download" } },
})
);
expect(detectProjectPackageManager(tmpDir)).toBe("pnpm");
});
it("honors devEngines.packageManager in array form", () => {
fs.writeFileSync(
path.join(tmpDir, "package.json"),
JSON.stringify({ devEngines: { packageManager: [{ name: "bun" }] } })
);
expect(detectProjectPackageManager(tmpDir)).toBe("bun");
});
it("ignores a devEngines packageManager with an unknown name", () => {
fs.writeFileSync(
path.join(tmpDir, "package.json"),
JSON.stringify({ devEngines: { packageManager: { name: "vlt" } } })
);
fs.writeFileSync(path.join(tmpDir, "yarn.lock"), "");
expect(detectProjectPackageManager(tmpDir)).toBe("yarn");
});
it("a real lockfile outranks devEngines (an OR-set of acceptable managers, not the project's choice)", () => {
// The common EBADDEVENGINES workaround declares npm AND pnpm acceptable;
// taking devEngines[0] would run `npm install` in a pnpm repo and stale
// pnpm-lock.yaml. The lockfile records what the project actually uses.
fs.writeFileSync(
path.join(tmpDir, "package.json"),
JSON.stringify({ devEngines: { packageManager: [{ name: "npm" }, { name: "pnpm" }] } })
);
fs.writeFileSync(path.join(tmpDir, "pnpm-lock.yaml"), "");
expect(detectProjectPackageManager(tmpDir)).toBe("pnpm");
});
it("a multi-entry devEngines array alone identifies nothing (falls through)", () => {
fs.writeFileSync(
path.join(tmpDir, "package.json"),
JSON.stringify({ devEngines: { packageManager: [{ name: "npm" }, { name: "pnpm" }] } })
);
// No lockfile: nothing identifies a single manager, so detection falls
// back to the user-agent default rather than guessing from entry order.
expect(detectProjectPackageManager(tmpDir)).toBe("npm");
});
it("a stale devEngines declaration does not outrank the lockfile (pnpm→yarn/bun migration)", () => {
// yarn and bun neither read nor update devEngines, so a `pnpm init`-era
// declaration survives a migration forever while the lockfile moves on.
fs.writeFileSync(
path.join(tmpDir, "package.json"),
JSON.stringify({ devEngines: { packageManager: { name: "pnpm", version: "^11.0.0" } } })
);
fs.writeFileSync(path.join(tmpDir, "yarn.lock"), "");
expect(detectProjectPackageManager(tmpDir)).toBe("yarn");
});
it("duplicate devEngines entries naming one manager still identify it", () => {
fs.writeFileSync(
path.join(tmpDir, "package.json"),
JSON.stringify({
devEngines: { packageManager: [{ name: "pnpm", version: "^10" }, { name: "pnpm" }] },
})
);
expect(detectProjectPackageManager(tmpDir)).toBe("pnpm");
});
it("the corepack packageManager field still outranks everything", () => {
fs.writeFileSync(
path.join(tmpDir, "package.json"),
JSON.stringify({
packageManager: "bun@1.2.0",
devEngines: { packageManager: { name: "pnpm" } },
})
);
fs.writeFileSync(path.join(tmpDir, "yarn.lock"), "");
expect(detectProjectPackageManager(tmpDir)).toBe("bun");
});
it("detects pnpm from pnpm-workspace.yaml when no lockfile exists yet", () => {
fs.writeFileSync(path.join(tmpDir, "pnpm-workspace.yaml"), "packages:\n - packages/*\n");
expect(detectProjectPackageManager(tmpDir)).toBe("pnpm");
});
it("a real lockfile outranks a stray pnpm-workspace.yaml (pnpm→yarn migration)", () => {
fs.writeFileSync(path.join(tmpDir, "pnpm-workspace.yaml"), "packages:\n - packages/*\n");
fs.writeFileSync(path.join(tmpDir, "yarn.lock"), "");
expect(detectProjectPackageManager(tmpDir)).toBe("yarn");
});
it("walks up to a workspace-root lockfile (monorepo sub-package)", () => {
// pnpm/yarn workspaces keep the single lockfile at the monorepo root.
fs.writeFileSync(path.join(tmpDir, "pnpm-lock.yaml"), "");
const sub = path.join(tmpDir, "packages", "app");
fs.mkdirSync(sub, { recursive: true });
fs.writeFileSync(path.join(sub, "package.json"), "{}");
expect(detectProjectPackageManager(sub)).toBe("pnpm");
});
it("stops the upward walk at a repo boundary (.git)", () => {
fs.writeFileSync(path.join(tmpDir, "pnpm-lock.yaml"), "");
const repo = path.join(tmpDir, "other-repo");
fs.mkdirSync(path.join(repo, ".git"), { recursive: true });
// The sibling repo has no lockfile of its own; the outer pnpm lockfile
// must NOT bleed through the .git boundary, so the walk stops and falls
// back to the (pinned-unset) agent default.
expect(detectProjectPackageManager(repo)).toBe("npm");
});
});
// ── Local install detection ──────────────────────────────────────────────────
describe("hasProjectPackageJson", () => {
it("is false without and true with a package.json", () => {
expect(hasProjectPackageJson(tmpDir)).toBe(false);
fs.writeFileSync(path.join(tmpDir, "package.json"), "{}");
expect(hasProjectPackageJson(tmpDir)).toBe(true);
});
});
describe("isYarnPnp", () => {
it("detects .pnp.cjs and .pnp.loader.mjs", () => {
expect(isYarnPnp(tmpDir)).toBe(false);
fs.writeFileSync(path.join(tmpDir, ".pnp.cjs"), "");
expect(isYarnPnp(tmpDir)).toBe(true);
fs.rmSync(path.join(tmpDir, ".pnp.cjs"));
fs.writeFileSync(path.join(tmpDir, ".pnp.loader.mjs"), "");
expect(isYarnPnp(tmpDir)).toBe(true);
});
});
describe("isLocallyInstalled / getLocallyInstalledVersion", () => {
it("is false / null when not installed", () => {
expect(isLocallyInstalled(tmpDir)).toBe(false);
expect(getLocallyInstalledVersion(tmpDir)).toBeNull();
});
it("is true and reads version from node_modules package.json", () => {
stageLocalArgent(tmpDir, { version: "9.9.9" });
expect(isLocallyInstalled(tmpDir)).toBe(true);
expect(getLocallyInstalledVersion(tmpDir)).toBe("9.9.9");
});
});
describe("getLocalArgentBinRelPath", () => {
it("returns null when not installed", () => {
expect(getLocalArgentBinRelPath(tmpDir)).toBeNull();
});
it("derives the bin path from the package.json bin map", () => {
const rel = stageLocalArgent(tmpDir);
expect(getLocalArgentBinRelPath(tmpDir)).toBe(rel);
expect(getLocalArgentBinRelPath(tmpDir)).toBe(`node_modules/${PACKAGE_NAME}/dist/cli.js`);
});
it("supports a string bin field", () => {
stageLocalArgent(tmpDir, { bin: "dist/cli.js" });
expect(getLocalArgentBinRelPath(tmpDir)).toBe(`node_modules/${PACKAGE_NAME}/dist/cli.js`);
});
it("returns null when the resolved bin file is missing on disk", () => {
stageLocalArgent(tmpDir, { withBinFile: false });
expect(getLocalArgentBinRelPath(tmpDir)).toBeNull();
});
it("uses forward slashes (committable / cross-platform)", () => {
stageLocalArgent(tmpDir);
expect(getLocalArgentBinRelPath(tmpDir)).not.toContain("\\");
});
it.skipIf(process.platform === "win32")(
"commits the stable node_modules path, not the pnpm version-pinned store dir",
() => {
// pnpm layout: node_modules/<pkg> symlinks into a version-suffixed .pnpm
// store dir, and module resolution returns that realpath. Committing it
// would bake the version into the MCP command; the stable symlink path wins.
const storeDir = path.join(
tmpDir,
"node_modules",
".pnpm",
"@swmansion+argent@0.13.0",
"node_modules",
PACKAGE_NAME
);
fs.mkdirSync(path.join(storeDir, "dist"), { recursive: true });
fs.writeFileSync(
path.join(storeDir, "package.json"),
JSON.stringify({ name: PACKAGE_NAME, version: "0.13.0", bin: { argent: "dist/cli.js" } })
);
fs.writeFileSync(path.join(storeDir, "dist", "cli.js"), "#!/usr/bin/env node\n");
const linkPath = path.join(tmpDir, "node_modules", PACKAGE_NAME);
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
fs.symlinkSync(storeDir, linkPath);
const rel = getLocalArgentBinRelPath(tmpDir);
expect(rel).toBe(`node_modules/${PACKAGE_NAME}/dist/cli.js`);
expect(rel).not.toContain(".pnpm");
expect(rel).not.toContain("0.13.0");
}
);
});
describe("probeLocalInstall / isDeclaredLocally", () => {
it("resolves a hoisted install from a sub-package (workspace layout)", () => {
// The package lives in the workspace-root node_modules; the project root
// is a sub-package. A hardcoded <root>/node_modules probe misses this.
stageLocalArgent(tmpDir, { version: "3.2.1" });
const sub = path.join(tmpDir, "packages", "app");
fs.mkdirSync(sub, { recursive: true });
fs.writeFileSync(path.join(sub, "package.json"), "{}");
const probe = probeLocalInstall(sub);
expect(probe.installed).toBe(true);
expect(probe.version).toBe("3.2.1");
expect(probe.packageDir).toBeTruthy();
});
it("treats a declared dep under Yarn PnP as installed (no node_modules)", () => {
fs.writeFileSync(path.join(tmpDir, ".pnp.cjs"), "");
fs.writeFileSync(
path.join(tmpDir, "package.json"),
JSON.stringify({ devDependencies: { [PACKAGE_NAME]: "1.4.0" } })
);
const probe = probeLocalInstall(tmpDir);
expect(probe.installed).toBe(true);
expect(probe.version).toBe("1.4.0"); // exact specifier doubles as the version
expect(probe.packageDir).toBeNull();
});
it("PnP with a range specifier is installed but version-unknown", () => {
fs.writeFileSync(path.join(tmpDir, ".pnp.cjs"), "");
fs.writeFileSync(
path.join(tmpDir, "package.json"),
JSON.stringify({ devDependencies: { [PACKAGE_NAME]: "^1.4.0" } })
);
const probe = probeLocalInstall(tmpDir);
expect(probe.installed).toBe(true);
expect(probe.version).toBeNull();
});
it("PnP without a declaration is not installed", () => {
fs.writeFileSync(path.join(tmpDir, ".pnp.cjs"), "");
fs.writeFileSync(path.join(tmpDir, "package.json"), "{}");
expect(probeLocalInstall(tmpDir).installed).toBe(false);
});
it("isDeclaredLocally reads dependencies and devDependencies", () => {
expect(isDeclaredLocally(tmpDir)).toBe(false);
fs.writeFileSync(
path.join(tmpDir, "package.json"),
JSON.stringify({ dependencies: { [PACKAGE_NAME]: "*" } })
);
expect(isDeclaredLocally(tmpDir)).toBe(true);
});
});
// ── Install-mode record (.argent/install.json) ───────────────────────────────
describe("install record", () => {
it("getInstallRecordPath points at <root>/.argent/install.json", () => {
expect(getInstallRecordPath(tmpDir)).toBe(path.join(tmpDir, ".argent", "install.json"));
});
it("write then read round-trips", () => {
writeInstallRecord(tmpDir, { mode: "local", package: PACKAGE_NAME, writtenBy: "1.0.0" });
expect(readInstallRecord(tmpDir)).toEqual({
mode: "local",
package: PACKAGE_NAME,
writtenBy: "1.0.0",
});
});
it("read returns null on a missing or malformed file", () => {
expect(readInstallRecord(tmpDir)).toBeNull();
fs.mkdirSync(path.join(tmpDir, ".argent"), { recursive: true });
fs.writeFileSync(getInstallRecordPath(tmpDir), "{ not json");
expect(readInstallRecord(tmpDir)).toBeNull();
});
it("read rejects an unknown mode value", () => {
fs.mkdirSync(path.join(tmpDir, ".argent"), { recursive: true });
fs.writeFileSync(getInstallRecordPath(tmpDir), JSON.stringify({ mode: "bogus" }));
expect(readInstallRecord(tmpDir)).toBeNull();
});
it("removeInstallRecord deletes the file and prunes an emptied .argent dir", () => {
writeInstallRecord(tmpDir, { mode: "local", package: PACKAGE_NAME });
expect(removeInstallRecord(tmpDir)).toBe(true);
expect(fs.existsSync(getInstallRecordPath(tmpDir))).toBe(false);
expect(fs.existsSync(path.join(tmpDir, ".argent"))).toBe(false);
expect(removeInstallRecord(tmpDir)).toBe(false);
});
it("removeInstallRecord keeps .argent when other files remain (e.g. flags.json)", () => {
writeInstallRecord(tmpDir, { mode: "local", package: PACKAGE_NAME });
fs.writeFileSync(path.join(tmpDir, ".argent", "flags.json"), "{}");
expect(removeInstallRecord(tmpDir)).toBe(true);
expect(fs.existsSync(path.join(tmpDir, ".argent"))).toBe(true);
});
});
describe("resolveInstallMode", () => {
const declareLocalArgent = (root: string) =>
fs.writeFileSync(
path.join(root, "package.json"),
JSON.stringify({ name: "host", devDependencies: { [PACKAGE_NAME]: "^1.2.3" } })
);
it("defaults to global with no record and no local install", () => {
expect(resolveInstallMode(tmpDir)).toBe("global");
});
it("does NOT infer local from mere node_modules presence (hoisted / transitive copy)", () => {
// A copy that merely exists in node_modules is not intent — acting on it
// would rewrite a manifest the user never opted into.
stageLocalArgent(tmpDir);
expect(resolveInstallMode(tmpDir)).toBe("global");
});
it("infers local from a dependency the project manifest declares (no record)", () => {
stageLocalArgent(tmpDir);
declareLocalArgent(tmpDir);
expect(resolveInstallMode(tmpDir)).toBe("local");
});
it("the committed record wins over inference", () => {
writeInstallRecord(tmpDir, { mode: "local", package: PACKAGE_NAME });
expect(resolveInstallMode(tmpDir)).toBe("local");
writeInstallRecord(tmpDir, { mode: "global", package: PACKAGE_NAME });
stageLocalArgent(tmpDir);
expect(resolveInstallMode(tmpDir)).toBe("global");
});
});
// ── resolveInstallModeFromFlags ──────────────────────────────────────────────
describe("resolveInstallModeFromFlags", () => {
it("returns local for --local (even with --yes)", () => {
expect(resolveInstallModeFromFlags({ local: true, global: false, nonInteractive: true })).toBe(
"local"
);
});
it("returns global for --global", () => {
expect(resolveInstallModeFromFlags({ local: false, global: true, nonInteractive: false })).toBe(
"global"
);
});
it("non-interactive with no flag defaults to global", () => {
expect(resolveInstallModeFromFlags({ local: false, global: false, nonInteractive: true })).toBe(
"global"
);
});
it("non-interactive honors a committed local record (no silent revert to global)", () => {
expect(
resolveInstallModeFromFlags({
local: false,
global: false,
nonInteractive: true,
recordedMode: "local",
})
).toBe("local");
});
it("an explicit --global still overrides a committed local record", () => {
expect(
resolveInstallModeFromFlags({
local: false,
global: true,
nonInteractive: true,
recordedMode: "local",
})
).toBe("global");
});
it("returns null (prompt) when interactive with no flag", () => {
expect(
resolveInstallModeFromFlags({ local: false, global: false, nonInteractive: false })
).toBeNull();
});
it("throws on conflicting --local and --global", () => {
expect(() =>
resolveInstallModeFromFlags({ local: true, global: true, nonInteractive: false })
).toThrow(InstallModeFlagError);
});
});