Skip to content

Commit 7285bb1

Browse files
committed
fix(maestro): reset notifications via reset-all fallback, declare permission divergences
- iOS reset notifications bypasses the simctl probe gate into the existing reset-all fallback (verified live on iOS 26.3 where help omits the service); grant/deny stay loud rejections. - Support matrix and replay docs now declare the intentional gaps vs upstream: no silent all-allow launch default, backend-servable all expansion, loud rejections, true-reset unset, never denies.
1 parent 4de150a commit 7285bb1

4 files changed

Lines changed: 75 additions & 22 deletions

File tree

packages/maestro/src/internal/support-matrix.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export const MAESTRO_COMPAT_SUPPORTED_CAPABILITIES = [
2-
'Flows: launchApp (with clearState, permissions, and Apple-only launch arguments); setPermissions (mid-flow permission grants/denials/resets, expanded per platform); runFlow file/inline with platform, visibility, and limited boolean conditions; onFlowStart/onFlowComplete; repeat.times and retry.',
2+
'Flows: launchApp (with clearState, permissions, and Apple-only launch arguments; permissions apply after state clearing but before launch, and a launchApp without permissions touches nothing — there is no silent all: allow default); setPermissions (mid-flow permission grants/denials/resets; all expands to the backend-servable set — Android: camera/contacts/microphone/notifications/photos, iOS: the simctl privacy help subset excluding camera/notifications; anything else fails loudly instead of being skipped; unset fully resets and location: never denies); runFlow file/inline with platform, visibility, and limited boolean conditions; onFlowStart/onFlowComplete; repeat.times and retry.',
33
'Interactions: tapOn, doubleTapOn, longPressOn, inputText on the focused element, eraseText, openLink, hideKeyboard, basic pressKey, and back; selector targets poll until available and support recursive index, childOf, above, below, leftOf, rightOf, containsChild, containsDescendants, points, and optional; outer command labels are metadata, not target selectors.',
44
'Assertions and navigation: assertVisible, assertNotVisible, assertTrue (literal values and ${VAR} lookups only; "", "false", "0", "null", and "undefined" are falsy, everything else is truthy), extendedWaitUntil, scroll, scrollUntilVisible, absolute/percentage/target swipe, takeScreenshot, waitForAnimationToEnd, and stopApp.',
55
'Scripts: ordered runScript file/env scripts with http.post, json, and output variables.',

packages/platform-apple/src/core/__tests__/app-settings.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,44 @@ test('setIosSetting permission reset notifications falls back to reset all when
465465
);
466466
});
467467

