Skip to content

Commit 9ee8358

Browse files
committed
fix(apple): attribute the launch confirmation to the URL scheme owner within one budget
The sheet title now only recognizes a launch confirmation. The app it would open is the URL scheme owner from resolveIosSimulatorDeepLinkBundleId, so a localized app name can no longer attribute a foreign app's sheet to the session app; the listapps name matching is deleted. The alert read, the runner interactor resolution, the owner lookup and the accept share the remaining launch budget. A failed or late leg leaves the open as it was, with a diagnostic.
1 parent e2c8b9b commit 9ee8358

4 files changed

Lines changed: 260 additions & 170 deletions

File tree

‎packages/platform-apple/src/launch-confirmation.test.ts‎

Lines changed: 94 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ALERT_NOT_FOUND_RUNNER_CODE } from '@agent-device/contracts/alert-contr
33
import type { Interactor } from '@agent-device/contracts/interactor-types';
44
import type { DeviceInfo } from '@agent-device/kernel/device';
55
import { AppError } from '@agent-device/kernel/errors';
6+
import { resolveIosSimulatorDeepLinkBundleId } from './core/app-resolution.ts';
67
import {
78
answerLaunchConfirmation,
89
createLaunchConfirmationPort,
@@ -11,10 +12,7 @@ import {
1112
} from './launch-confirmation.ts';
1213

1314
vi.mock('./core/app-resolution.ts', () => ({
14-
listIosApps: vi.fn(async () => [
15-
{ bundleId: 'com.example.other', name: 'Other App' },
16-
{ bundleId: 'com.example.app', name: 'Example App' },
17-
]),
15+
resolveIosSimulatorDeepLinkBundleId: vi.fn(async () => 'com.example.app'),
1816
}));
1917

2018
const simulator: DeviceInfo = {
@@ -34,47 +32,54 @@ function alertNotFound(): AppError {
3432
});
3533
}
3634

37-
const BUDGET_MS = 60_000;
35+
/** How `exec.ts` rejects a spawn that outlives its timeout; `allowFailure` does not absorb it. */
36+
function spawnTimeout(): AppError {
37+
return new AppError('COMMAND_FAILED', 'xcrun timed out', { timeoutMs: 10_000 });
38+
}
3839

39-
const INSTALLED_APPS = [
40-
{ bundleId: 'com.example.other', name: 'Other App' },
41-
{ bundleId: 'com.example.app', name: 'Example App' },
42-
];
40+
const BUDGET_MS = 60_000;
41+
const CONFIRMATION = { message: 'Open in “Example App”?', items: ['Cancel', 'Open'] };
4342

4443
function port(
4544
readAlert: () => Promise<Record<string, unknown>>,
46-
installedApps: readonly { bundleId: string; name: string }[] = INSTALLED_APPS,
45+
legs: Readonly<{
46+
resolveUrlOwner?: () => Promise<string | undefined>;
47+
acceptAlert?: () => Promise<unknown>;
48+
}> = {},
4749
) {
48-
const acceptAlert = vi.fn(async () => ({}));
49-
const listInstalledApps = vi.fn(async () => installedApps);
50+
const acceptAlert = vi.fn(legs.acceptAlert ?? (async () => ({})));
51+
const resolveUrlOwner = vi.fn(legs.resolveUrlOwner ?? (async () => 'com.example.app'));
5052
const value: LaunchConfirmationPort = {
5153
appBundleId: 'com.example.app',
5254
readAlert: vi.fn(readAlert),
5355
acceptAlert,
54-
listInstalledApps,
56+
resolveUrlOwner,
5557
};
56-
return { port: value, acceptAlert, listInstalledApps };
58+
return { port: value, acceptAlert, resolveUrlOwner };
5759
}
5860

5961
test.each([
6062
['curly', 'Open in “Example App”?'],
6163
['straight', 'Open in "Example App"?'],
62-
])('accepts a confirmation that names the session app with %s quotes', async (_quotes, message) => {
63-
const { port: device, acceptAlert } = port(async () => ({
64-
message,
65-
items: ['Cancel', 'Open'],
66-
}));
67-
68-
await expect(answerLaunchConfirmation(device, BUDGET_MS)).resolves.toBe('accepted');
69-
expect(device.readAlert).toHaveBeenCalledOnce();
70-
expect(acceptAlert).toHaveBeenCalledOnce();
71-
});
64+
['localized-name', 'Open in “Beispiel-App”?'],
65+
])(
66+
'accepts a confirmation (%s title) when the URL owner is the session app',
67+
async (_case, message) => {
68+
const { port: device, acceptAlert } = port(async () => ({
69+
message,
70+
items: ['Cancel', 'Open'],
71+
}));
72+
73+
await expect(answerLaunchConfirmation(device, BUDGET_MS)).resolves.toBe('accepted');
74+
expect(device.readAlert).toHaveBeenCalledOnce();
75+
expect(acceptAlert).toHaveBeenCalledOnce();
76+
},
77+
);
7278

