Files
software-mansion__argent/packages/tool-server/test/ios-only-blueprint-gate.test.ts
filip131311 b835de2326 fix(packaging): ship one shared copy of the Android screen-sharing agent (#983)
## Summary

Fixes the biggest chunk of #980: the published package shipped **four
byte-identical copies** of the Android screen-sharing agent
(`resources/android/`: jar + per-ABI `.so`, ~12.7 MiB each) — one next
to each platform's simulator-server binary, because the binary resolves
the agent relative to its working directory and the spawn set cwd to the
per-platform dir.

This ships **one shared copy** at `bin/resources/android/` and points
the spawn cwd at the bin root instead.

Package size (built from this branch, same artifact set as 0.22.1):

|  | before | after |
|---|---|---|
| unpacked | 115.5 MB | **76.4 MB** |
| packed | 37.6 MB | 25.4 MB |

76.4 MB is under npmmirror's 83886080-byte unpacked-size cap (the limit
#980 trips — note it applies to `dist.unpackedSize`, not the tarball).
Already-published oversized versions are immutable, so the mirror also
needs the `cnpm/unpkg-white-list` entry to sync the backlog; this PR
keeps future versions under the cap.

## Changes

- `scripts/download-simulator-server.sh` extracts the agent tarball
once, to `bin/resources/android/`, instead of next to each binary.
- `bundle-tools.cjs` copies that single shared directory into the
package.
- `@argent/native-devtools-ios` replaces `simulatorServerBinaryDir()`
with `simulatorServerRunDir()`: the bin root when the shared
`resources/android/` exists there, falling back to the per-platform dir
so an `ARGENT_SIMULATOR_SERVER_DIR` override pointing at a pre-dedup
layout keeps working.
- The blueprint spawns simulator-server with `cwd: RUN_DIR` (binary path
stays absolute; the agent lookup is the only cwd-relative path in the
simulator-server crate — audited: every other fs path there is a
tempdir, `dirs::cache_dir()`, or caller-supplied).

No symlinks, no postinstall scripts — plain files plus a different cwd,
so nothing changes for npm/pnpm/yarn/bun or Windows.

## Testing

- Unit: resolver tests updated + new shared-layout case; blueprint test
asserts the spawn cwd.
- Packaging: ran the modified download script + `npm run pack`
end-to-end; tarball contains exactly one `bin/resources/android/` (94
files vs 112).
- Live: branch tool-server with `ARGENT_SIMULATOR_SERVER_DIR` pointed at
the new layout — iOS simulator (screenshot) and Android emulator
(boot-device, screenshot, gesture-tap) green; `lsof` confirms both
spawned simulator-servers run with cwd = bin root, where
`resources/android/<jar>` and each per-ABI `.so` resolve (verified
present at those exact relative paths).
- Physical Android phone (Galaxy S24, the one path that actually pushes
the agent): live-tested — the `android_device` controller resolved the
agent from the shared layout, pushed it, and connected ("All agent
connections established successfully"); screenshot and tap through the
on-phone agent both green, spawn cwd confirmed as the shared bin root.

No docs update needed: internal packaging/layout only — no MCP tool,
CLI, config, or flow-file surface changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01UGAsAvYBJ3Jy9DKLaX9aoZ


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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved simulator-server resource handling by using one shared
screen-sharing resource location across host platforms.
  * Added a warning when required shared resources are unavailable.
* Ensured simulator-server processes start from the correct runtime
directory.

* **Documentation**
  * Clarified simulator-server runtime and shared resource behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-31 14:49:11 +02:00

127 lines
5.5 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import type { DeviceInfo } from "@argent/registry";
import { processScopedUdid } from "./helpers/process-scoped-udid";
// The native-profiler and native-devtools blueprints both open real OS
// resources (sockets, processes) if we let them reach past the gate. Stub the
// heavy bits so the only behavior under test is the iOS/Android classification
// throw.
vi.mock("@argent/native-devtools-ios", () => ({
bootstrapDylibPath: () => "/fake/bootstrap.dylib",
simulatorServerBinaryPath: () => "/fake/sim-server",
simulatorServerRunDir: () => "/fake",
}));
// The proof-of-gate iOS test below runs the native-devtools factory for real,
// and its ensureEnv shells out to `xcrun simctl` (list / launchctl getenv +
// setenv / defaults write) against a UDID no simulator has — several real
// subprocesses that take seconds under the parallel suite load and trip the 5s
// per-test timeout. Nothing here asserts on the env setup, only on which gate
// error (if any) a factory throws — so intercept execFile and answer every
// probe instantly with an empty success.
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!) as (
err: Error | null,
out: { stdout: string; stderr: string }
) => void;
callback(null, { stdout: "", stderr: "" });
},
};
});
import { nativeDevtoolsBlueprint } from "../src/blueprints/native-devtools";
import { nativeProfilerSessionBlueprint } from "../src/blueprints/native-profiler-session";
import { axServiceBlueprint } from "../src/blueprints/ax-service";
function iosDevice(udid: string): DeviceInfo {
return { id: udid, platform: "ios", kind: "simulator" };
}
function androidDevice(serial: string): DeviceInfo {
return { id: serial, platform: "android", kind: "emulator" };
}
describe("iOS-only blueprints reject Android targets up-front", () => {
// Agents see both iOS and Android targets in list-devices. Feeding an Android
// serial to a tool backed by an iOS-only blueprint (native-devtools,
// native-profiler-session) used to resolve the service, fail deep in
// launchctl / xctrace / socket connect, and surface as an opaque error.
// These gates turn that into a clear "iOS-only, pick an iOS udid" message
// at the blueprint boundary — using the caller-supplied DeviceInfo, not a
// re-classification of the URN payload.
it("native-devtools blueprint rejects an Android device with a targeted error", async () => {
const device = androidDevice("emulator-5554");
await expect(nativeDevtoolsBlueprint.factory({}, device, { device })).rejects.toThrow(
/NativeDevtools is iOS-only.*classifies as android/
);
});
it("native-profiler-session blueprint accepts an Android device (Perfetto backend)", async () => {
const device = androidDevice("emulator-5556");
const instance = await nativeProfilerSessionBlueprint.factory({}, device, { device });
expect(instance.api.platform).toBe("android");
await instance.dispose();
});
it("native-devtools blueprint does NOT gate an iOS DeviceInfo (gate is one-sided)", async () => {
// Proof-of-gate: if the caller hands us an iOS device we pass the
// `device.platform !== "ios"` check. Whether the rest of the factory
// resolves or rejects depends on socket state which this test doesn't
// control — the invariant we care about is that the failure mode is
// never the iOS-only gate message for an iOS target.
const device = iosDevice(processScopedUdid("-2222-3333-4444-555555555555"));
let threwGateError = false;
try {
const instance = await nativeDevtoolsBlueprint.factory({}, device, { device });
// If the factory resolves, dispose it so we don't leak the socket watcher.
await instance.dispose();
} catch (e) {
if (e instanceof Error && /NativeDevtools is iOS-only/.test(e.message)) {
threwGateError = true;
}
}
expect(threwGateError).toBe(false);
});
it("native-devtools blueprint rejects when caller forgets options.device", async () => {
// Defensive: without a DeviceInfo the factory has no way to gate on
// platform — surface a clear error pointing at the helper rather than
// silently defaulting.
const stub = iosDevice("ignored");
await expect(nativeDevtoolsBlueprint.factory({}, stub)).rejects.toThrow(
/requires a resolved DeviceInfo via options\.device/
);
});
it("native-profiler-session blueprint rejects when caller forgets options.device", async () => {
const stub = iosDevice("ignored");
await expect(nativeProfilerSessionBlueprint.factory({}, stub)).rejects.toThrow(
/requires a resolved DeviceInfo via options\.device/
);
});
it("ax-service blueprint rejects an Android device with a targeted error", async () => {
const device = androidDevice("emulator-5554");
await expect(axServiceBlueprint.factory({}, device, { device })).rejects.toThrow(
/AXService is iOS-only.*classifies as android.*uiautomator/
);
});
it("ax-service blueprint rejects when caller forgets options.device", async () => {
const stub = iosDevice("ignored");
await expect(axServiceBlueprint.factory({}, stub)).rejects.toThrow(
/requires a resolved DeviceInfo via options\.device/
);
});
});