mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
f8d58489d0
`shake` only touches the guest, so nothing on screen explains why an app popped its dev menu. This wobbles the host window carrying that device while the gesture runs. - Opt-in `microinteractions` flag, off by default (`argent enable microinteractions`). Read inside the handlers, not as `ToolDefinition.featureFlag`, which would hide the whole tool. - Can never fail a shake: every path warns once to stderr and resolves. Skipped off macOS, on headless boots, and for `sim-remote`. - Windows are title-matched on the iOS display name, so a host with several booted simulators wobbles the right one. - `prepareHostWindowShake` resolves flag/platform/device-set/script once per call; `begin()` per gesture, `settle()` once after the loop. Not in the `finally`, so a real failure throws immediately rather than waiting on cosmetics. - `ARGENT_SIMULATOR_NO_WINDOW` / `ARGENT_EMULATOR_NO_WINDOW` parsing extracted to `utils/no-window-env`, shared with `boot-device`. ### iOS simulators only The wobble originally covered Android emulators too and has been removed there. Picking the emulator's window meant matching a title among several `qemu-system-*` host processes, using needles built from the console port plus an extra `adb emu avd name` round trip per shake. That lookup was the fragile half of the feature: a build that drops the port from the title, or a serial with no console port, falls back to either the wrong window or no animation at all. An Android shake also already produces visible in-guest feedback, so the amplification bought less there. `shake` itself is unchanged on Android: the same accelerometer burst, the same resting-vector capture and restore, the same per-device lock. Only the cosmetic host-window animation is gone. `ARGENT_EMULATOR_NO_WINDOW` handling stays in `utils/no-window-env`, where `boot-device` still uses it. Verified live on an iPhone 17 Pro Max: window returns to its exact origin and the wobble fits inside one gesture. The Android sensor path was verified live on a Pixel_9 before the removal and is unchanged by it; it has not been re-run since. Full tool-server suite green. The AppleScript motion itself is only verified by eye; unit tests stub `osascript`. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
316 lines
12 KiB
TypeScript
316 lines
12 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { EventEmitter } from "node:events";
|
|
|
|
// The animation is one `osascript` invocation reading its script from stdin.
|
|
// Capture that script instead of scripting a real window.
|
|
vi.mock("node:child_process", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("node:child_process")>()),
|
|
execFile: vi.fn(),
|
|
}));
|
|
|
|
// The gate reads flags.json off disk; pin it so the suite never depends on
|
|
// whether the developer running it has the flag enabled.
|
|
vi.mock("@argent/configuration-core", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("@argent/configuration-core")>()),
|
|
isFeatureEnabled: vi.fn(() => true),
|
|
}));
|
|
|
|
// Device-set membership normally costs a simctl probe; pin it to the default
|
|
// set so the iOS gate stays open unless a test says otherwise.
|
|
vi.mock("../src/utils/ios-device-sets", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../src/utils/ios-device-sets")>()),
|
|
deviceSetForUdid: vi.fn(async () => null),
|
|
}));
|
|
|
|
import { execFile } from "node:child_process";
|
|
import { isFeatureEnabled } from "@argent/configuration-core";
|
|
import { deviceSetForUdid } from "../src/utils/ios-device-sets";
|
|
import {
|
|
animationScript,
|
|
prepareHostWindowShake,
|
|
MICROINTERACTIONS_FLAG,
|
|
type HostWindowTarget,
|
|
} from "../src/utils/window-shake";
|
|
|
|
type ExecCb = (err: Error | null, stdout: string, stderr: string) => void;
|
|
|
|
const IOS: HostWindowTarget = { kind: "ios", udid: "UDID-1234", name: "iPhone 16 Pro" };
|
|
|
|
/**
|
|
* Stand-in for the `osascript` child: records what was piped to stdin and
|
|
* settles the callback with the caller's chosen outcome.
|
|
*/
|
|
function stubOsascript(
|
|
outcome: { err?: Error & { killed?: boolean; signal?: string }; stderr?: string } = {}
|
|
) {
|
|
const scripts: string[] = [];
|
|
vi.mocked(execFile).mockImplementation(((
|
|
_file: string,
|
|
_args: string[],
|
|
_opts: unknown,
|
|
cb: ExecCb
|
|
) => {
|
|
const child = new EventEmitter() as EventEmitter & { stdin: EventEmitter & { end: unknown } };
|
|
const stdin = new EventEmitter() as EventEmitter & { end: (s: string) => void };
|
|
stdin.end = (script: string) => {
|
|
scripts.push(script);
|
|
// The real callback is async; keep that ordering so an awaiting caller
|
|
// can't observe a resolved promise before stdin has been written.
|
|
setImmediate(() => cb(outcome.err ?? null, "", outcome.stderr ?? ""));
|
|
};
|
|
child.stdin = stdin as never;
|
|
return child;
|
|
}) as never);
|
|
return scripts;
|
|
}
|
|
|
|
/** prepare + begin + settle in one go, for tests that only inspect the outcome. */
|
|
async function shakeOnce(target: HostWindowTarget): Promise<void> {
|
|
const shaker = await prepareHostWindowShake(target);
|
|
shaker.begin();
|
|
await shaker.settle();
|
|
}
|
|
|
|
const originalPlatform = process.platform;
|
|
const originalIosNoWindow = process.env.ARGENT_SIMULATOR_NO_WINDOW;
|
|
|
|
function setPlatform(value: string) {
|
|
Object.defineProperty(process, "platform", { value, configurable: true });
|
|
}
|
|
|
|
function restoreEnv(name: string, value: string | undefined) {
|
|
if (value === undefined) delete process.env[name];
|
|
else process.env[name] = value;
|
|
}
|
|
|
|
let stderrWrite: ReturnType<typeof vi.spyOn>;
|
|
|
|
function warnings(): string[] {
|
|
return stderrWrite.mock.calls
|
|
.map((call: unknown[]) => String(call[0]))
|
|
.filter((line: string) => line.startsWith("[shake:window]"));
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(execFile).mockReset();
|
|
vi.mocked(isFeatureEnabled).mockReturnValue(true);
|
|
vi.mocked(deviceSetForUdid).mockReset();
|
|
vi.mocked(deviceSetForUdid).mockResolvedValue(null);
|
|
setPlatform("darwin");
|
|
delete process.env.ARGENT_SIMULATOR_NO_WINDOW;
|
|
// Diagnostics go to stderr (stdout carries JSON-RPC); capture them there.
|
|
stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true) as never;
|
|
});
|
|
|
|
afterEach(() => {
|
|
setPlatform(originalPlatform);
|
|
restoreEnv("ARGENT_SIMULATOR_NO_WINDOW", originalIosNoWindow);
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("prepareHostWindowShake — the gate", () => {
|
|
it("yields an inert shaker when the flag is off", async () => {
|
|
vi.mocked(isFeatureEnabled).mockReturnValue(false);
|
|
stubOsascript();
|
|
await shakeOnce(IOS);
|
|
expect(execFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reads the flag the CLI writes", async () => {
|
|
stubOsascript();
|
|
await shakeOnce(IOS);
|
|
expect(isFeatureEnabled).toHaveBeenCalledWith(MICROINTERACTIONS_FLAG);
|
|
expect(MICROINTERACTIONS_FLAG).toBe("microinteractions");
|
|
});
|
|
|
|
it("does nothing off macOS — the animation is AppleScript", async () => {
|
|
setPlatform("linux");
|
|
stubOsascript();
|
|
await shakeOnce(IOS);
|
|
expect(execFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips a headless simulator boot, which has no window to move", async () => {
|
|
// Same env var `boot-device` honours before `open -a Simulator.app`.
|
|
process.env.ARGENT_SIMULATOR_NO_WINDOW = "1";
|
|
stubOsascript();
|
|
await shakeOnce(IOS);
|
|
expect(execFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips a device from an additional device set — those boots never get a GUI window", async () => {
|
|
vi.mocked(deviceSetForUdid).mockResolvedValue("/tmp/radon-set/Devices");
|
|
stubOsascript();
|
|
await shakeOnce(IOS);
|
|
expect(deviceSetForUdid).toHaveBeenCalledWith("UDID-1234");
|
|
expect(execFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("treats a rejecting device-set probe as the default set", async () => {
|
|
vi.mocked(deviceSetForUdid).mockRejectedValue(new Error("simctl exploded"));
|
|
stubOsascript();
|
|
await expect(shakeOnce(IOS)).resolves.toBeUndefined();
|
|
expect(execFile).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("prepareHostWindowShake — never fails the shake", () => {
|
|
it("settles and warns when osascript is refused Accessibility permission", async () => {
|
|
stubOsascript({
|
|
err: new Error("Command failed"),
|
|
stderr: "execution error: … is not allowed assistive access. (-1743)",
|
|
});
|
|
await expect(shakeOnce(IOS)).resolves.toBeUndefined();
|
|
expect(warnings().join("\n")).toContain("-1743");
|
|
});
|
|
|
|
it("settles when the child cannot be spawned at all", async () => {
|
|
vi.mocked(execFile).mockImplementation((() => {
|
|
throw new Error("EPERM");
|
|
}) as never);
|
|
await expect(shakeOnce(IOS)).resolves.toBeUndefined();
|
|
expect(warnings()).toHaveLength(1);
|
|
});
|
|
|
|
it("goes dead after one failed wobble: no further spawns, exactly one warning", async () => {
|
|
stubOsascript({ err: new Error("Command failed"), stderr: "boom" });
|
|
const shaker = await prepareHostWindowShake(IOS);
|
|
shaker.begin();
|
|
await shaker.settle();
|
|
shaker.begin();
|
|
await shaker.settle();
|
|
shaker.begin();
|
|
await shaker.settle();
|
|
expect(execFile).toHaveBeenCalledTimes(1);
|
|
expect(warnings()).toHaveLength(1);
|
|
});
|
|
|
|
it("ignores begin() while a wobble is already in flight", async () => {
|
|
// Hold the child open across both begins, then settle it manually.
|
|
const callbacks: ExecCb[] = [];
|
|
vi.mocked(execFile).mockImplementation(((
|
|
_file: string,
|
|
_args: string[],
|
|
_opts: unknown,
|
|
cb: ExecCb
|
|
) => {
|
|
const child = new EventEmitter() as EventEmitter & { stdin: EventEmitter & { end: unknown } };
|
|
const stdin = new EventEmitter() as EventEmitter & { end: (s: string) => void };
|
|
stdin.end = () => callbacks.push(cb);
|
|
child.stdin = stdin as never;
|
|
return child;
|
|
}) as never);
|
|
|
|
const shaker = await prepareHostWindowShake(IOS);
|
|
shaker.begin();
|
|
shaker.begin();
|
|
expect(execFile).toHaveBeenCalledTimes(1);
|
|
callbacks[0]!(null, "", "");
|
|
await shaker.settle();
|
|
// A finished wobble makes room for the next gesture's wobble.
|
|
shaker.begin();
|
|
expect(execFile).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe("prepareHostWindowShake — iOS window lookup", () => {
|
|
it("title-matches the device's window across Simulator and Device Hub", async () => {
|
|
const scripts = stubOsascript();
|
|
await shakeOnce(IOS);
|
|
|
|
const [file, args] = vi.mocked(execFile).mock.calls[0]!;
|
|
expect(file).toBe("/usr/bin/osascript");
|
|
// `-` reads the script from stdin, so nothing has to survive shell quoting.
|
|
expect(args).toEqual(["-"]);
|
|
// The device display name is the needle: with several booted devices each
|
|
// window is titled with its device's name, so window 1 is not safe.
|
|
expect(scripts[0]).toContain('set needles to {"iPhone 16 Pro"}');
|
|
expect(scripts[0]).toContain('every process whose name is "Simulator" or name is "Device Hub"');
|
|
// A shake with no window to move must error out inside AppleScript rather
|
|
// than silently "succeeding" against a stale reference.
|
|
expect(scripts[0]).toContain("if win is missing value then error");
|
|
});
|
|
|
|
it("falls back to window 1 of Simulator/Device Hub when the name is unknown", async () => {
|
|
const scripts = stubOsascript();
|
|
await shakeOnce({ kind: "ios", udid: "UDID-1234" });
|
|
expect(scripts[0]).toContain('{"Simulator", "Device Hub"}');
|
|
expect(scripts[0]).not.toContain("set needles to");
|
|
});
|
|
|
|
it("returns the window to its exact starting position, twice", async () => {
|
|
// The window server lags behind a burst of moves, so a single re-assert can
|
|
// be overtaken and leave the window a few points adrift.
|
|
const scripts = stubOsascript();
|
|
await shakeOnce(IOS);
|
|
const reasserts = scripts[0]!.match(/set position of win to \{ox, oy\}/g);
|
|
expect(reasserts).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe("prepareHostWindowShake — timeout recovery", () => {
|
|
it("re-asserts the logged origin after a SIGTERMed run", async () => {
|
|
// The 5s timeout kills the script between moves, so the closing re-asserts
|
|
// never ran. The origin was logged to stderr up front exactly for this.
|
|
const scripts = stubOsascript({
|
|
err: Object.assign(new Error("timed out"), { killed: true, signal: "SIGTERM" }),
|
|
stderr: "ARGENT_WINDOW_ORIGIN:120,45\n",
|
|
});
|
|
await shakeOnce(IOS);
|
|
expect(execFile).toHaveBeenCalledTimes(2);
|
|
expect(scripts[1]).toContain("set position of win to {120, 45}");
|
|
// The repair reuses the exact same window lookup as the animation.
|
|
expect(scripts[1]).toContain('set needles to {"iPhone 16 Pro"}');
|
|
// The marker is bookkeeping; the warning carries the real detail only.
|
|
expect(warnings().join("\n")).not.toContain("ARGENT_WINDOW_ORIGIN");
|
|
});
|
|
|
|
it("skips the repair when the kill left no origin marker behind", async () => {
|
|
stubOsascript({
|
|
err: Object.assign(new Error("timed out"), { killed: true, signal: "SIGTERM" }),
|
|
stderr: "",
|
|
});
|
|
await shakeOnce(IOS);
|
|
expect(execFile).toHaveBeenCalledTimes(1);
|
|
expect(warnings()).toHaveLength(1);
|
|
});
|
|
|
|
it("skips the repair on a plain script error — the closing re-asserts ran", async () => {
|
|
stubOsascript({ err: new Error("Command failed"), stderr: "no matching window" });
|
|
await shakeOnce(IOS);
|
|
expect(execFile).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("animationScript", () => {
|
|
it("escapes a needle so a quote in a device name can't break out of the literal", () => {
|
|
const script = animationScript(['a"b\\c']);
|
|
expect(script).toContain('"a\\"b\\\\c"');
|
|
});
|
|
|
|
it("carries the wobble as precomputed offsets — no in-script trigonometry", () => {
|
|
const script = animationScript(["iPhone 16 Pro"]);
|
|
expect(script).toContain("on run");
|
|
expect(script).toContain("end run");
|
|
// AMPLITUDE and STEPS are compile-time constants, so the damped sine is
|
|
// evaluated in TypeScript; the script only replays the integer offsets.
|
|
expect(script).not.toContain("sinOf");
|
|
expect(script).toContain("repeat with pair in offsets");
|
|
const offsets = script.match(/set offsets to \{(.*)\}/)?.[1] ?? "";
|
|
const pairs = offsets.match(/\{-?\d+, -?\d+\}/g) ?? [];
|
|
expect(pairs).toHaveLength(61); // STEPS + 1 moves, i = 0..60
|
|
expect(pairs[0]).toBe("{0, 0}"); // sin(0) on both axes
|
|
// The wobble must actually move the window in both directions.
|
|
expect(pairs.some((p) => /\{-\d+, /.test(p))).toBe(true);
|
|
expect(pairs.some((p) => /\{[1-9]\d*, /.test(p))).toBe(true);
|
|
});
|
|
|
|
it("logs the origin marker before the first move so a kill is repairable", () => {
|
|
const script = animationScript(["iPhone 16 Pro"]);
|
|
const markerAt = script.indexOf("ARGENT_WINDOW_ORIGIN:");
|
|
const firstMoveAt = script.indexOf("repeat with pair in offsets");
|
|
expect(markerAt).toBeGreaterThan(-1);
|
|
expect(markerAt).toBeLessThan(firstMoveAt);
|
|
});
|
|
});
|