Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- Add `uninstall <app>` and `client.apps.uninstall({ app })` to remove an installed app on adb-backed Android targets, iOS simulators, and CoreDevice iOS devices. Both report `wasInstalled`. A package name or bundle id that is not installed succeeds with `wasInstalled: false`. Other targets, Appium and Limrun sessions included, refuse it before anything runs.
- Add `settings mobile-data on|off` for adb-backed Android targets. It switches mobile data through `svc data` and reports the `mobileData` state stored in the global `mobile_data` row; multi-SIM read-back is not covered yet. Every other target refuses it before anything runs, and `--app` is refused because the switch is device-wide.
- Add `settings bluetooth on|off` for adb-backed Android targets. It turns the Bluetooth adapter on or off with `svc bluetooth`, from Android 8.1, and reports the resulting `bluetooth` state. When the device refuses the change it fails with `COMMAND_FAILED` and `error.details.reason` `bluetooth-not-applied`, or `bluetooth-unreadable` if `bluetooth_on` holds a value it does not recognize. A device without a Bluetooth adapter is refused with `UNSUPPORTED_OPERATION` and `error.details.reason: android_bluetooth_adapter_missing` before anything changes, and a device with an adapter on a build older than Android 8.1 with `android_bluetooth_command_unavailable`. `--app` is refused because the switch is device-wide.
- On iOS, a `fill` that runs under a degraded XCTest snapshot channel now aims at a point, including ref, selector and replay fills. When the runner identifies no text input under that point, it refuses with `NO_TEXT_INPUT_AT_POINT` (`error.details.reason: no_text_input_at_point`, `dispatched: no`) before dispatching its focus tap, and no field changes; when the input lookup itself cannot answer, it refuses with `TEXT_INPUT_PROBE_UNAVAILABLE` (`error.details.reason: text_input_probe_unavailable`). Previously such a fill cleared and typed through the app-level first responder, so it could rewrite a field the caller never named, or report a clear failure after emptying it. A ref or selector fill whose rect centre misses the real field (an outer container, a non-input node) now needs a point inside the input's own rect.
- Limrun iOS snapshots no longer fail when the instance reports a numeric or boolean accessibility value (the system photo picker's tabs report `1`/`0`): the provider seam normalizes variant scalars to text, the way the Apple tree does. Limrun nodes now also publish `selected: true` for nodes carrying the `Selected` trait, so snapshot output and `selected:` selectors can tell a picked photo from an unpicked one.
- `settings` refuses a setting the target does not serve before anything changes, with `UNSUPPORTED_OPERATION`, `settings <setting> is not supported on this device`, and the target's `error.details.reason` (such as `unsupported-platform-leaf`). This replaces the macOS `INVALID_ARGS` refusal and the Apple `setting-unsupported-on-leaf` reason, and now also covers settings that used to reach the device first, such as Android `reset-keychain` or Apple `animations`.
Expand Down
18 changes: 18 additions & 0 deletions packages/contracts/src/client-settings.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, expectTypeOf, test, vi } from 'vitest';
import type { SettingsUpdateOptions } from './client-settings.ts';
import type { SettingName } from './settings-catalog.ts';

type Permission = Extract<SettingsUpdateOptions, { setting: 'permission' }>;

Expand Down Expand Up @@ -43,6 +44,23 @@ describe('public client permission vocabulary', () => {
});
});

describe('public client settings cover the catalog', () => {
test('every catalog setting has an update arm', () => {
type SettingWithoutArm = {
[Name in SettingName]: [Extract<SettingsUpdateOptions, { setting: Name }>] extends [never]
? Name
: never;
}[SettingName];
expectTypeOf<SettingWithoutArm>().toEqualTypeOf<never>();
});

test('the device-wide toggles take on|off and an app to refuse', () => {
type Toggle = Extract<SettingsUpdateOptions, { setting: 'mobile-data' | 'bluetooth' }>;
expectTypeOf<Toggle['state']>().toEqualTypeOf<'on' | 'off'>();
expectTypeOf<Toggle['app']>().toEqualTypeOf<string | undefined>();
});
});

/**
* The client-facing vocabulary is a type surface. Deriving it from `settings.ts` must not put that
* module, and with it `AppError`, on the client's runtime path — a value import here would load it.
Expand Down
25 changes: 19 additions & 6 deletions packages/contracts/src/client-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,32 @@ type StateOnlySettingOptions = {
: { setting: Name; state: SettingState<Name> });
}[StateOnlySetting];

/** Switches that change the whole Android device; naming an app is refused with `setting_app_not_consumed`. */
type DeviceLevelToggleSetting = {
[Name in SettingName]: (typeof SETTINGS)[Name] extends {
app: 'input';
appScope: { mobile: 'device-level' };
}
? Name
: never;
}[SettingName];

