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 -->
751 lines
30 KiB
TypeScript
751 lines
30 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
const execFileMock = vi.fn();
|
|
|
|
// Production spawns `ps` by absolute path (PS_BIN); match on basename so the mock
|
|
// fires regardless.
|
|
const isPs = (cmd: string): boolean => cmd === "ps" || cmd.endsWith("/ps");
|
|
|
|
vi.mock("node:child_process", async () => {
|
|
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
|
const { EventEmitter } = await import("node:events");
|
|
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 options = typeof opts === "function" ? undefined : opts;
|
|
const result = execFileMock(cmd, args, options);
|
|
if (result instanceof Error) callback(result, { stdout: "", stderr: "" });
|
|
else callback(null, result ?? { stdout: "", stderr: "" });
|
|
},
|
|
// `runVega` now uses spawn (for `detached`), so route the vega CLI calls
|
|
// through the same execFileMock and surface the result as a fake child that
|
|
// emits stdout then `close`. An Error result emits `error` (mirrors a failed /
|
|
// timed-out CLI). Only the vega path spawns here — adb/emulator/ps stay on
|
|
// execFile above.
|
|
spawn: (cmd: string, args: readonly string[]) => {
|
|
const result = execFileMock(cmd, args, undefined);
|
|
const mkStream = () => {
|
|
const s = new EventEmitter() as any;
|
|
s.setEncoding = () => {};
|
|
s.destroy = () => {};
|
|
return s;
|
|
};
|
|
const child = new EventEmitter() as any;
|
|
// 0 (not a real OS pid) on purpose: this mock only drives the happy path
|
|
// (`close`, code 0), so reapVegaGroup never runs — but if a future timeout-path
|
|
// test were ever routed through it, `pid <= 1` makes the reap a no-op rather than
|
|
// letting `process.kill(-pid)` signal a real process group.
|
|
child.pid = 0;
|
|
child.stdout = mkStream();
|
|
child.stderr = mkStream();
|
|
child.kill = () => true;
|
|
setImmediate(() => {
|
|
if (result instanceof Error) {
|
|
child.emit("error", result);
|
|
return;
|
|
}
|
|
const out = (result ?? { stdout: "", stderr: "" }) as { stdout?: string; stderr?: string };
|
|
if (out.stdout) child.stdout.emit("data", out.stdout);
|
|
if (out.stderr) child.stderr.emit("data", out.stderr);
|
|
child.emit("close", 0, null);
|
|
});
|
|
return child;
|
|
},
|
|
};
|
|
});
|
|
|
|
// `runAdb` and `listAvds` resolve adb / emulator to an absolute path before
|
|
// spawning, so a bare `cmd === "adb" / "emulator"` matcher would never fire
|
|
// on real hosts. Stub the resolver to return the bare name so existing test
|
|
// mocks keep working regardless of host SDK install state.
|
|
vi.mock("../src/utils/android-binary", () => ({
|
|
resolveAndroidBinary: vi.fn(async (name: "adb" | "emulator") => name),
|
|
__resetAndroidBinaryCacheForTesting: () => {},
|
|
}));
|
|
|
|
// Chromium discovery probes real TCP ports (9222 plus any persisted by a
|
|
// previous tool-server on this machine). A developer actually running an
|
|
// Chromium app would leak it into this test's device list — stub discovery
|
|
// so the result only contains what the simctl / adb mocks define.
|
|
vi.mock("../src/utils/chromium-discovery", async () => {
|
|
const actual = await vi.importActual<typeof import("../src/utils/chromium-discovery")>(
|
|
"../src/utils/chromium-discovery"
|
|
);
|
|
return { ...actual, discoverChromiumDevices: vi.fn(async () => []) };
|
|
});
|
|
|
|
// VVD image enumeration hits the real filesystem (~/vega SDK); stub it so `vvds`
|
|
// is deterministic. Defaults to none; the dedicated test overrides per-call.
|
|
vi.mock("../src/utils/vega-sdk", () => ({ listVvdImages: vi.fn(async () => []) }));
|
|
|
|
// Physical-device discovery shells out to `xcrun devicectl` with a JSON output
|
|
// file; mock the module seam and feed rows directly. Defaults to none; the
|
|
// physical-device test overrides per-call.
|
|
vi.mock("../src/utils/ios-device/devicectl", async () => {
|
|
const actual = await vi.importActual<typeof import("../src/utils/ios-device/devicectl")>(
|
|
"../src/utils/ios-device/devicectl"
|
|
);
|
|
return {
|
|
...actual,
|
|
listIosPhysicalDevices: vi.fn(async () => []),
|
|
};
|
|
});
|
|
|
|
import * as fs from "node:fs";
|
|
import * as http from "node:http";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import type { AddressInfo } from "node:net";
|
|
import { listDevicesTool } from "../src/tools/devices/list-devices";
|
|
import { listIosPhysicalDevices } from "../src/utils/ios-device/devicectl";
|
|
import {
|
|
__resetExternalDeviceCacheForTesting,
|
|
__resetProviderWarningsForTesting,
|
|
} from "../src/utils/external-devices";
|
|
import { __resetVegaBinaryCacheForTests } from "../src/utils/vega-cli";
|
|
import { listVvdImages } from "../src/utils/vega-sdk";
|
|
|
|
function simctlJson(): string {
|
|
return JSON.stringify({
|
|
devices: {
|
|
"com.apple.CoreSimulator.SimRuntime.iOS-18-2": [
|
|
{
|
|
udid: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA",
|
|
name: "iPhone 16",
|
|
state: "Booted",
|
|
deviceTypeIdentifier: "com.apple.CoreSimulator.SimDeviceType.iPhone-16",
|
|
isAvailable: true,
|
|
},
|
|
{
|
|
udid: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB",
|
|
name: "iPad Pro",
|
|
state: "Shutdown",
|
|
deviceTypeIdentifier: "com.apple.CoreSimulator.SimDeviceType.iPad-Pro",
|
|
isAvailable: true,
|
|
},
|
|
{
|
|
udid: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC",
|
|
name: "iPhone 16 (unavailable)",
|
|
state: "Shutdown",
|
|
deviceTypeIdentifier: "com.apple.CoreSimulator.SimDeviceType.iPhone-16",
|
|
isAvailable: false,
|
|
},
|
|
],
|
|
"com.apple.CoreSimulator.SimRuntime.tvOS-17-5": [
|
|
{
|
|
udid: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD",
|
|
name: "Apple TV",
|
|
state: "Shutdown",
|
|
deviceTypeIdentifier: "com.apple.CoreSimulator.SimDeviceType.Apple-TV",
|
|
isAvailable: true,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
execFileMock.mockReset();
|
|
});
|
|
|
|
describe("list-devices", () => {
|
|
it("merges iOS simulators and Android devices into a single tagged array", async () => {
|
|
execFileMock.mockImplementation((cmd: string, args: string[]) => {
|
|
if (cmd === "xcrun" && args[0] === "simctl" && args[1] === "list") {
|
|
return { stdout: simctlJson(), stderr: "" };
|
|
}
|
|
if (cmd === "adb" && args[0] === "devices") {
|
|
return { stdout: "List of devices attached\nemulator-5554\tdevice\n", stderr: "" };
|
|
}
|
|
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
|
|
const shellCmd = args[3] ?? "";
|
|
if (shellCmd.includes("ro.product.model")) return { stdout: "Pixel_3a\n", stderr: "" };
|
|
if (shellCmd.includes("ro.build.version.sdk")) return { stdout: "34\n", stderr: "" };
|
|
if (shellCmd.includes("ro.kernel.qemu.avd_name"))
|
|
return { stdout: "Pixel_3a_API_34\n", stderr: "" };
|
|
}
|
|
if (cmd === "emulator" && args[0] === "-list-avds") {
|
|
return { stdout: "Pixel_3a_API_34\nPixel_7_API_34\n", stderr: "" };
|
|
}
|
|
return { stdout: "", stderr: "" };
|
|
});
|
|
|
|
const result = await listDevicesTool.execute!({}, {});
|
|
|
|
// Every device has a `platform` discriminator; there is no separate iOS/Android
|
|
// list the caller has to merge.
|
|
for (const d of result.devices) {
|
|
expect(d.platform === "ios" || d.platform === "android").toBe(true);
|
|
}
|
|
|
|
const ios = result.devices.filter((d) => d.platform === "ios") as Array<{
|
|
platform: "ios";
|
|
udid: string;
|
|
name: string;
|
|
state: string;
|
|
runtimeKind?: "mobile" | "tv";
|
|
}>;
|
|
// Unavailable simulators are filtered out. tvOS simulators are now included
|
|
// (tagged runtimeKind "tv") so Apple TV targets are discoverable; iOS
|
|
// simulators are tagged "mobile".
|
|
expect(ios.map((d) => d.name).sort()).toEqual(["Apple TV", "iPad Pro", "iPhone 16"]);
|
|
// Booted iOS devices come before shut-down ones.
|
|
expect(ios[0]!.state).toBe("Booted");
|
|
expect(ios[0]!.name).toBe("iPhone 16");
|
|
// The tvOS simulator carries the "tv" runtime kind; iOS ones are "mobile".
|
|
expect(ios.find((d) => d.name === "Apple TV")?.runtimeKind).toBe("tv");
|
|
expect(ios.find((d) => d.name === "iPhone 16")?.runtimeKind).toBe("mobile");
|
|
|
|
const android = result.devices.filter((d) => d.platform === "android") as Array<{
|
|
platform: "android";
|
|
serial: string;
|
|
sdkLevel: number | null;
|
|
avdName: string | null;
|
|
isEmulator: boolean;
|
|
}>;
|
|
expect(android).toHaveLength(1);
|
|
expect(android[0]).toMatchObject({
|
|
serial: "emulator-5554",
|
|
sdkLevel: 34,
|
|
avdName: "Pixel_3a_API_34",
|
|
isEmulator: true,
|
|
});
|
|
|
|
// AVDs list comes from `emulator -list-avds`.
|
|
expect(result.avds).toEqual([{ name: "Pixel_3a_API_34" }, { name: "Pixel_7_API_34" }]);
|
|
});
|
|
|
|
/**
|
|
* The common case, which the suite-wide setup already models. No provider
|
|
* registered, so the result must not carry a word about them.
|
|
*/
|
|
it("says nothing about external devices when no provider is registered", async () => {
|
|
execFileMock.mockImplementation((cmd: string, args: string[]) => {
|
|
if (cmd === "xcrun" && args[0] === "simctl") {
|
|
return { stderr: "", stdout: simctlJson() };
|
|
}
|
|
|
|
if (cmd === "adb" && args[0] === "devices") {
|
|
return { stderr: "", stdout: "List of devices attached\n" };
|
|
}
|
|
|
|
return { stderr: "", stdout: "" };
|
|
});
|
|
|
|
const result = await listDevicesTool.execute!({}, {});
|
|
|
|
expect(result.devices.length).toBeGreaterThan(0);
|
|
expect(result).not.toHaveProperty("hint");
|
|
});
|
|
|
|
it("readAvdName prefers the modern avd_name prop over the legacy one (now probed concurrently)", async () => {
|
|
// The two getprops run in parallel (so a wedged device costs 5s, not 10s), but
|
|
// precedence must be unchanged: `ro.boot.qemu.avd_name` (modern, emulator 30+)
|
|
// wins over `ro.kernel.qemu.avd_name` (legacy) when BOTH answer. The first test
|
|
// above covers the legacy-only fallback; this pins the both-present ordering so the
|
|
// parallelization can't silently flip it.
|
|
execFileMock.mockImplementation((cmd: string, args: string[]) => {
|
|
if (cmd === "adb" && args[0] === "devices") {
|
|
return { stdout: "List of devices attached\nemulator-5554\tdevice\n", stderr: "" };
|
|
}
|
|
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
|
|
const shellCmd = args[3] ?? "";
|
|
if (shellCmd.includes("ro.boot.qemu.avd_name"))
|
|
return { stdout: "ModernName\n", stderr: "" };
|
|
if (shellCmd.includes("ro.kernel.qemu.avd_name"))
|
|
return { stdout: "LegacyName\n", stderr: "" };
|
|
}
|
|
return { stdout: "", stderr: "" };
|
|
});
|
|
|
|
const result = await listDevicesTool.execute!({}, {});
|
|
const android = result.devices.filter((d) => d.platform === "android") as Array<{
|
|
avdName: string | null;
|
|
}>;
|
|
expect(android).toHaveLength(1);
|
|
expect(android[0]!.avdName).toBe("ModernName");
|
|
});
|
|
|
|
it("silently omits iOS when xcrun is unavailable — other platforms still returned", async () => {
|
|
execFileMock.mockImplementation((cmd: string, args: string[]) => {
|
|
if (cmd === "xcrun") {
|
|
return new Error("xcrun: error: invalid active developer path");
|
|
}
|
|
if (cmd === "adb" && args[0] === "devices") {
|
|
return { stdout: "List of devices attached\nemulator-5554\tdevice\n", stderr: "" };
|
|
}
|
|
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
|
|
return { stdout: "", stderr: "" };
|
|
}
|
|
if (cmd === "emulator") {
|
|
return { stdout: "Pixel_3a_API_34\n", stderr: "" };
|
|
}
|
|
return { stdout: "", stderr: "" };
|
|
});
|
|
|
|
const result = await listDevicesTool.execute!({}, {});
|
|
expect(result.devices.filter((d) => d.platform === "ios")).toHaveLength(0);
|
|
expect(result.devices.filter((d) => d.platform === "android")).toHaveLength(1);
|
|
expect(result.avds.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("silently omits Android when adb is unavailable — iOS still returned", async () => {
|
|
execFileMock.mockImplementation((cmd: string, args: string[]) => {
|
|
if (cmd === "xcrun" && args[0] === "simctl") {
|
|
return { stdout: simctlJson(), stderr: "" };
|
|
}
|
|
if (cmd === "adb") {
|
|
return new Error("adb: command not found");
|
|
}
|
|
if (cmd === "emulator") {
|
|
return new Error("emulator: command not found");
|
|
}
|
|
return { stdout: "", stderr: "" };
|
|
});
|
|
|
|
const result = await listDevicesTool.execute!({}, {});
|
|
expect(result.devices.filter((d) => d.platform === "android")).toHaveLength(0);
|
|
expect(result.devices.filter((d) => d.platform === "ios").length).toBeGreaterThan(0);
|
|
expect(result.avds).toEqual([]);
|
|
});
|
|
|
|
// A running VVD surfaces on adb as `emulator-XXXX`; the dedup reads its console
|
|
// port from the `vega-virtual-device` process (mocked via the `ps` branch).
|
|
function mockVegaVvd(cmd: string, args: string[]): { stdout: string; stderr: string } | null {
|
|
if (cmd === "/bin/sh" && args[0] === "-c" && args[1]?.includes("command -v vega")) {
|
|
return { stdout: "/usr/bin/vega\n", stderr: "" };
|
|
}
|
|
if (cmd === "/bin/sh" && args[0] === "-c") return { stdout: "", stderr: "" }; // kepler probe
|
|
if (cmd.endsWith("vega") && args[0] === "device" && args[1] === "list") {
|
|
return {
|
|
stdout:
|
|
"Found the following device:\nVirtualDevice : tv - aarch64 - OS - amazon-4a27df03c9777152\n",
|
|
stderr: "",
|
|
};
|
|
}
|
|
if (cmd.endsWith("vega") && args[0] === "virtual-device" && args[1] === "status") {
|
|
return { stdout: JSON.stringify({ running: true }), stderr: "" };
|
|
}
|
|
if (cmd.endsWith("vega") && args[0] === "device" && args[1] === "info") {
|
|
return { stdout: JSON.stringify({ product: "vvrp_aarch64", simulated: true }), stderr: "" };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// A `ps` listing with a `vega-virtual-device` process per console port.
|
|
function psWithVvds(...consolePorts: number[]): { stdout: string; stderr: string } {
|
|
const lines = ["/sbin/launchd", "/usr/libexec/secd"];
|
|
for (const p of consolePorts) {
|
|
lines.push(
|
|
"/Users/me/vega/sdk/vega-sdk/main/0.22.6759/vvd/images/tv/vmtools/agent/qemu/" +
|
|
`darwin-aarch64/vega-virtual-device -avd-arch arm64 -ports ${p},${p + 1} ` +
|
|
`-qemu -qmp unix:/tmp/qmp-socket-${p}.sock,server,nowait`
|
|
);
|
|
}
|
|
return { stdout: lines.join("\n") + "\n", stderr: "" };
|
|
}
|
|
|
|
it("de-duplicates a running VVD that also auto-registers on adb (shows only as vega)", async () => {
|
|
__resetVegaBinaryCacheForTests();
|
|
execFileMock.mockImplementation((cmd: string, args: string[]) => {
|
|
const vega = mockVegaVvd(cmd, args);
|
|
if (vega) return vega;
|
|
if (isPs(cmd)) return psWithVvds(5554); // VVD on console port 5554
|
|
if (cmd === "xcrun") return { stdout: simctlJson(), stderr: "" };
|
|
if (cmd === "adb" && args[0] === "devices") {
|
|
return { stdout: "List of devices attached\nemulator-5554\tdevice\n", stderr: "" };
|
|
}
|
|
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
|
|
return { stdout: "", stderr: "" }; // enrichment getprops — irrelevant to dedup
|
|
}
|
|
return { stdout: "", stderr: "" };
|
|
});
|
|
|
|
const result = await listDevicesTool.execute!({}, {});
|
|
const android = result.devices.filter((d) => d.platform === "android");
|
|
const vega = result.devices.filter((d) => d.platform === "vega");
|
|
|
|
expect(vega).toHaveLength(1);
|
|
expect((vega[0] as { serial: string }).serial).toBe("amazon-4a27df03c9777152");
|
|
// The emulator-5554 shadow row is dropped — the VVD appears exactly once.
|
|
expect(android).toHaveLength(0);
|
|
});
|
|
|
|
it("de-duplicates a VVD reached via `adb connect` 127.0.0.1:5555 (edge 1)", async () => {
|
|
__resetVegaBinaryCacheForTests();
|
|
execFileMock.mockImplementation((cmd: string, args: string[]) => {
|
|
const vega = mockVegaVvd(cmd, args);
|
|
if (vega) return vega;
|
|
if (isPs(cmd)) return psWithVvds(5554);
|
|
if (cmd === "xcrun") return { stdout: simctlJson(), stderr: "" };
|
|
if (cmd === "adb" && args[0] === "devices") {
|
|
// adb port = console + 1, so the `adb connect` serial is 127.0.0.1:5555.
|
|
return { stdout: "List of devices attached\n127.0.0.1:5555\tdevice\n", stderr: "" };
|
|
}
|
|
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
|
|
return { stdout: "", stderr: "" };
|
|
}
|
|
return { stdout: "", stderr: "" };
|
|
});
|
|
|
|
const result = await listDevicesTool.execute!({}, {});
|
|
expect(result.devices.filter((d) => d.platform === "android")).toHaveLength(0);
|
|
expect(result.devices.filter((d) => d.platform === "vega")).toHaveLength(1);
|
|
});
|
|
|
|
it("keeps a real Android emulator running alongside the VVD (edge 2 / distinguishes QEMU)", async () => {
|
|
// Real emulator on 5554, VVD on 5556 (from its process). Only the VVD's row is
|
|
// dropped; a co-running emulator / QEMU is never mistaken for the VVD.
|
|
__resetVegaBinaryCacheForTests();
|
|
execFileMock.mockImplementation((cmd: string, args: string[]) => {
|
|
const vega = mockVegaVvd(cmd, args);
|
|
if (vega) return vega;
|
|
if (isPs(cmd)) return psWithVvds(5556); // VVD on console port 5556
|
|
if (cmd === "xcrun") return { stdout: simctlJson(), stderr: "" };
|
|
if (cmd === "adb" && args[0] === "devices") {
|
|
return {
|
|
stdout: "List of devices attached\nemulator-5554\tdevice\nemulator-5556\tdevice\n",
|
|
stderr: "",
|
|
};
|
|
}
|
|
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
|
|
const shellCmd = args[3] ?? "";
|
|
if (shellCmd.includes("ro.product.model")) return { stdout: "Pixel_7\n", stderr: "" };
|
|
if (shellCmd.includes("ro.build.version.sdk")) return { stdout: "34\n", stderr: "" };
|
|
return { stdout: "", stderr: "" };
|
|
}
|
|
return { stdout: "", stderr: "" };
|
|
});
|
|
|
|
const result = await listDevicesTool.execute!({}, {});
|
|
const android = result.devices.filter((d) => d.platform === "android");
|
|
const vega = result.devices.filter((d) => d.platform === "vega");
|
|
|
|
expect(vega).toHaveLength(1);
|
|
// The shadow (emulator-5556) is dropped; the real emulator stays.
|
|
expect(android).toHaveLength(1);
|
|
expect((android[0] as { serial: string }).serial).toBe("emulator-5554");
|
|
});
|
|
|
|
it("does not filter an emulator when no VVD process is running (e.g. mid-boot)", async () => {
|
|
// Vega SDK present but no `vega-virtual-device` process → no running VVD. A
|
|
// standalone emulator must NOT be mistaken for a shadow.
|
|
__resetVegaBinaryCacheForTests();
|
|
execFileMock.mockImplementation((cmd: string, args: string[]) => {
|
|
if (cmd === "/bin/sh" && args[0] === "-c" && args[1]?.includes("command -v vega")) {
|
|
return { stdout: "/usr/bin/vega\n", stderr: "" };
|
|
}
|
|
if (cmd === "/bin/sh" && args[0] === "-c") return { stdout: "", stderr: "" };
|
|
if (cmd.endsWith("vega") && args[0] === "device" && args[1] === "list") {
|
|
return { stdout: "Found the following device:\n", stderr: "" }; // no devices
|
|
}
|
|
if (isPs(cmd)) return { stdout: "/sbin/launchd\n", stderr: "" }; // no VVD process
|
|
if (cmd === "xcrun") return { stdout: simctlJson(), stderr: "" };
|
|
if (cmd === "adb" && args[0] === "devices") {
|
|
return { stdout: "List of devices attached\nemulator-5554\tdevice\n", stderr: "" };
|
|
}
|
|
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
|
|
return { stdout: "", stderr: "" };
|
|
}
|
|
return { stdout: "", stderr: "" };
|
|
});
|
|
|
|
const result = await listDevicesTool.execute!({}, {});
|
|
const android = result.devices.filter((d) => d.platform === "android");
|
|
const vega = result.devices.filter((d) => d.platform === "vega");
|
|
|
|
expect(vega).toHaveLength(0);
|
|
expect(android).toHaveLength(1);
|
|
expect((android[0] as { serial: string }).serial).toBe("emulator-5554");
|
|
});
|
|
|
|
it("does not re-list a running VVD as a phantom stopped image when its profile is absent", async () => {
|
|
// `vega device info` omits `profile`, but an image named "tv" is installed.
|
|
// The running VVD must dedup against that image (single installed image) so it
|
|
// shows up once as running, not also as a stopped "tv".
|
|
__resetVegaBinaryCacheForTests();
|
|
vi.mocked(listVvdImages).mockResolvedValueOnce([{ name: "tv", path: "/sdk/vvd/images/tv" }]);
|
|
execFileMock.mockImplementation((cmd: string, args: string[]) => {
|
|
const vega = mockVegaVvd(cmd, args);
|
|
if (vega) return vega;
|
|
if (isPs(cmd)) return psWithVvds(5554);
|
|
if (cmd === "xcrun") return { stdout: simctlJson(), stderr: "" };
|
|
if (cmd === "adb" && args[0] === "devices") {
|
|
return { stdout: "List of devices attached\nemulator-5554\tdevice\n", stderr: "" };
|
|
}
|
|
if (cmd === "adb" && args[0] === "-s" && args[2] === "shell") {
|
|
return { stdout: "", stderr: "" };
|
|
}
|
|
return { stdout: "", stderr: "" };
|
|
});
|
|
|
|
const result = await listDevicesTool.execute!({}, {});
|
|
const vega = result.devices.filter((d) => d.platform === "vega") as Array<{
|
|
state: string;
|
|
serial: string | null;
|
|
vvdImage: string | null;
|
|
}>;
|
|
|
|
expect(vega).toHaveLength(1);
|
|
expect(vega[0]).toMatchObject({ state: "running", vvdImage: "tv" });
|
|
});
|
|
|
|
it("lists an installed-but-stopped VVD under devices[] (state stopped, no serial)", async () => {
|
|
__resetVegaBinaryCacheForTests();
|
|
vi.mocked(listVvdImages).mockResolvedValueOnce([{ name: "tv", path: "/sdk/vvd/images/tv" }]);
|
|
execFileMock.mockImplementation((cmd: string, args: string[]) => {
|
|
if (cmd === "/bin/sh" && args[0] === "-c" && args[1]?.includes("command -v vega")) {
|
|
return { stdout: "/usr/bin/vega\n", stderr: "" };
|
|
}
|
|
if (cmd === "/bin/sh" && args[0] === "-c") return { stdout: "", stderr: "" };
|
|
// No device connected and not running → the VVD is stopped.
|
|
if (cmd.endsWith("vega") && args[0] === "device" && args[1] === "list") {
|
|
return { stdout: "Found the following device:\n", stderr: "" };
|
|
}
|
|
if (cmd === "xcrun") return { stdout: simctlJson(), stderr: "" };
|
|
return { stdout: "", stderr: "" };
|
|
});
|
|
|
|
const result = await listDevicesTool.execute!({}, {});
|
|
const vega = result.devices.filter((d) => d.platform === "vega") as Array<{
|
|
kind: string;
|
|
state: string;
|
|
serial: string | null;
|
|
vvdImage: string | null;
|
|
}>;
|
|
|
|
expect(vega).toHaveLength(1);
|
|
expect(vega[0]).toMatchObject({
|
|
kind: "vvd",
|
|
state: "stopped",
|
|
serial: null,
|
|
vvdImage: "tv",
|
|
});
|
|
});
|
|
|
|
it("derives physical iOS state from transportType alone and sorts connected first", async () => {
|
|
// Live-captured shapes: a cabled phone is "wired", a Wi-Fi-paired phone
|
|
// last seen days ago lists as "disconnected"/"localNetwork", and a freshly
|
|
// unplugged phone keeps tunnelState "connected" while the tunnel migrates
|
|
// to Wi-Fi (hardware-verified, devicectl 518.x). Only the wired one is reachable
|
|
// over usbmux, so only it may present as "connected".
|
|
vi.mocked(listIosPhysicalDevices).mockResolvedValueOnce([
|
|
{
|
|
udid: "00008120-000A44443333801E",
|
|
name: "Office iPhone",
|
|
model: "iPhone 14",
|
|
osVersion: "18.5",
|
|
developerModeEnabled: true,
|
|
pairingState: "paired",
|
|
transportType: "localNetwork",
|
|
tunnelState: "disconnected",
|
|
},
|
|
{
|
|
udid: "00008110-000978540290401E",
|
|
name: "Desk iPhone",
|
|
model: "iPhone 13",
|
|
osVersion: "26.0",
|
|
developerModeEnabled: true,
|
|
pairingState: "paired",
|
|
transportType: "wired",
|
|
tunnelState: "connected",
|
|
},
|
|
{
|
|
udid: "00008120-000B55554444901E",
|
|
name: "Unplugged iPhone",
|
|
model: "iPhone 15",
|
|
osVersion: "27.0",
|
|
developerModeEnabled: true,
|
|
pairingState: "paired",
|
|
transportType: "localNetwork",
|
|
tunnelState: "connected",
|
|
},
|
|
]);
|
|
execFileMock.mockImplementation((cmd: string, args: string[]) => {
|
|
if (cmd === "xcrun" && args[0] === "simctl" && args[1] === "list") {
|
|
return { stdout: simctlJson(), stderr: "" };
|
|
}
|
|
return { stdout: "", stderr: "" };
|
|
});
|
|
|
|
const result = await listDevicesTool.execute!({}, {});
|
|
const byName = (name: string) =>
|
|
result.devices.findIndex((d) => "name" in d && d.name === name);
|
|
|
|
// Devices order doubles as the sort pin: the connected phone (rank 0)
|
|
// precedes the paired one (rank 1) in this filtered projection.
|
|
const physical = result.devices.filter((d) => "kind" in d && d.kind === "device") as Array<{
|
|
name: string;
|
|
state: string;
|
|
pairingState: string | null;
|
|
tunnelState: string | null;
|
|
}>;
|
|
expect(physical.map((d) => [d.name, d.state])).toEqual([
|
|
["Desk iPhone", "connected"],
|
|
["Office iPhone", "paired"],
|
|
["Unplugged iPhone", "paired"],
|
|
]);
|
|
// pairingState/tunnelState ride along so callers can see WHY a row is paired.
|
|
expect(physical[1]).toMatchObject({ pairingState: "paired", tunnelState: "disconnected" });
|
|
// The Wi-Fi-migrated tunnel reads "connected" yet usbmux cannot reach it.
|
|
expect(physical[2]).toMatchObject({ state: "paired", tunnelState: "connected" });
|
|
// The paired phone sinks below every usable device, booted simulators
|
|
// included; flow auto-bind keys on state "connected" and skips it.
|
|
expect(byName("Desk iPhone")).toBeLessThan(byName("Office iPhone"));
|
|
expect(byName("iPhone 16")).toBeLessThan(byName("Office iPhone"));
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A provider's device can reach this tool twice: once as an `ext:` entry, and
|
|
* once through the platform's own discovery. `adb devices` always sees a
|
|
* provider's emulator, and `simctl` sees its simulators as soon as the
|
|
* provider's device set is listed in `ios.additionalDeviceSets`. Only the
|
|
* external entry carries the attribution and the capability grants, and
|
|
* driving the bare id would spawn a second simulator-server against a device
|
|
* already in use, so the plain row is the one that goes.
|
|
*/
|
|
describe("list-devices — external provider shadows", () => {
|
|
const CLAIMED_UDID = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA";
|
|
|
|
let temporaryDirectory: string;
|
|
let simulatorServer: http.Server;
|
|
let descriptorPath: string;
|
|
|
|
beforeEach(async () => {
|
|
temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "argent-list-providers-"));
|
|
/**
|
|
* Discovery probes `apiUrl` for liveness, so the device needs something
|
|
* actually listening or it is dropped before dedup is even reached.
|
|
*/
|
|
simulatorServer = http.createServer((_request, response) => {
|
|
response.statusCode = 404;
|
|
response.end();
|
|
});
|
|
|
|
await new Promise<void>((resolve) => simulatorServer.listen(0, "127.0.0.1", resolve));
|
|
|
|
const { port } = simulatorServer.address() as AddressInfo;
|
|
|
|
descriptorPath = path.join(temporaryDirectory, "acme.json");
|
|
|
|
fs.writeFileSync(
|
|
descriptorPath,
|
|
JSON.stringify({
|
|
devices: [
|
|
{
|
|
capabilities: ["ax-service", "simctl", "simulator-server"],
|
|
deviceSet: "/tmp/acme/Devices/iOS",
|
|
kind: "simulator",
|
|
name: "iPhone 16",
|
|
nativeId: CLAIMED_UDID,
|
|
platform: "ios",
|
|
simulatorServer: {
|
|
apiUrl: `http://127.0.0.1:${port}`,
|
|
streamUrl: `http://127.0.0.1:${port}/stream.mjpeg`,
|
|
},
|
|
state: "Booted",
|
|
},
|
|
],
|
|
id: "acme-3f2a9c",
|
|
name: "Acme IDE",
|
|
schemaVersion: 1,
|
|
})
|
|
);
|
|
|
|
/** Opt back into discovery, which the suite-wide setup switches off. */
|
|
delete process.env.ARGENT_DISABLE_DEVICE_PROVIDERS;
|
|
process.env.ARGENT_DEVICE_PROVIDERS = descriptorPath;
|
|
__resetExternalDeviceCacheForTesting();
|
|
__resetProviderWarningsForTesting();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
delete process.env.ARGENT_DEVICE_PROVIDERS;
|
|
process.env.ARGENT_DISABLE_DEVICE_PROVIDERS = "1";
|
|
await new Promise<void>((resolve) => simulatorServer.close(() => resolve()));
|
|
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
|
});
|
|
|
|
it("lists a claimed simulator once, as the external entry", async () => {
|
|
execFileMock.mockImplementation((cmd: string, args: string[]) => {
|
|
/**
|
|
* `simctl` sees the provider's simulator because its device set is
|
|
* configured.
|
|
*/
|
|
if (cmd === "xcrun" && args[0] === "simctl") {
|
|
return { stderr: "", stdout: simctlJson() };
|
|
}
|
|
|
|
if (cmd === "adb" && args[0] === "devices") {
|
|
return { stderr: "", stdout: "List of devices attached\n" };
|
|
}
|
|
|
|
return { stderr: "", stdout: "" };
|
|
});
|
|
|
|
const result = await listDevicesTool.execute!({}, {});
|
|
|
|
const claimed = result.devices.filter(
|
|
(device) => "udid" in device && (device.udid as string).includes(CLAIMED_UDID)
|
|
);
|
|
|
|
expect(claimed).toHaveLength(1);
|
|
expect(claimed[0]).toMatchObject({
|
|
external: true,
|
|
udid: `ext:acme-3f2a9c:${CLAIMED_UDID}`,
|
|
});
|
|
});
|
|
|
|
it("leaves an unclaimed simulator in the plain listing", async () => {
|
|
execFileMock.mockImplementation((cmd: string, args: string[]) => {
|
|
if (cmd === "xcrun" && args[0] === "simctl") {
|
|
return { stderr: "", stdout: simctlJson() };
|
|
}
|
|
|
|
if (cmd === "adb" && args[0] === "devices") {
|
|
return { stderr: "", stdout: "List of devices attached\n" };
|
|
}
|
|
|
|
return { stderr: "", stdout: "" };
|
|
});
|
|
|
|
const result = await listDevicesTool.execute!({}, {});
|
|
|
|
const iPad = result.devices.filter(
|
|
(device) => "udid" in device && device.udid === "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB"
|
|
);
|
|
|
|
expect(iPad).toHaveLength(1);
|
|
expect(iPad[0]).not.toHaveProperty("external");
|
|
});
|
|
|
|
/** The rules ride on the result, so they cost nothing until one is listed. */
|
|
it("explains the provider rules in the result once an external device is listed", async () => {
|
|
execFileMock.mockImplementation((cmd: string, args: string[]) => {
|
|
if (cmd === "xcrun" && args[0] === "simctl") {
|
|
return { stderr: "", stdout: simctlJson() };
|
|
}
|
|
|
|
if (cmd === "adb" && args[0] === "devices") {
|
|
return { stderr: "", stdout: "List of devices attached\n" };
|
|
}
|
|
|
|
return { stderr: "", stdout: "" };
|
|
});
|
|
|
|
const result = await listDevicesTool.execute!({}, {});
|
|
|
|
expect(result.hint).toBeDefined();
|
|
/** The two rules that differ from a device argent booted itself. */
|
|
expect(result.hint).toContain("boot-device");
|
|
expect(result.hint).toContain("provider.workspace.path");
|
|
});
|
|
});
|