Files
software-mansion__argent/packages/tool-server/test/open-url-deep-link-note.test.ts
Ignacy Łątka 300bc30e3d fix(open-url): flag when a web URL may open the browser instead of an app (#476)
## Problem

An agent calling `open-url` to deep-link into an app gets Safari
instead:

```
open-url { "url": "https://bsky.app/profile/tvpworld.bsky.social" }  →  opens Safari
```

### Root cause (not a wrapper bug — an iOS Simulator limitation)

`open-url` on iOS is a thin wrapper over `xcrun simctl openurl`. An
`https://`
URL is a **Universal Link**, which only reaches a native app when that
app is
installed and verified for the link's domain. Two things bite here:

1. On the iOS **Simulator**, `simctl openurl` routes Universal Links to
Safari
   even when the owning app *is* installed — a long-standing, documented
limitation. Custom URL schemes (`scheme://…`) route reliably; Universal
   Links do not.
2. In the reported case the Bluesky app isn't installed at all, so
Safari is
the only valid handler — identical to tapping the link on a real iPhone
with
   Bluesky absent.

Reproduced on a booted iOS 18.5 sim: `simctl openurl <bsky https link>`
launches `com.apple.mobilesafari`; the sim has zero third-party apps
installed;
`bluesky://…` fails with `-10814` (no registered handler), confirming
absence.

The real defect on our side: the result was **always** `{ opened: true
}`, so a
web URL that silently fell back to Safari was indistinguishable from a
successful deep-link — the caller (an agent) believed it had reached the
native
app. There is no reliable way to observe which app actually handled the
URL
(Safari and other apps are invisible to the native-devtools socket), so
the fix
makes the ambiguity explicit rather than guessing.

## Fix

- Add an optional `note` to the `open-url` result whenever a web URL
(`http`/
`https`) is opened on a native device (iOS + iOS-remote). It states the
URL
may have opened in the browser and points to the reliable path: the
app's
**custom scheme** (`scheme://path`) or **`launch-app`** with the bundle
id.
- Update the tool description with the same deep-linking caveat.
- Custom schemes, non-web schemes (`tel:`, `maps://`, `geo:`), and
Chromium
navigations are unaffected (no note). No URL rewriting — the URL is
still
  handed to `simctl` unchanged.

Scoped to iOS deliberately: Android App Links go through `am start` with
a
different verification/chooser path, and the reported symptom is
iOS-specific.

## Verification

- New unit tests (`open-url-deep-link-note.test.ts`, 4 cases): the
helper
returns the caveat for `http`/`https` (case-insensitive) and `undefined`
for
custom/non-web schemes; the iOS handler attaches `note` for the bsky
https
link (URL passed to `simctl` unchanged) and omits it for a custom
scheme.
- End-to-end against a live sim through the real `openUrlTool.execute`:
  - `https://bsky.app/profile/…` → `{ opened: true, url, note }`
- `maps://?q=cupertino` (installed native handler) → `{ opened: true }`,
no note
- `bluesky://…` (not installed) → correct `FailureError` (existing
behavior)
- `tsc --noEmit`, `tsc --noEmit -p tsconfig.test.json`, and `prettier
--check`
  all pass on the changed files.
2026-07-21 16:56:31 +02:00

150 lines
5.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import type { DeviceInfo } from "@argent/registry";
// iosImpl shells out via execFileAsync(promisify(execFile)). Stub the round-trip
// so the handler resolves without a real `xcrun simctl openurl`, letting us
// assert the returned result shape (the note) rather than the subprocess.
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) callback(result, { stdout: "", stderr: "" });
else callback(null, result ?? { stdout: "", stderr: "" });
},
};
});
// androidImpl goes through the adbShell util; ios-remote through simctlOpenUrl.
// Stub both at the module boundary to assert their returned result shapes.
const adbShellMock = vi.fn();
vi.mock("../src/utils/adb", async () => {
const actual = await vi.importActual<object>("../src/utils/adb");
return { ...actual, adbShell: (...args: unknown[]) => adbShellMock(...args) };
});
const simctlOpenUrlMock = vi.fn();
vi.mock("../src/utils/sim-remote", async () => {
const actual = await vi.importActual<object>("../src/utils/sim-remote");
return { ...actual, simctlOpenUrl: (...args: unknown[]) => simctlOpenUrlMock(...args) };
});
import { httpDeepLinkNote } from "../src/tools/open-url/deep-link-note";
import { iosImpl } from "../src/tools/open-url/platforms/ios";
import { androidImpl } from "../src/tools/open-url/platforms/android";
import { iosRemoteImpl } from "../src/tools/open-url/platforms/ios-remote";
const device = { platform: "ios", udid: "SIM" } as unknown as DeviceInfo;
const androidDevice = { platform: "android", udid: "emulator-5554" } as unknown as DeviceInfo;
beforeEach(() => {
execFileMock.mockReset();
execFileMock.mockReturnValue({ stdout: "", stderr: "" });
adbShellMock.mockReset();
adbShellMock.mockResolvedValue("Starting: Intent { act=android.intent.action.VIEW }");
simctlOpenUrlMock.mockReset();
simctlOpenUrlMock.mockResolvedValue(undefined);
});
describe("httpDeepLinkNote", () => {
it("returns the deep-linking caveat for http/https web URLs", () => {
for (const url of [
"https://bsky.app/profile/tvpworld.bsky.social",
"http://example.com",
"HTTPS://EXAMPLE.COM", // scheme match is case-insensitive
"http:example.com", // slashless form — browsers normalize it to http://
]) {
const note = httpDeepLinkNote(url);
expect(note, url).toBeTypeOf("string");
expect(note).toMatch(/custom scheme|Universal Links|launch-app/);
}
});
it("returns undefined for custom schemes and non-web schemes", () => {
for (const url of [
"bluesky://profile/tvpworld", // app custom scheme — routes reliably
"messages://",
"settings://",
"tel:5551234",
"mailto:a@b.com",
"geo:37.0,-122.0",
]) {
expect(httpDeepLinkNote(url), url).toBeUndefined();
}
});
});
describe("open-url iOS handler surfaces the caveat only for web URLs", () => {
it("attaches note for an https Universal Link (the bsky.app repro)", async () => {
const res = await iosImpl.handler(
{},
{ udid: "SIM", url: "https://bsky.app/profile/tvpworld.bsky.social" },
device
);
expect(res.opened).toBe(true);
expect(res.url).toBe("https://bsky.app/profile/tvpworld.bsky.social");
expect(res.note).toBeTypeOf("string");
// The exact URL is still handed to simctl unchanged (no rewriting).
expect(execFileMock).toHaveBeenCalledWith("xcrun", [
"simctl",
"openurl",
"SIM",
"https://bsky.app/profile/tvpworld.bsky.social",
]);
});
it("omits note for a custom-scheme deep link", async () => {
const res = await iosImpl.handler({}, { udid: "SIM", url: "bluesky://profile/x" }, device);
expect(res.opened).toBe(true);
expect(res.note).toBeUndefined();
});
});
describe("open-url Android handler surfaces the caveat only for web URLs", () => {
it("attaches note for an https App Link", async () => {
const res = await androidImpl.handler(
{},
{ udid: "emulator-5554", url: "https://bsky.app/profile/x" },
androidDevice
);
expect(res.opened).toBe(true);
expect(res.note).toBeTypeOf("string");
});
it("omits note for a custom-scheme deep link", async () => {
const res = await androidImpl.handler(
{},
{ udid: "emulator-5554", url: "geo:37.0,-122.0" },
androidDevice
);
expect(res.opened).toBe(true);
expect(res.note).toBeUndefined();
});
});
describe("open-url iOS remote handler surfaces the caveat only for web URLs", () => {
it("attaches note for an https Universal Link", async () => {
const res = await iosRemoteImpl.handler(
{},
{ udid: "SIM", url: "https://example.com" },
device
);
expect(res.opened).toBe(true);
expect(res.note).toBeTypeOf("string");
expect(simctlOpenUrlMock).toHaveBeenCalledWith("SIM", "https://example.com");
});
it("omits note for a custom-scheme deep link", async () => {
const res = await iosRemoteImpl.handler({}, { udid: "SIM", url: "maps://" }, device);
expect(res.opened).toBe(true);
expect(res.note).toBeUndefined();
});
});