Files
software-mansion__argent/packages/tool-server/test/simulator-watcher-init-failure.test.ts
Mateusz Aliyev 6e9e45c9f0 feat: attach to devices offered by external providers (#735)
This pull request lets Argent drive a simulator or emulator another
process is already running, attaching to its simulator-server rather
than spawning a second one.

A provider writes a JSON descriptor to `~/.argent/providers/` listing
the devices it offers and the mechanisms Argent may use on each. They
appear in `list-devices` with an `ext:` id and work with the existing
tools. The file is re-read on every call, so a withdrawal or a narrowed
grant applies immediately.

`ios.additionalDeviceSets` (#600) already makes such a simulator
reachable by UDID. What changes is ownership. `boot-device` and
`stop-simulator-server` refuse these devices, anything the provider did
not grant is refused with a message naming it and Argent uses only the
endpoints its own simulator-server build serves. A grant binds to the
device rather than to one of its names, so the real udid or serial is
gated exactly like the `ext:` id. Android emulators are covered too and
a device visible to both a provider and `adb`/`simctl` is listed once.

The contract ships as `schemas/device-provider-v1.json`, validated by
`argent providers check`, and is documented in
`docs/reference/device-providers.mdx`. Includes unit tests and an e2e
phase where the harness acts as its own provider.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added support for discovering and connecting to externally managed
devices.
* Added `argent providers` commands for listing, validating, publishing,
withdrawing, and pruning providers.
* Added provider-aware simulator, debugger, profiler, native tools, and
device listings.
* Added capability controls, endpoint validation, revocation handling,
and provider-specific diagnostics.
  * Added automatic CLI discovery for provider integrations.
* **Bug Fixes**
  * Improved service recovery and paused-runtime error reporting.
  * Improved simulator keyboard and paste command reliability.
* **Documentation**
* Documented provider descriptors, capabilities, lifecycle rules, and
CLI usage.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-04 22:02:57 +02:00

233 lines
6.9 KiB
TypeScript

import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
const execFileMock = vi.fn();
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
execFile: (
cmd: string,
args: readonly string[],
opts: unknown,
cb?: (err: Error | null, out: { stdout: string; stderr: string }) => void
) => {
const callback = typeof opts === "function" ? opts : cb!;
const result = execFileMock(cmd, args);
if (result instanceof Error) {
const e = result as Error & { stderr?: string; stdout?: string };
callback(e, { stdout: e.stdout ?? "", stderr: e.stderr ?? "" });
} else {
callback(null, result ?? { stdout: "", stderr: "" });
}
},
};
});
import type { Registry } from "@argent/registry";
import {
MAX_NATIVE_DEVTOOLS_INIT_ATTEMPTS,
type NativeDevtoolsApi,
type NativeDevtoolsInitFailure,
} from "../src/blueprints/native-devtools";
import { startSimulatorWatcher } from "../src/utils/simulator-watcher";
const UDID = "11111111-1111-1111-1111-111111111111";
function bootedListResponse(udids: string[]): { stdout: string; stderr: string } {
return {
stdout: JSON.stringify({
devices: {
"com.apple.CoreSimulator.SimRuntime.iOS-17-0": udids.map((udid) => ({
udid,
state: "Booted",
})),
},
}),
stderr: "",
};
}
function makeFailingApi(): { api: NativeDevtoolsApi; ensureCalls: () => number } {
let initFailure: NativeDevtoolsInitFailure | null = null;
let calls = 0;
const api: NativeDevtoolsApi = {
isEnvSetup: () => false,
socketPath: "/tmp/mock.sock",
ensureEnvReady: async () => {
calls += 1;
if (initFailure?.givenUp) return;
const attempts = (initFailure?.attempts ?? 0) + 1;
initFailure = {
attempts,
lastError: "stub ensureEnv failure",
givenUp: attempts >= MAX_NATIVE_DEVTOOLS_INIT_ATTEMPTS,
};
throw new Error("stub ensureEnv failure");
},
reverifyEnv: async () => {},
armsEnv: true,
withdrawEnv: async () => {},
getInitFailure: () => initFailure,
isConnected: () => false,
isAppRunning: async () => false,
listConnectedBundleIds: () => [],
appConnectionState: async () => "stale_process",
activateNetworkInspection: () => {},
getNetworkLog: () => [],
clearNetworkLog: () => {},
getAppState: async () => {
throw new Error("not implemented");
},
detectFrontmostBundleId: async () => null,
queryViewHierarchy: async () => ({}),
};
return { api, ensureCalls: () => calls };
}
function makeRegistry(api: NativeDevtoolsApi): {
registry: Registry;
resolveService: ReturnType<typeof vi.fn>;
disposeService: ReturnType<typeof vi.fn>;
} {
// Mimic the registry: factory runs once, subsequent resolveService calls
// return the cached api. Attempt counts only go up when the watcher itself
// calls api.ensureEnvReady().
let factoryRan = false;
const resolveService = vi.fn(async () => {
if (!factoryRan) {
factoryRan = true;
await api.ensureEnvReady().catch(() => {});
}
return api;
});
const disposeService = vi.fn(async () => {
factoryRan = false;
});
return {
registry: { resolveService, disposeService } as unknown as Registry,
resolveService,
disposeService,
};
}
beforeEach(() => {
execFileMock.mockReset();
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("simulator-watcher with api-owned init failure state", () => {
it("stops calling ensureEnvReady after the api reports givenUp", async () => {
const bootedUdids: string[] = [UDID];
execFileMock.mockImplementation((cmd: string) => {
if (cmd === "xcrun") return bootedListResponse(bootedUdids);
return { stdout: "", stderr: "" };
});
const { api, ensureCalls } = makeFailingApi();
const { registry } = makeRegistry(api);
vi.useFakeTimers();
try {
const { ready, stop } = startSimulatorWatcher(registry);
await ready;
while ((api.getInitFailure()?.attempts ?? 0) < MAX_NATIVE_DEVTOOLS_INIT_ATTEMPTS) {
await vi.advanceTimersByTimeAsync(10_000);
}
const failure = api.getInitFailure();
expect(failure?.attempts).toBe(MAX_NATIVE_DEVTOOLS_INIT_ATTEMPTS);
expect(failure?.givenUp).toBe(true);
const callsAtCap = ensureCalls();
await vi.advanceTimersByTimeAsync(10_000);
await vi.advanceTimersByTimeAsync(10_000);
expect(ensureCalls()).toBe(callsAtCap);
stop();
} finally {
vi.useRealTimers();
}
});
it("makes no further ensureEnvReady calls for a healthy UDID after init", async () => {
const bootedUdids: string[] = [UDID];
execFileMock.mockImplementation((cmd: string) => {
if (cmd === "xcrun") return bootedListResponse(bootedUdids);
return { stdout: "", stderr: "" };
});
let calls = 0;
const api: NativeDevtoolsApi = {
isEnvSetup: () => true,
socketPath: "/tmp/mock.sock",
ensureEnvReady: async () => {
calls += 1;
},
reverifyEnv: async () => {},
armsEnv: true,
withdrawEnv: async () => {},
getInitFailure: () => null,
isConnected: () => false,
isAppRunning: async () => false,
listConnectedBundleIds: () => [],
appConnectionState: async () => "stale_process",
activateNetworkInspection: () => {},
getNetworkLog: () => [],
clearNetworkLog: () => {},
getAppState: async () => {
throw new Error("not implemented");
},
detectFrontmostBundleId: async () => null,
queryViewHierarchy: async () => ({}),
};
const { registry } = makeRegistry(api);
vi.useFakeTimers();
try {
const { ready, stop } = startSimulatorWatcher(registry);
await ready;
const callsAfterInit = calls;
await vi.advanceTimersByTimeAsync(10_000);
await vi.advanceTimersByTimeAsync(10_000);
expect(calls).toBe(callsAfterInit);
stop();
} finally {
vi.useRealTimers();
}
});
it("disposes the service when the UDID leaves the booted set", async () => {
let bootedUdids: string[] = [UDID];
execFileMock.mockImplementation((cmd: string) => {
if (cmd === "xcrun") return bootedListResponse(bootedUdids);
return { stdout: "", stderr: "" };
});
const { api } = makeFailingApi();
const { registry, disposeService } = makeRegistry(api);
vi.useFakeTimers();
try {
const { ready, stop } = startSimulatorWatcher(registry);
await ready;
expect(disposeService).not.toHaveBeenCalled();
bootedUdids = [];
await vi.advanceTimersByTimeAsync(10_000);
expect(disposeService).toHaveBeenCalledWith(`NativeDevtools:${UDID}`);
stop();
} finally {
vi.useRealTimers();
}
});
});