mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
6e9e45c9f0
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 -->
233 lines
6.9 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|