Skip to content
Merged
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
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