73-
test('a confirmation naming another installed app is never accepted and fails naming both apps', async () => {
74-
const { port: device, acceptAlert } = port(async () => ({
75-
message: 'Open in “Other App”?',
76-
items: ['Cancel', 'Open'],
77-
}));
79+
test('a confirmation for a URL another app owns is never accepted, whatever name it shows', async () => {
80+
const { port: device, acceptAlert } = port(async () => CONFIRMATION, {
81+
resolveUrlOwner: async () => 'com.example.other',
82+
});
7883

7984
const failure = await answerLaunchConfirmation(device, BUDGET_MS).catch(
8085
(error: unknown) => error,
@@ -84,7 +89,6 @@ test('a confirmation naming another installed app is never accepted and fails na
8489
expect((failure as AppError).code).toBe('COMMAND_FAILED');
8590
expect((failure as AppError).details).toMatchObject({
8691
reason: LAUNCH_CONFIRMATION_FOREIGN_APP_REASON,
87-
appName: 'Other App',
8892
foreignAppBundleId: 'com.example.other',
8993
sessionAppBundleId: 'com.example.app',
9094
});
@@ -95,98 +99,113 @@ test('no alert costs one read and answers nothing', async () => {
9599
const {
96100
port: device,
97101
acceptAlert,
98-
listInstalledApps,
102+
resolveUrlOwner,
99103
} = port(async () => {
100104
throw alertNotFound();
101105
});
102106

103107
await expect(answerLaunchConfirmation(device, BUDGET_MS)).resolves.toBeUndefined();
104108
expect(device.readAlert).toHaveBeenCalledOnce();
105-
expect(listInstalledApps).not.toHaveBeenCalled();
109+
expect(resolveUrlOwner).not.toHaveBeenCalled();
106110
expect(acceptAlert).not.toHaveBeenCalled();
107111
});
108112

109113
test('an alert that is not a launch confirmation is left for the caller', async () => {
110114
const {
111115
port: device,
112116
acceptAlert,
113-
listInstalledApps,
117+
resolveUrlOwner,
114118
} = port(async () => ({
115119
message: 'Allow “Example App” to use your location?',
116120
items: ['Allow Once', 'Don’t Allow'],
117121
}));
118122

119123
await expect(answerLaunchConfirmation(device, BUDGET_MS)).resolves.toBeUndefined();
120-
expect(listInstalledApps).not.toHaveBeenCalled();
124+
expect(resolveUrlOwner).not.toHaveBeenCalled();
121125
expect(acceptAlert).not.toHaveBeenCalled();
122126
});
123127

124-
test.each([
125-
['nothing installed carries', [{ bundleId: 'com.example.other', name: 'Other App' }]],
126-
[
127-
'two installed bundles share',
128-
[
129-
{ bundleId: 'com.example.app', name: 'Example App' },
130-
{ bundleId: 'com.example.app.dev', name: 'Example App' },
131-
],
132-
],
133-
])(
134-
'a confirmation naming an app %s is neither accepted nor reported as foreign',
135-
async (_case, installedApps) => {
136-
const { port: device, acceptAlert } = port(
137-
async () => ({ message: 'Open in “Example App”?', items: ['Cancel', 'Open'] }),
138-
installedApps,
139-
);
140-
141-
await expect(answerLaunchConfirmation(device, BUDGET_MS)).resolves.toBeUndefined();
142-
expect(acceptAlert).not.toHaveBeenCalled();
143-
},
144-
);
145-
146-
test('a failed alert read leaves the open unanswered', async () => {
147-
const { port: device, acceptAlert } = port(async () => {
148-
throw new AppError('COMMAND_FAILED', 'runner unavailable', { reason: 'runner-start-failed' });
128+
test('a URL no single installed app owns is neither accepted nor reported as foreign', async () => {
129+
const { port: device, acceptAlert } = port(async () => CONFIRMATION, {
130+
resolveUrlOwner: async () => undefined,
149131
});
150132

151133
await expect(answerLaunchConfirmation(device, BUDGET_MS)).resolves.toBeUndefined();
152134
expect(acceptAlert).not.toHaveBeenCalled();
153135
});
154136

155-
test('an alert read that outlives the launch budget leaves the open unanswered', async () => {
137+
test.each([
138+
[
139+
'the alert read fails',
140+
port(async () => {
141+
throw new AppError('COMMAND_FAILED', 'runner unavailable', { reason: 'runner-start-failed' });
142+
}),
143+
],
144+
[
145+
'the URL owner lookup rejects',
146+
port(async () => CONFIRMATION, {
147+
resolveUrlOwner: async () => {
148+
throw spawnTimeout();
149+
},
150+
}),
151+
],
152+
[
153+
'the accept rejects',
154+
port(async () => CONFIRMATION, {
155+
acceptAlert: async () => {
156+
throw spawnTimeout();
157+
},
158+
}),
159+
],
160+
])('the open is left unanswered when %s', async (_case, { port: device }) => {
161+
await expect(answerLaunchConfirmation(device, BUDGET_MS)).resolves.toBeUndefined();
162+
});
163+
164+
test.each([
165+
['the alert read', { read: BUDGET_MS * 2, owner: 0, accept: 0 }],
166+
['the URL owner lookup', { read: BUDGET_MS / 2, owner: BUDGET_MS * 0.75, accept: 0 }],
167+
['the accept', { read: BUDGET_MS / 3, owner: BUDGET_MS / 3, accept: BUDGET_MS / 2 }],
168+
])('%s spending the rest of one shared budget leaves the open unanswered', async (_leg, delays) => {
156169
vi.useFakeTimers();
157170
try {
158-
const { port: device, acceptAlert } = port(
159-
() =>
160-
new Promise((resolve) => {
161-
setTimeout(() => resolve({ message: 'Open in “Example App”?' }), BUDGET_MS * 2);
162-
}),
163-
);
171+
const after = <T>(ms: number, value: T) =>
172+
new Promise<T>((resolve) => {
173+
setTimeout(() => resolve(value), ms);
174+
});
175+
const { port: device } = port(async () => await after(delays.read, CONFIRMATION), {
176+
resolveUrlOwner: async () => await after(delays.owner, 'com.example.app'),
177+
acceptAlert: async () => await after(delays.accept, {}),
178+
});
164179

165180
const answer = answerLaunchConfirmation(device, BUDGET_MS);
166181
await vi.advanceTimersByTimeAsync(BUDGET_MS);
167182

168183
await expect(answer).resolves.toBeUndefined();
169-
await vi.advanceTimersByTimeAsync(BUDGET_MS);
170-
expect(acceptAlert).not.toHaveBeenCalled();
171184
} finally {
172185
vi.useRealTimers();
173186
}
174187
});
175188

176-
test('the port reads and accepts the alert for the session app and lists every installed app', async () => {
177-
const readAlert = vi.fn(async () => ({ message: 'Open in “Example App”?' }));
189+
test('the port reads and accepts the alert for the session app and asks the scheme owner', async () => {
190+
const readAlert = vi.fn(async () => CONFIRMATION);
178191
const acceptAlert = vi.fn(async () => ({}));
179192
const interactor = { readAlert, acceptAlert } as unknown as Interactor;
180193

181-
const device = createLaunchConfirmationPort(simulator, 'com.example.app', interactor);
194+
const device = createLaunchConfirmationPort(
195+
simulator,
196+
'com.example.app',
197+
'example://automation',
198+
async () => interactor,
199+
);
182200
await device.readAlert();
183201
await device.acceptAlert();
184202

185203
const target = { appBundleId: 'com.example.app', surface: 'app' };
186204
expect(readAlert).toHaveBeenCalledWith(target);
187205
expect(acceptAlert).toHaveBeenCalledWith(target);
188-
await expect(device.listInstalledApps()).resolves.toEqual([
189-
{ bundleId: 'com.example.other', name: 'Other App' },
190-
{ bundleId: 'com.example.app', name: 'Example App' },
191-
]);
206+
await expect(device.resolveUrlOwner()).resolves.toBe('com.example.app');
207+
expect(resolveIosSimulatorDeepLinkBundleId).toHaveBeenCalledWith(
208+
simulator,
209+
'example://automation',
210+
);
192211
});

0 commit comments

Comments
 (0)