468+
test('setIosSetting permission reset notifications falls back to reset all when unlisted in privacy help', async () => {
469+
// Runtimes like iOS 26.3 omit notifications from `simctl privacy help`, yet
470+
// direct reset fails only with "operation not permitted" while `reset all`
471+
// succeeds — so reset bypasses the probe gate into the existing fallback.
472+
const device: DeviceInfo = {
473+
...IOS_TEST_SIMULATOR,
474+
simulatorSetPath: '/fake/privacy-help-no-notifications',
475+
};
476+
const HELP_WITHOUT_NOTIFICATIONS = `Usage: simctl privacy <device> <action> <service> [<bundle identifier>]
477+
478+
service
479+
The service:
480+
microphone - Allow access to audio input.`;
481+
await withFakeAppleTool(
482+
(args) => {
483+
if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON;
484+
if (args.includes('help')) return HELP_WITHOUT_NOTIFICATIONS;
485+
const flat = args.join(' ');
486+
if (flat.includes('reset notifications com.example.app')) {
487+
return { stderr: 'Failed to reset access\nOperation not permitted', exitCode: 1 };
488+
}
489+
if (flat.includes('reset all com.example.app')) return '';
490+
return unexpectedArgs(args);
491+
},
492+
async ({ calls }) => {
493+
await setIosSetting(device, 'permission', 'reset', 'com.example.app', {
494+
permissionTarget: 'notifications',
495+
});
496+
const flat = calls.map((args) => args.join(' '));
497+
assert.equal(
498+
flat.some((line) => line.includes('reset all com.example.app')),
499+
true,
500+
flat.join('; '),
501+
);
502+
},
503+
);
504+
});
505+
468506
test('setIosSetting permission deny notifications returns unsupported on runtimes that block it', async () => {
469507
await withFakeAppleTool(
470508
(args) => {

packages/platform-apple/src/core/app-settings.ts

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,11 @@ async function runIosPrivacyCommand(
272272
appBundleId: string,
273273
): Promise<void> {
274274
const supportedServices = await getSimctlPrivacyServices(device);
275-
if (!supportedServices.has(target)) {
275+
// reset notifications falls back to `reset all` below (direct reset fails
276+
// with "operation not permitted" on runtimes whose help omits the service),
277+
// so it passes the probe gate even when the service is unlisted. Grant/deny
278+
// for notifications stay loud rejections.
279+
if (!supportedServices.has(target) && !(action === 'reset' && target === 'notifications')) {
276280
throw new AppError(
277281
'UNSUPPORTED_OPERATION',
278282
`iOS simctl privacy does not support service "${target}" on this runtime.`,
@@ -285,29 +289,40 @@ async function runIosPrivacyCommand(
285289
}
286290

287291
const args = ['privacy', device.id, action, target, appBundleId];
288-
const isNotificationsTarget = target === 'notifications';
289-
if (!(action === 'reset' && isNotificationsTarget)) {
290-
try {
291-
await runSimctl(device, args);
292-
return;
293-
} catch (error) {
294-
if (!(isNotificationsTarget && isNotificationsOperationNotPermitted(error))) {
295-
throw error;
296-
}
297-
throw new AppError(
298-
'UNSUPPORTED_OPERATION',
299-
'iOS simulator does not support setting notifications permission via simctl privacy on this runtime.',
300-
{
301-
deviceId: device.id,
302-
appBundleId,
303-
hint: 'Use reset notifications for reprompt behavior, or toggle notifications manually in Settings.',
304-
},
305-
);
292+
if (action === 'reset' && target === 'notifications') {
293+
await resetIosNotificationsPermission(device, appBundleId);
294+
return;
295+
}
296+
try {
297+
await runSimctl(device, args);
298+
return;
299+
} catch (error) {
300+
if (!(target === 'notifications' && isNotificationsOperationNotPermitted(error))) {
301+
throw error;
306302
}
303+
throw new AppError(
304+
'UNSUPPORTED_OPERATION',
305+
'iOS simulator does not support setting notifications permission via simctl privacy on this runtime.',
306+
{
307+
deviceId: device.id,
308+
appBundleId,
309+
hint: 'Use reset notifications for reprompt behavior, or toggle notifications manually in Settings.',
310+
},
311+
);
307312
}
313+
}
308314

315+
/**
316+
* Direct `reset notifications` fails with "operation not permitted" on
317+
* runtimes whose help omits the service, while `reset all` succeeds — so
318+
* reset goes through the fallback instead of failing loudly like grant/deny.
319+
*/
320+
async function resetIosNotificationsPermission(
321+
device: DeviceInfo,
322+
appBundleId: string,
323+
): Promise<void> {
309324
try {
310-
await runSimctl(device, args);
325+
await runSimctl(device, ['privacy', device.id, 'reset', 'notifications', appBundleId]);
311326
return;
312327
} catch (error) {
313328
if (!isNotificationsOperationNotPermitted(error)) {

website/docs/docs/replay-e2e.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ agent-device test ./maestro-flows --maestro --platform android --artifacts-dir .
7070

7171
Supported subset:
7272

73-
- Flows: `launchApp` (with `clearState`, `permissions`, and Apple-only launch arguments); `setPermissions` (mid-flow permission grants/denials/resets, expanded per platform); `runFlow` file/inline with platform, visibility, and limited boolean conditions; `onFlowStart`/`onFlowComplete`; `repeat.times` and retry.
73+
- Flows: `launchApp` (with `clearState`, `permissions`, and Apple-only launch arguments; `permissions` apply after state clearing but before launch, and a `launchApp` without `permissions` touches nothing — there is no silent `all: allow` default); `setPermissions` (mid-flow permission grants/denials/resets; `all` expands to the backend-servable set — Android: camera/contacts/microphone/notifications/photos, iOS: the `simctl privacy help` subset excluding `camera`/`notifications`; anything else fails loudly instead of being skipped; `unset` fully resets and `location: never` denies); `runFlow` file/inline with platform, visibility, and limited boolean conditions; `onFlowStart`/`onFlowComplete`; `repeat.times` and retry.
7474
- Interactions: `tapOn`, `doubleTapOn`, `longPressOn`, `inputText` on the focused element, `eraseText`, `openLink`, `hideKeyboard`, basic `pressKey`, and `back`; selector targets poll until available and support recursive `index`, `childOf`, `above`, `below`, `leftOf`, `rightOf`, `containsChild`, `containsDescendants`, points, and `optional`; outer command labels are metadata, not target selectors.
7575
- Assertions and navigation: `assertVisible`, `assertNotVisible`, `assertTrue` (literal values and `${VAR}` lookups only; `""`, `"false"`, `"0"`, `"null"`, and `"undefined"` are falsy, everything else is truthy), `extendedWaitUntil`, `scroll`, `scrollUntilVisible`, absolute/percentage/target `swipe`, `takeScreenshot`, `waitForAnimationToEnd`, and `stopApp`.
7676
- Scripts: ordered `runScript` file/env scripts with `http.post`, `json`, and `output` variables.

0 commit comments

Comments
 (0)