type DeviceLevelToggleOptions = {
[Name in DeviceLevelToggleSetting]: DeviceCommandBaseOptions & {
setting: Name;
state: SettingState<Name>;
app?: string;
};
}[DeviceLevelToggleSetting];

export type SettingsUpdateOptions =
| StateOnlySettingOptions
| DeviceLevelToggleOptions
| (DeviceCommandBaseOptions & {
setting: 'clear-app-state';
state: 'clear';
app?: string;
})
/** Android only. Naming an app is refused with `setting_app_not_consumed`. */
| (DeviceCommandBaseOptions & {
setting: 'mobile-data';
state: 'on' | 'off';
app?: string;
})
/** On Apple simulators `on`/`off` is a location-permission change for `app` (or the session app). */
| (DeviceCommandBaseOptions & {
setting: 'location';
Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/settings-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ export const SETTINGS = {
forms: [{ kind: 'on-off' }],
help: 'Android switches Wi-Fi with svc wifi; an Apple simulator overrides only its status bar.',
},
bluetooth: {
forms: [{ kind: 'on-off' }],
Comment thread
mikolajadamowicz marked this conversation as resolved.
app: 'input',
appScope: { mobile: 'device-level' },
help: 'Android over adb turns its Bluetooth adapter on or off with svc bluetooth, from Android 8.1. It reports the bluetooth state the device holds afterwards, or fails with bluetooth-not-applied or bluetooth-unreadable. A device without an adapter refuses with android_bluetooth_adapter_missing, and an adapter on a build older than 8.1 with android_bluetooth_command_unavailable, before anything changes. --app is refused.',
},
airplane: {
forms: [{ kind: 'on-off' }],
help: 'Android applies it through the connectivity service (Android 11+) and reports the airplaneMode that service holds; older builds fail without changing anything. An Apple simulator overrides only its status bar.',
Expand Down
32 changes: 18 additions & 14 deletions packages/contracts/src/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ function expectInvalidArgs(run: () => unknown, message: string): void {
describe('settings usage', () => {
test('folds settings that share a form into one line', () => {
expect(SETTINGS_USAGE_OVERRIDE.split(' | ')).toEqual([
'settings <wifi|airplane|mobile-data|location|animations> <on|off>',
'settings <wifi|bluetooth|airplane|mobile-data|location|animations> <on|off>',
'settings location set <lat> <lon>',
'settings appearance <light|dark|toggle>',
`settings text-size [${TEXT_SIZE_CATEGORIES.join('|')}]`,
Expand All @@ -56,7 +56,7 @@ describe('settings usage', () => {

test('the invalid-args message lists the same forms, with the last one as an alternative', () => {
expect(SETTINGS_INVALID_ARGS_MESSAGE).toMatch(
/^settings requires <wifi\|airplane\|mobile-data\|location\|animations> <on\|off>, .*, or permission <grant\|reset> <accessibility\|screen-recording\|input-monitoring>$/,
/^settings requires <wifi\|bluetooth\|airplane\|mobile-data\|location\|animations> <on\|off>, .*, or permission <grant\|reset> <accessibility\|screen-recording\|input-monitoring>$/,
);
});

Expand All @@ -70,6 +70,10 @@ describe('parseSettingsMutation', () => {
expect(parseSettingsMutation(['WiFi', 'ON'])).toEqual({ setting: 'wifi', state: 'on' });
expect(parseSettingsMutation(['airplane', '0'])).toEqual({ setting: 'airplane', state: 'off' });
expect(parseSettingsMutation(['wifi', ' true '])).toEqual({ setting: 'wifi', state: 'on' });
expect(parseSettingsMutation(['Bluetooth', 'off'])).toEqual({
setting: 'bluetooth',
state: 'off',
});
});

test('refuses a state outside a choice with the accepted states', () => {
Expand All @@ -84,10 +88,7 @@ describe('parseSettingsMutation', () => {
});

test('refuses an unknown setting and extra positionals with the usage', () => {
expectInvalidArgs(
() => parseSettingsMutation(['bluetooth', 'on']),
SETTINGS_INVALID_ARGS_MESSAGE,
);
expectInvalidArgs(() => parseSettingsMutation(['nfc', 'on']), SETTINGS_INVALID_ARGS_MESSAGE);
expectInvalidArgs(
() => parseSettingsMutation(['reset-keychain', 'clear', 'extra']),
SETTINGS_INVALID_ARGS_MESSAGE,
Expand Down Expand Up @@ -238,14 +239,17 @@ describe('settingsAppScope', () => {
});
});

describe('settingsAppScope mobile-data', () => {
test('mobile-data switches the whole device wherever it is served', () => {
expect(settingsAppScope('mobile', 'mobile-data', 'on')).toBe('device-level');
expect(settingsAppScope('mobile', 'mobile-data', 'off')).toBe('device-level');
// No Apple target serves it; its refusal belongs to the leaf, not to the app.
expect(settingsAppScope('apple', 'mobile-data', 'on')).toBe('unknown');
expect(settingsAppScope('macos-host', 'mobile-data', 'on')).toBe('unknown');
});
describe('settingsAppScope device-wide radios', () => {
test.each(['mobile-data', 'bluetooth'])(
'%s switches the whole device wherever it is served',
(setting) => {
expect(settingsAppScope('mobile', setting, 'on')).toBe('device-level');
expect(settingsAppScope('mobile', setting, 'off')).toBe('device-level');
// No Apple target serves it; its refusal belongs to the leaf, not to the app.
expect(settingsAppScope('apple', setting, 'on')).toBe('unknown');
expect(settingsAppScope('macos-host', setting, 'on')).toBe('unknown');
},
);
});

describe('settingsAppNotConsumedRefusal', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ function settingsFacts(device: DeviceInfo) {
unsupported,
write: {
wifi: available,
bluetooth: available,
airplane: available,
'mobile-data': available,
location: available,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,29 @@ test('settings text-size reads the category the owner holds without mutating any
expect(fixtureSettingsMutations).toHaveLength(0);
});

test('settings bluetooth on an iOS simulator is refused at admission, before dispatch', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'ios-bluetooth';
sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice));

const response = await handleSnapshotCommands({
createCommandSurface: fakeCreateCommandSurface,
platformServices: daemonPlatformServicesFixture(),
req: snapshotRequest(sessionName, 'settings', { positionals: ['bluetooth', 'on'] }),
sessionName,
logPath: '/tmp/daemon.log',
sessionStore,
});

expect(response?.ok).toBe(false);
if (response && !response.ok) {
expect(response.error.code).toBe('UNSUPPORTED_OPERATION');
expect(response.error.message).toBe('settings bluetooth is not supported on this device');
expect(response.error.details?.reason).toBe('unsupported-platform-leaf');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This test claims the Bluetooth request is refused before dispatch, but it does not assert the typed dispatch disclosure. Add details.dispatched === 'no' so the test distinguishes a true admission refusal from an ambiguous dispatch status; keep the empty mutation assertion as the device-side evidence.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/daemon/src/execution/handlers/__tests__/snapshot-settings-handler.test.ts, line 248:

<comment>This test claims the Bluetooth request is refused before dispatch, but it does not assert the typed dispatch disclosure. Add `details.dispatched === 'no'` so the test distinguishes a true admission refusal from an ambiguous dispatch status; keep the empty mutation assertion as the device-side evidence.</comment>

<file context>
@@ -227,6 +227,29 @@ test('settings text-size reads the category the owner holds without mutating any
+  if (response && !response.ok) {
+    expect(response.error.code).toBe('UNSUPPORTED_OPERATION');
+    expect(response.error.message).toBe('settings bluetooth is not supported on this device');
+    expect(response.error.details?.reason).toBe('unsupported-platform-leaf');
+  }
+  expect(fixtureSettingsMutations).toHaveLength(0);
</file context>
Suggested change
expect(response.error.details?.reason).toBe('unsupported-platform-leaf');
expect(response.error.details?.reason).toBe('unsupported-platform-leaf');
expect(response.error.details?.dispatched).toBe('no');

@mikolajadamowicz mikolajadamowicz Oct 16, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not fixing this one here. The refusal comes from the shared unsupportedOperationResponse in runtime-admission.ts, and it never sets dispatched. So the router marks every mutating command refused at admission as unknown, settings or not. That needs a fix in admission for all routes, which is a separate PR.

}
expect(fixtureSettingsMutations).toHaveLength(0);
});

test('settings on the macOS host refuses a setting it does not serve, read and write alike', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'macos-settings';
Expand Down Expand Up @@ -655,31 +678,34 @@ test('settings mobile-data reaches the Android owner as an on/off mutation', asy
expect(fixtureSettingsMutations.at(-1)).toMatchObject({ setting: 'mobile-data', state: 'off' });
});

test('settings Android mobile-data refuses a named app before reaching the owner', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'android-mobile-data-app-refused';
sessionStore.publish(sessionName, makeSession(sessionName, androidDevice));
test.each(['mobile-data', 'bluetooth'])(
'settings Android %s refuses a named app before reaching the owner',
async (setting) => {
const sessionStore = makeSessionStore();
const sessionName = `android-${setting}-app-refused`;
sessionStore.publish(sessionName, makeSession(sessionName, androidDevice));

const response = await handleSnapshotCommands({
createCommandSurface: fakeCreateCommandSurface,
platformServices: daemonPlatformServicesFixture(),
req: snapshotRequest(sessionName, 'settings', {
positionals: ['mobile-data', 'off'],
input: { app: 'com.example.app' },
}),
sessionName,
logPath: '/tmp/daemon.log',
sessionStore,
});
const response = await handleSnapshotCommands({
createCommandSurface: fakeCreateCommandSurface,
platformServices: daemonPlatformServicesFixture(),
req: snapshotRequest(sessionName, 'settings', {
positionals: [setting, 'off'],
input: { app: 'com.example.app' },
}),
sessionName,
logPath: '/tmp/daemon.log',
sessionStore,
});

expect(response?.ok).toBe(false);
expect(fixtureSettingsMutations).toHaveLength(0);
if (response && !response.ok) {
expect(response.error.code).toBe('INVALID_ARGS');
expect(response.error.details?.reason).toBe('setting_app_not_consumed');
expect(response.error.details?.dispatched).toBe('no');
}
});
expect(response?.ok).toBe(false);
expect(fixtureSettingsMutations).toHaveLength(0);
if (response && !response.ok) {
expect(response.error.code).toBe('INVALID_ARGS');
expect(response.error.details?.reason).toBe('setting_app_not_consumed');
expect(response.error.details?.dispatched).toBe('no');
}
},
);

test('settings macOS permission grant refuses a named app before reaching the owner', async () => {
const sessionStore = makeSessionStore();
Expand Down
14 changes: 1 addition & 13 deletions packages/platform-android/src/__tests__/alert.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { test, vi } from 'vitest';
import assert from 'node:assert/strict';
import { underFakeTimers } from './test-utils/fake-timers.ts';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { deviceShellArgv, type ShellWord } from '@agent-device/kernel/device-shell';
import { button, node, text } from './alert-fixtures.ts';
Expand All @@ -23,19 +24,6 @@ const dialog = [
button(2, 'Cancel', 'android:id/button2', { x: 210, y: 612 }),
];

/** Runs an alert action whose polls sleep between captures, without waiting the sleeps out. */
async function underFakeTimers<T>(run: () => Promise<T>): Promise<T> {
vi.useFakeTimers();
try {
const outcome = run();
outcome.catch(() => {});
await vi.runAllTimersAsync();
return await outcome;
} finally {
vi.useRealTimers();
}
}

/** The dialog is in the tree until the button press lands, then gone — the real timeline. */
function dialogUntilPressed(nodes = dialog) {
return async () => (runAndroidAdb.mock.calls.length === 0 ? nodes : []);
Expand Down
24 changes: 24 additions & 0 deletions packages/platform-android/src/__tests__/device-sdk-int.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { readAndroidSdkInt } from '../device-sdk-int.ts';
import { withFakeAdb, type FakeAdbResponse } from './test-utils/fake-adb.ts';

test.each([
{ response: '34\n', expected: 34 },
{ response: '', expected: null },
{ response: 'unknown', expected: null },
{ response: '0', expected: null },
{ response: '27garbage', expected: null },
{ response: { stderr: 'error: closed', exitCode: 1 }, expected: null },
] satisfies { response: FakeAdbResponse; expected: number | null }[])(
'readAndroidSdkInt reads $response as $expected',
async ({ response, expected }) => {
await withFakeAdb(
() => response,
async ({ calls, device }) => {
assert.equal((await readAndroidSdkInt(device)).sdkInt, expected);
assert.deepEqual(calls, [['shell', 'getprop', 'ro.build.version.sdk']]);
},
);
},
);
22 changes: 2 additions & 20 deletions packages/platform-android/src/__tests__/fill-verification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
// whether any of a commit reached the field, which both the soft-success guard and the IME rebind
// share.

import { afterEach, beforeEach, test, vi } from 'vitest';
import { afterEach, beforeEach, test } from 'vitest';
import './test-utils/android-host-test-setup.ts';
import assert from 'node:assert/strict';
import { underFakeTimers } from './test-utils/fake-timers.ts';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { withAndroidAdbProvider, type AndroidAdbProvider } from '../adb-executor.ts';
import type { AndroidFillVerificationNode } from '../fill-diagnostics.ts';
Expand Down Expand Up @@ -145,25 +146,6 @@ test('a value that never lands is a mismatch read from the last sample', async (
);
});

/**
* Runs the sampler with its sleeps advanced instead of waited out. The clock moves in slices
* shorter than a sample interval so the helper session's own timers never fire ahead of its replies.
*/
async function underFakeTimers<T>(run: () => Promise<T>): Promise<T> {
vi.useFakeTimers();
try {
let done = false;
const outcome = run().finally(() => {
done = true;
});
outcome.catch(() => {});
while (!done) await vi.advanceTimersByTimeAsync(10);
return await outcome;
} finally {
vi.useRealTimers();
}
}

test('the pre-action target read shares the daemon-session helper with the samples', async () => {
const session = createFillHelperSession();

Expand Down
Loading
Loading