Skip to content

Commit 73cbf8a

Browse files
committed
fix: resolve ios simulator url open targets
1 parent f74ad06 commit 73cbf8a

7 files changed

Lines changed: 353 additions & 16 deletions

File tree

src/compat/maestro/__tests__/runtime-targets.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,42 @@ test('resolveMaestroNodeFromSnapshot blocks taps on app content behind React Nat
5959
});
6060
});
6161

62+
test('resolveVisibleMaestroNodeFromSnapshot does not block content behind collapsed React Native warnings', () => {
63+
const snapshot: SnapshotState = {
64+
createdAt: Date.now(),
65+
nodes: [
66+
{
67+
index: 1,
68+
ref: 'e1',
69+
type: 'android.widget.TextView',
70+
label: 'Morning Favorites',
71+
rect: { x: 24, y: 420, width: 320, height: 54 },
72+
depth: 8,
73+
},
74+
{
75+
index: 2,
76+
ref: 'e2',
77+
type: 'android.view.ViewGroup',
78+
label: 'Open debugger to view warnings',
79+
rect: { x: 0, y: 2190, width: 1080, height: 96 },
80+
depth: 6,
81+
},
82+
],
83+
};
84+
85+
const appContent = resolveVisibleMaestroNodeFromSnapshot(
86+
snapshot,
87+
'label="Morning Favorites" || text="Morning Favorites" || id="Morning Favorites"',
88+
'android',
89+
{ referenceWidth: 1080, referenceHeight: 2340 },
90+
);
91+
92+
expect(appContent).toMatchObject({
93+
ok: true,
94+
node: expect.objectContaining({ label: 'Morning Favorites' }),
95+
});
96+
});
97+
6298
test('resolveMaestroNodeFromSnapshot prefers foreground duplicate matches', () => {
6399
const snapshot: SnapshotState = {
64100
createdAt: Date.now(),

src/compat/maestro/runtime-targets.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,9 @@ function filterReactNativeOverlayBlockedMatches(
184184
if (!overlay.detected) {
185185
return { matches, blockedByReactNativeOverlay: false };
186186
}
187+
if (!overlay.redBox) {
188+
return { matches, blockedByReactNativeOverlay: false };
189+
}
187190
const overlayNodeIndexes = new Set(
188191
[...overlay.dismissNodes, ...overlay.minimizeNodes, ...overlay.collapsedNodes].map(
189192
(node) => node.index,

src/daemon/handlers/__tests__/session.test.ts

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ vi.mock('../../../platforms/ios/apps.ts', async (importOriginal) => {
6464
...actual,
6565
listIosApps: vi.fn(async () => []),
6666
resolveIosApp: vi.fn(async () => undefined),
67+
resolveIosSimulatorDeepLinkBundleId: vi.fn(async () => undefined),
6768
};
6869
});
6970
vi.mock('../../app-log.ts', async (importOriginal) => {
@@ -108,7 +109,7 @@ import {
108109
ensureAndroidEmulatorBooted,
109110
} from '../../../platforms/android/devices.ts';
110111
import { listAppleDevices } from '../../../platforms/ios/devices.ts';
111-
import { resolveIosApp } from '../../../platforms/ios/apps.ts';
112+
import { resolveIosApp, resolveIosSimulatorDeepLinkBundleId } from '../../../platforms/ios/apps.ts';
112113
import { startAppLog, stopAppLog } from '../../app-log.ts';
113114
import { defaultInstallOps, defaultReinstallOps } from '../session-deploy.ts';
114115
import { clearRequestCanceled, markRequestCanceled } from '../../request-cancel.ts';
@@ -129,6 +130,7 @@ const mockRunCmd = vi.mocked(runCmd);
129130
const mockListAndroidDevices = vi.mocked(listAndroidDevices);
130131
const mockListAppleDevices = vi.mocked(listAppleDevices);
131132
const mockResolveIosApp = vi.mocked(resolveIosApp);
133+
const mockResolveIosSimulatorDeepLinkBundleId = vi.mocked(resolveIosSimulatorDeepLinkBundleId);
132134
const mockEnsureAndroidEmulatorBooted = vi.mocked(ensureAndroidEmulatorBooted);
133135
const mockStartAppLog = vi.mocked(startAppLog);
134136
const mockStopAppLog = vi.mocked(stopAppLog);
@@ -177,6 +179,8 @@ beforeEach(() => {
177179
}
178180
return app.includes('.') ? app : `com.example.${normalizedApp}`;
179181
});
182+
mockResolveIosSimulatorDeepLinkBundleId.mockReset();
183+
mockResolveIosSimulatorDeepLinkBundleId.mockResolvedValue(undefined);
180184
mockEnsureAndroidEmulatorBooted.mockReset();
181185
mockStartAppLog.mockReset();
182186
mockStopAppLog.mockReset();
@@ -1865,6 +1869,102 @@ test('open URL on existing iOS device session preserves app bundle id context',
18651869
expect(dispatchedContext?.appBundleId).toBe('com.example.app');
18661870
});
18671871

1872+
test('open custom URL on existing iOS simulator session preserves app bundle id context', async () => {
1873+
const sessionStore = makeSessionStore();
1874+
const sessionName = 'ios-simulator-session';
1875+
sessionStore.set(sessionName, {
1876+
...makeSession(sessionName, {
1877+
platform: 'ios',
1878+
id: 'sim-1',
1879+
name: 'iPhone 17 Pro',
1880+
kind: 'simulator',
1881+
booted: true,
1882+
}),
1883+
appBundleId: 'com.example.app',
1884+
appName: 'Example App',
1885+
});
1886+
mockResolveTargetDevice.mockResolvedValue({
1887+
platform: 'ios',
1888+
id: 'sim-1',
1889+
name: 'iPhone 17 Pro',
1890+
kind: 'simulator',
1891+
booted: true,
1892+
});
1893+
1894+
let dispatchedContext: Record<string, unknown> | undefined;
1895+
mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => {
1896+
dispatchedContext = context as Record<string, unknown> | undefined;
1897+
return {};
1898+
});
1899+
1900+
const response = await handleSessionCommands({
1901+
req: {
1902+
token: 't',
1903+
session: sessionName,
1904+
command: 'open',
1905+
positionals: ['myapp://item/42'],
1906+
flags: {},
1907+
},
1908+
sessionName,
1909+
logPath: path.join(os.tmpdir(), 'daemon.log'),
1910+
sessionStore,
1911+
invoke: noopInvoke,
1912+
});
1913+
1914+
expect(response).toBeTruthy();
1915+
expect(response?.ok).toBe(true);
1916+
const updated = sessionStore.get(sessionName);
1917+
expect(updated?.appBundleId).toBe('com.example.app');
1918+
expect(updated?.appName).toBe('myapp://item/42');
1919+
expect(dispatchedContext?.appBundleId).toBe('com.example.app');
1920+
});
1921+
1922+
test('open custom URL on fresh iOS simulator session infers app bundle id from URL scheme', async () => {
1923+
const sessionStore = makeSessionStore();
1924+
const sessionName = 'ios-simulator-url-session';
1925+
mockResolveTargetDevice.mockResolvedValue({
1926+
platform: 'ios',
1927+
id: 'sim-1',
1928+
name: 'iPhone 17 Pro',
1929+
kind: 'simulator',
1930+
booted: true,
1931+
});
1932+
mockResolveIosSimulatorDeepLinkBundleId.mockResolvedValue('org.reactnavigation.playground');
1933+
1934+
let dispatchedContext: Record<string, unknown> | undefined;
1935+
mockDispatch.mockImplementation(async (_device, _command, _positionals, _out, context) => {
1936+
dispatchedContext = context as Record<string, unknown> | undefined;
1937+
return {};
1938+
});
1939+
1940+
const response = await handleSessionCommands({
1941+
req: {
1942+
token: 't',
1943+
session: sessionName,
1944+
command: 'open',
1945+
positionals: ['rne://navigator-layout'],
1946+
flags: { platform: 'ios', udid: 'sim-1' },
1947+
},
1948+
sessionName,
1949+
logPath: path.join(os.tmpdir(), 'daemon.log'),
1950+
sessionStore,
1951+
invoke: noopInvoke,
1952+
});
1953+
1954+
expect(response).toBeTruthy();
1955+
expect(response?.ok).toBe(true);
1956+
expect(mockResolveIosSimulatorDeepLinkBundleId).toHaveBeenCalledWith(
1957+
expect.objectContaining({ id: 'sim-1', kind: 'simulator' }),
1958+
'rne://navigator-layout',
1959+
);
1960+
const updated = sessionStore.get(sessionName);
1961+
expect(updated?.appBundleId).toBe('org.reactnavigation.playground');
1962+
expect(updated?.appName).toBe('rne://navigator-layout');
1963+
expect(dispatchedContext?.appBundleId).toBe('org.reactnavigation.playground');
1964+
expect(mockPrewarmIosRunnerSession).toHaveBeenCalledTimes(1);
1965+
expect(mockPrewarmIosRunnerXctestrun).not.toHaveBeenCalled();
1966+
});
1967+
18681968
test('open iOS app session prewarms runner session when app bundle id is known', async () => {
18691969
const sessionStore = makeSessionStore();
18701970
const sessionName = 'ios-device-session';
@@ -1904,7 +2004,7 @@ test('open iOS app session prewarms runner session when app bundle id is known',
19042004
expect(mockPrewarmIosRunnerXctestrun).not.toHaveBeenCalled();
19052005
});
19062006

1907-
test('open iOS URL without app bundle id keeps xctestrun-only prewarm', async () => {
2007+
test('open iOS URL without app bundle id skips runner prewarm', async () => {
19082008
const sessionStore = makeSessionStore();
19092009
const sessionName = 'ios-device-session';
19102010
sessionStore.set(
@@ -1935,7 +2035,7 @@ test('open iOS URL without app bundle id keeps xctestrun-only prewarm', async ()
19352035
expect(response).toBeTruthy();
19362036
expect(response?.ok).toBe(true);
19372037
expect(mockPrewarmIosRunnerSession).not.toHaveBeenCalled();
1938-
expect(mockPrewarmIosRunnerXctestrun).toHaveBeenCalledTimes(1);
2038+
expect(mockPrewarmIosRunnerXctestrun).not.toHaveBeenCalled();
19392039
});
19402040

19412041
test('open web URL on iOS device session without active app falls back to Safari', async () => {

src/daemon/handlers/session-open-target.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { isDeepLinkTarget, resolveIosDeviceDeepLinkBundleId } from '../../core/open-target.ts';
1+
import {
2+
isDeepLinkTarget,
3+
isWebUrl,
4+
resolveIosDeviceDeepLinkBundleId,
5+
} from '../../core/open-target.ts';
26
import type { DeviceInfo } from '../../utils/device.ts';
37

48
async function resolveIosBundleIdForOpen(
@@ -12,11 +16,28 @@ async function resolveIosBundleIdForOpen(
1216
if (device.kind === 'device') {
1317
return resolveIosDeviceDeepLinkBundleId(currentAppBundleId, openTarget);
1418
}
19+
if (!isWebUrl(openTarget)) {
20+
return (
21+
currentAppBundleId ?? (await tryResolveIosSimulatorDeepLinkBundleId(device, openTarget))
22+
);
23+
}
1524
return undefined;
1625
}
1726
return await tryResolveIosAppBundleId(device, openTarget);
1827
}
1928

29+
async function tryResolveIosSimulatorDeepLinkBundleId(
30+
device: DeviceInfo,
31+
openTarget: string,
32+
): Promise<string | undefined> {
33+
try {
34+
const { resolveIosSimulatorDeepLinkBundleId } = await import('../../platforms/ios/apps.ts');
35+
return await resolveIosSimulatorDeepLinkBundleId(device, openTarget);
36+
} catch {
37+
return undefined;
38+
}
39+
}
40+
2041
async function tryResolveIosAppBundleId(
2142
device: DeviceInfo,
2243
openTarget: string,

src/daemon/handlers/session-open.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { contextFromFlags } from '../context.ts';
55
import { createRequestCanceledError, isRequestCanceled } from '../request-cancel.ts';
66
import {
77
prewarmIosRunnerSession,
8-
prewarmIosRunnerXctestrun,
98
stopIosRunnerSession,
109
} from '../../platforms/ios/runner-client.ts';
1110
import { applyRuntimeHintsToApp } from '../runtime-hints.ts';
@@ -183,10 +182,6 @@ async function completeOpenCommand(params: {
183182
timing.runnerPrewarmKind = 'session';
184183
timing.runnerPrewarmScheduled = true;
185184
runnerPrewarm = prewarmIosRunnerSession(device, runnerPrewarmOptions);
186-
} else if (shouldPrewarmIosRunner) {
187-
timing.runnerPrewarmKind = 'xctestrun';
188-
timing.runnerPrewarmScheduled = true;
189-
runnerPrewarm = prewarmIosRunnerXctestrun(device, runnerPrewarmOptions);
190185
}
191186
const openStartedAtMs = Date.now();
192187
await dispatchCommand(device, 'open', openPositionals, req.flags?.out, {

src/platforms/ios/__tests__/index.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
readIosClipboardText,
5353
reinstallIosApp,
5454
resolveIosApp,
55+
resolveIosSimulatorDeepLinkBundleId,
5556
screenshotIos,
5657
setIosSetting,
5758
shouldFallbackToRunnerForIosScreenshot,
@@ -1640,6 +1641,76 @@ test('resolveIosApp caches display-name bundle matches but bypasses exact bundle
16401641
}
16411642
});
16421643

1644+
test('resolveIosSimulatorDeepLinkBundleId maps custom URL scheme to installed user app', async () => {
1645+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-scheme-resolve-'));
1646+
const xcrunPath = path.join(tmpDir, 'xcrun');
1647+
const plutilPath = path.join(tmpDir, 'plutil');
1648+
const appPath = path.join(tmpDir, 'ReactNavigationExample.app');
1649+
const runnerPath = path.join(tmpDir, 'AgentDeviceRunner.app');
1650+
await fs.writeFile(
1651+
xcrunPath,
1652+
[
1653+
'#!/bin/sh',
1654+
'if [ "$1" = "simctl" ] && [ "$2" = "listapps" ]; then',
1655+
" cat <<'JSON'",
1656+
JSON.stringify({
1657+
'com.callstack.agentdevice.runner': {
1658+
ApplicationType: 'User',
1659+
CFBundleDisplayName: 'AgentDeviceRunner',
1660+
Path: runnerPath,
1661+
},
1662+
'org.reactnavigation.playground': {
1663+
ApplicationType: 'User',
1664+
CFBundleDisplayName: 'React Navigation Example',
1665+
Path: appPath,
1666+
},
1667+
}),
1668+
'JSON',
1669+
' exit 0',
1670+
'fi',
1671+
'exit 1',
1672+
'',
1673+
].join('\n'),
1674+
'utf8',
1675+
);
1676+
await fs.writeFile(
1677+
plutilPath,
1678+
[
1679+
'#!/bin/sh',
1680+
'case "$5" in',
1681+
[
1682+
' *AgentDeviceRunner.app/Info.plist) echo ',
1683+
'\'{"CFBundleURLTypes":[{"CFBundleURLSchemes":["rne"]}]}\' ;;',
1684+
].join(''),
1685+
[
1686+
' *ReactNavigationExample.app/Info.plist) echo ',
1687+
'\'{"CFBundleURLTypes":[{"CFBundleURLSchemes":["rne"]}]}\' ;;',
1688+
].join(''),
1689+
' *) echo "{}" ;;',
1690+
'esac',
1691+
'exit 0',
1692+
'',
1693+
].join('\n'),
1694+
'utf8',
1695+
);
1696+
await fs.chmod(xcrunPath, 0o755);
1697+
await fs.chmod(plutilPath, 0o755);
1698+
1699+
const previousPath = process.env.PATH;
1700+
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
1701+
1702+
try {
1703+
const bundleId = await resolveIosSimulatorDeepLinkBundleId(
1704+
IOS_TEST_SIMULATOR,
1705+
'rne://navigator-layout',
1706+
);
1707+
assert.equal(bundleId, 'org.reactnavigation.playground');
1708+
} finally {
1709+
process.env.PATH = previousPath;
1710+
await fs.rm(tmpDir, { recursive: true, force: true });
1711+
}
1712+
});
1713+
16431714
test('installIosInstallablePath invalidates cached display-name bundle matches', async () => {
16441715
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-install-cache-'));
16451716
const xcrunPath = path.join(tmpDir, 'xcrun');

0 commit comments

Comments
 (0)