From 24a9d09c44508a98434d81cfff28798e62de8169 Mon Sep 17 00:00:00 2001 From: Mikolaj Adamowicz Date: Fri, 16 Oct 2026 12:45:16 +0000 Subject: [PATCH 1/5] feat(apps): add standalone uninstall command Expose `uninstall ` so a test can start without the app installed, for example before a fresh install from a build or a store. Android reuses the adb uninstall helper reinstall already owns; iOS simulators use `simctl uninstall` and CoreDevice devices use `devicectl device uninstall app`, through the helper reinstall already uses. The app resolves against installed apps: a package name or bundle id that is not installed succeeds with `wasInstalled: false`, and another name that matches nothing fails with APP_NOT_INSTALLED. Other owners report the operation unavailable. --- CHANGELOG.md | 1 + packages/command-registry/src/registry.ts | 6 ++ .../src/app-deployment-runtime-plan.ts | 2 + .../contracts/src/app-deployment-runtime.ts | 21 ++++- packages/contracts/src/client-app.ts | 4 + packages/contracts/src/facades/client.ts | 1 + .../src/platform-runtime-unavailable.ts | 1 + .../contracts/src/runtime-operation-names.ts | 1 + .../handlers/session-command-harness.ts | 14 ++++ .../src/core/runtime-operation-effects.ts | 1 + .../handlers/__tests__/install-source.test.ts | 1 + .../__tests__/session-uninstall.test.ts | 60 ++++++++++++++ .../handlers/session-app-deployment-route.ts | 13 ++- .../handlers/session-app-deployment.ts | 41 ++++++++++ .../daemon/src/sessions/handlers/session.ts | 1 + ...on-capabilities-install-projection.test.ts | 2 + .../session-capabilities.fixtures.ts | 1 + .../src/deployment/runtime.test.ts | 37 +++++++++ .../src/deployment/runtime.ts | 34 ++++++++ .../src/deployment/runtime.test.ts | 59 ++++++++++++++ .../platform-apple/src/deployment/runtime.ts | 29 +++++++ packages/platform-apple/src/runner-demand.ts | 1 + .../src/deployment/runtime.ts | 2 + packages/platform-web/src/runtime.test.ts | 13 +++ packages/platform-web/src/runtime.ts | 1 + .../provider-limrun/src/deployment-runtime.ts | 8 ++ packages/provider-limrun/src/facts-runtime.ts | 1 + .../test-kit/src/runtime-operation-facts.ts | 1 + src/__tests__/cli-client-commands.test.ts | 21 +++-- src/__tests__/remote-connection.test.ts | 4 +- src/agent-device-client.ts | 2 + src/cli/commands/proxy-lease-device.ts | 1 + src/client/client-types.ts | 2 + src/commands/management/install.ts | 34 ++++++++ ...untime-android-deployment-executor.test.ts | 5 +- ...orm-runtime-android-deployment-executor.ts | 4 +- ...-runtime-apple-deployment-executor.test.ts | 4 +- ...tform-runtime-apple-deployment-executor.ts | 4 +- .../android-uninstall.test.ts | 80 +++++++++++++++++++ website/docs/docs/client-api.md | 2 +- website/docs/docs/commands.md | 9 +++ 41 files changed, 505 insertions(+), 24 deletions(-) create mode 100644 packages/daemon/src/sessions/handlers/__tests__/session-uninstall.test.ts create mode 100644 test/integration/provider-scenarios/android-uninstall.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ffc292f9a0..679a8b79a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Add `uninstall ` to remove an installed app on Android, iOS simulators, and CoreDevice iOS devices. A package name or bundle id that is not installed succeeds with `wasInstalled: false`. - 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 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`. - `settings` refuses, before touching the device, a state its setting does not accept (`Invalid state: …`), a permission action or mode its target does not take (such as `permission deny accessibility`), and a stray extra argument (such as `clear-app-state `). The CLI and the daemon accept the same state spellings, in any casing or surrounding whitespace. diff --git a/packages/command-registry/src/registry.ts b/packages/command-registry/src/registry.ts index 5b13d2b498..303a38e35c 100644 --- a/packages/command-registry/src/registry.ts +++ b/packages/command-registry/src/registry.ts @@ -27,6 +27,7 @@ import { deployAppUse, readyMaterializeAndDeployAppUse, readySendPushNotificationUse, + uninstallAppUse, } from '@agent-device/contracts/app-deployment-runtime-plan'; import { closeApplicationRuntimePlanUses, @@ -952,6 +953,11 @@ export const RAW_COMMAND_DESCRIPTORS = [ name: 'reinstall', ...DEPLOY_APP_COMMAND_DESCRIPTOR, }, + { + name: 'uninstall', + ...DEPLOY_APP_COMMAND_DESCRIPTOR, + platformExecution: { kind: 'device-runtime', use: uninstallAppUse }, + }, { name: 'install_source', deviceClaimPolicy: 'transient-exclusive', diff --git a/packages/contracts/src/app-deployment-runtime-plan.ts b/packages/contracts/src/app-deployment-runtime-plan.ts index d7d41b048f..69f166e42c 100644 --- a/packages/contracts/src/app-deployment-runtime-plan.ts +++ b/packages/contracts/src/app-deployment-runtime-plan.ts @@ -2,6 +2,8 @@ import { defineUse } from './platform-runtime-operations.ts'; export const deployAppUse = defineUse({ required: ['deployApp'] }); +export const uninstallAppUse = defineUse({ required: ['uninstallApp'] }); + /** * Source installs retain their readiness precondition in the same request binding as * materialization and deployment, so the handler has one admission and one bind. diff --git a/packages/contracts/src/app-deployment-runtime.ts b/packages/contracts/src/app-deployment-runtime.ts index 711a6858c6..d942032207 100644 --- a/packages/contracts/src/app-deployment-runtime.ts +++ b/packages/contracts/src/app-deployment-runtime.ts @@ -55,12 +55,21 @@ export type PushNotificationResult = Readonly<{ extrasCount?: number; }>; +export type AppUninstallInput = Readonly<{ app: string }>; + +/** `wasInstalled: false` means the target names no installed app, so nothing was removed. */ +export type AppUninstallResult = + | Readonly<{ wasInstalled: false }> + | Readonly<{ wasInstalled: true; bundleId: string }> + | Readonly<{ wasInstalled: true; packageName: string }>; + /** The request-scoped deployment facet; no operation creates a durable resource. */ export type AppDeploymentRuntimeOperations = Readonly<{ deployApp(input: AppDeploymentInput): Promise; materializeAppSource(input: MaterializeAppSourceInput): Promise; deployMaterializedApp(input: DeployMaterializedAppInput): Promise; sendPushNotification(input: PushNotificationInput): Promise; + uninstallApp(input: AppUninstallInput): Promise; }>; /** @@ -77,7 +86,11 @@ export type AppleAppDeploymentExecutor = Readonly<{ input: MaterializeAppSourceInput, options: Readonly<{ appIdentifierHint?: string; signal: AbortSignal }>, ): Promise; - resolveAppBundleId(device: DeviceInfo, app: string): Promise; + resolveAppBundleId( + device: DeviceInfo, + app: string, + options?: Readonly<{ installedOnly?: boolean }>, + ): Promise; }>; /** @@ -96,5 +109,9 @@ export type AndroidAppDeploymentExecutor = Readonly<{ input: MaterializeAppSourceInput, options: Readonly<{ resolveIdentity?: boolean; signal: AbortSignal }>, ): Promise; - resolveAppPackage(device: DeviceInfo, app: string): Promise; + resolveAppPackage( + device: DeviceInfo, + app: string, + options?: Readonly<{ installedOnly?: boolean }>, + ): Promise; }>; diff --git a/packages/contracts/src/client-app.ts b/packages/contracts/src/client-app.ts index 30ee79dde7..765785d57e 100644 --- a/packages/contracts/src/client-app.ts +++ b/packages/contracts/src/client-app.ts @@ -185,6 +185,10 @@ export type AppPushOptions = DeviceCommandBaseOptions & { payload: string | JsonObject; }; +export type AppUninstallOptions = DeviceCommandBaseOptions & { + app: string; +}; + export type AppTriggerEventOptions = DeviceCommandBaseOptions & { event: string; payload?: JsonObject; diff --git a/packages/contracts/src/facades/client.ts b/packages/contracts/src/facades/client.ts index 739099fb64..d751df86ee 100644 --- a/packages/contracts/src/facades/client.ts +++ b/packages/contracts/src/facades/client.ts @@ -11,6 +11,7 @@ export type { AppOpenResult, AppPushOptions, AppTriggerEventOptions, + AppUninstallOptions, DeviceSelectionMetadata, DeviceSelectionReason, DeviceSelectionSource, diff --git a/packages/contracts/src/platform-runtime-unavailable.ts b/packages/contracts/src/platform-runtime-unavailable.ts index d44c9a3620..8fe5ff79ee 100644 --- a/packages/contracts/src/platform-runtime-unavailable.ts +++ b/packages/contracts/src/platform-runtime-unavailable.ts @@ -202,6 +202,7 @@ export function createUnavailablePlatformRuntimeFacts( materializeAppSource: frozen.appDeployment, deployMaterializedApp: frozen.appDeployment, sendPushNotification: frozen.appDeployment, + uninstallApp: frozen.appDeployment, appState: frozen.appState, networkDump: frozen.network, screenRecordingStart: frozen.screenRecording, diff --git a/packages/contracts/src/runtime-operation-names.ts b/packages/contracts/src/runtime-operation-names.ts index 1239630cbb..55d799875e 100644 --- a/packages/contracts/src/runtime-operation-names.ts +++ b/packages/contracts/src/runtime-operation-names.ts @@ -87,6 +87,7 @@ export const RUNTIME_OPERATION_NAMES = [ 'triggerAppEvent', 'tvRemote', 'typeText', + 'uninstallApp', 'writeClipboard', ] as const; diff --git a/packages/daemon/src/__tests__/support/handlers/session-command-harness.ts b/packages/daemon/src/__tests__/support/handlers/session-command-harness.ts index a74329053b..ec833d4482 100644 --- a/packages/daemon/src/__tests__/support/handlers/session-command-harness.ts +++ b/packages/daemon/src/__tests__/support/handlers/session-command-harness.ts @@ -8,6 +8,8 @@ import type { MaterializedAppSource, PushNotificationInput, PushNotificationResult, + AppUninstallInput, + AppUninstallResult, } from '@agent-device/contracts/app-deployment-runtime'; import { applicationLifecycleOperationFacts } from '@agent-device/contracts/application-lifecycle-runtime'; import type { EnsureReadyInput } from '@agent-device/contracts/device-readiness-runtime'; @@ -66,6 +68,9 @@ export const mockDeployMaterializedAppRuntime = vi.fn( export const mockPushNotificationRuntime = vi.fn( async (_input: PushNotificationInput): Promise => ({}), ); +export const mockUninstallAppRuntime = vi.fn( + async (_input: AppUninstallInput): Promise => ({ wasInstalled: false }), +); export const mockBindDeviceRuntime = vi.fn( async ( device: DeviceInfo, @@ -96,6 +101,8 @@ beforeEach(() => { mockDeployMaterializedAppRuntime.mockResolvedValue({}); mockPushNotificationRuntime.mockReset(); mockPushNotificationRuntime.mockResolvedValue({}); + mockUninstallAppRuntime.mockReset(); + mockUninstallAppRuntime.mockResolvedValue({ wasInstalled: false }); mockBindDeviceRuntime.mockClear(); }); @@ -157,6 +164,7 @@ function readinessFacts(device: DeviceInfo): RuntimeFacts await mockPushNotificationRuntime(input), } : {}), + ...(facts.operations.uninstallApp.available + ? { + uninstallApp: async (input) => await mockUninstallAppRuntime(input), + } + : {}), ...lifecycle, }, [Symbol.asyncDispose]: async () => {}, @@ -267,6 +280,7 @@ function deploymentAvailability(device: DeviceInfo) { source: deploy && (device.platform === 'apple' || device.platform === 'android'), push: device.platform === 'android' || (device.platform === 'apple' && device.kind === 'simulator'), + uninstall: deploy && (device.platform === 'apple' || device.platform === 'android'), }; } diff --git a/packages/daemon/src/core/runtime-operation-effects.ts b/packages/daemon/src/core/runtime-operation-effects.ts index cf2e1b6c89..5fcb5d109a 100644 --- a/packages/daemon/src/core/runtime-operation-effects.ts +++ b/packages/daemon/src/core/runtime-operation-effects.ts @@ -94,5 +94,6 @@ export const RUNTIME_OPERATION_EFFECTS: Readonly< triggerAppEvent: 'mutates', tvRemote: 'mutates', typeText: 'mutates', + uninstallApp: 'mutates', writeClipboard: 'mutates', }); diff --git a/packages/daemon/src/sessions/handlers/__tests__/install-source.test.ts b/packages/daemon/src/sessions/handlers/__tests__/install-source.test.ts index 55018dc3de..0b0ebb07dc 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/install-source.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/install-source.test.ts @@ -476,6 +476,7 @@ function sourceRuntimeFacts( materializeAppSource: materializationAvailable ? { available: true } : unavailable, deployMaterializedApp: materializationAvailable ? { available: true } : unavailable, sendPushNotification: unavailable, + uninstallApp: unavailable, networkDump: unavailable, screenRecordingStart: unavailable, screenRecordingReattach: unavailable, diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-uninstall.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-uninstall.test.ts new file mode 100644 index 0000000000..9dff1777cb --- /dev/null +++ b/packages/daemon/src/sessions/handlers/__tests__/session-uninstall.test.ts @@ -0,0 +1,60 @@ +import { daemonPlatformServicesFixture } from '../../../__tests__/support/platform-services-fixture.ts'; +import { expect, test } from 'vitest'; +import type { SessionState } from '../../../core/session-state.ts'; +import { makeSessionStore } from '../../../__tests__/support/store-factory.ts'; +import { ANDROID_EMULATOR } from '@agent-device/test-kit'; +import { + handleSessionCommands, + mockBindDeviceRuntime, + mockUninstallAppRuntime, +} from '../../../__tests__/support/handlers/session-command-harness.ts'; +import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; + +async function uninstall(device: SessionState['device'], app: string) { + const sessionStore = makeSessionStore('agent-device-session-uninstall-'); + sessionStore.publish('default', { name: 'default', createdAt: Date.now(), actions: [], device }); + return await handleSessionCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: { token: 't', session: 'default', command: 'uninstall', positionals: [app], flags: {} }, + sessionName: 'default', + logPath: '/tmp/daemon.log', + sessionStore, + invoke: async () => { + throw new Error('uninstall must stay on the runtime route'); + }, + }); +} + +test.each([ + [ + { wasInstalled: true, packageName: 'com.example.app' }, + { wasInstalled: true, appId: 'com.example.app', message: 'Uninstalled: com.example.app' }, + ], + [{ wasInstalled: false }, { wasInstalled: false, message: 'Not installed: com.example.app' }], +] as const)('uninstall reports %j through one admitted binding', async (removed, expected) => { + mockUninstallAppRuntime.mockResolvedValueOnce(removed); + + await expect(uninstall(ANDROID_EMULATOR, 'com.example.app')).resolves.toEqual({ + ok: true, + data: { platform: 'android', app: 'com.example.app', ...expected }, + }); + expect(mockBindDeviceRuntime.mock.calls[0]?.[1]).toMatchObject({ required: ['uninstallApp'] }); +}); + +test('uninstall is refused before binding where the runtime fact closes it', async () => { + const harmony = { + platform: 'harmonyos', + id: 'hdc-1', + name: 'Harmony', + kind: 'device', + booted: true, + } as const; + + await expect(uninstall(harmony, 'com.example.app')).resolves.toMatchObject({ + ok: false, + error: { code: 'UNSUPPORTED_OPERATION' }, + }); + expect(mockBindDeviceRuntime).not.toHaveBeenCalled(); + expect(mockUninstallAppRuntime).not.toHaveBeenCalled(); +}); diff --git a/packages/daemon/src/sessions/handlers/session-app-deployment-route.ts b/packages/daemon/src/sessions/handlers/session-app-deployment-route.ts index 494f60701c..2e3dcf7cff 100644 --- a/packages/daemon/src/sessions/handlers/session-app-deployment-route.ts +++ b/packages/daemon/src/sessions/handlers/session-app-deployment-route.ts @@ -9,10 +9,11 @@ import type { DaemonRequest } from '../../core/daemon-request.ts'; import { handleAppDeploymentCommand, handlePushNotificationCommand, + handleUninstallCommand, } from './session-app-deployment.ts'; import { handleInstallFromSourceDeploymentCommand } from './session-app-source-deployment.ts'; -/** The four canonical install-family descriptors share one daemon route boundary. */ +/** The canonical install-family descriptors share one daemon route boundary. */ export async function handleSessionAppDeploymentCommand(params: { req: DaemonRequest; sessionName: string; @@ -43,6 +44,16 @@ export async function handleSessionAppDeploymentCommand(params: { platformServices, }); } + if (req.command === 'uninstall') { + return await handleUninstallCommand({ + req, + sessionName, + sessionStore, + inspectFacts, + bindDevice, + platformServices, + }); + } if (req.command === 'push') { return await handlePushNotificationCommand({ req, diff --git a/packages/daemon/src/sessions/handlers/session-app-deployment.ts b/packages/daemon/src/sessions/handlers/session-app-deployment.ts index cb6a09e7f4..7290f6c5dd 100644 --- a/packages/daemon/src/sessions/handlers/session-app-deployment.ts +++ b/packages/daemon/src/sessions/handlers/session-app-deployment.ts @@ -6,6 +6,7 @@ import type { AppDeploymentResult } from '@agent-device/contracts/app-deployment import { deployAppUse, readySendPushNotificationUse, + uninstallAppUse, } from '@agent-device/contracts/app-deployment-runtime-plan'; import { isIosFamily, publicPlatformString } from '@agent-device/kernel/device'; import { readNotificationPayload } from '../dispatch-payload.ts'; @@ -166,6 +167,46 @@ export async function handlePushNotificationCommand( return { ok: true, data }; } +export async function handleUninstallCommand( + params: RuntimeCommandHandlerParams, +): Promise { + const { req, sessionName, sessionStore } = params; + const ref = sessionStore.lookup(sessionName); + const session = ref?.session; + const flags = req.flags ?? {}; + const guard = requireSessionOrExplicitSelector('uninstall', session, flags); + if (guard) return guard; + + const app = req.positionals?.[0]?.trim(); + if (!app) return errorResponse('INVALID_ARGS', 'uninstall requires '); + const device = await resolveCommandDevice({ + session, + flags, + platformServices: params.platformServices, + }); + const facts = await requireRuntimeFacts(params.inspectFacts)(device); + const unsupported = unavailableRuntimeOperationResponse( + 'uninstall', + facts.operations.uninstallApp, + ); + if (unsupported) return unsupported; + + const runtime = await requireRuntimeBinding(params.bindDevice)(device, uninstallAppUse); + if (ref) expireRefFrame(sessionStore.requireCurrent(ref)); + const result = await runtime.operations.uninstallApp({ app }); + const platform = isIosFamily(device) ? 'ios' : 'android'; + const appId = !result.wasInstalled + ? undefined + : 'bundleId' in result + ? result.bundleId + : result.packageName; + const data = appId + ? withSuccessText({ platform, app, wasInstalled: true, appId }, `Uninstalled: ${appId}`) + : withSuccessText({ platform, app, wasInstalled: false }, `Not installed: ${app}`); + recordSessionAction(sessionStore, ref, req, 'uninstall', data); + return { ok: true, data }; +} + function buildDeployResult( device: SessionState['device'], app: string, diff --git a/packages/daemon/src/sessions/handlers/session.ts b/packages/daemon/src/sessions/handlers/session.ts index d6cf629619..94a8461f2d 100644 --- a/packages/daemon/src/sessions/handlers/session.ts +++ b/packages/daemon/src/sessions/handlers/session.ts @@ -191,6 +191,7 @@ const SESSION_COMMAND_HANDLER_IMPLS = { }), install: handleSessionAppDeploymentCommand, reinstall: handleSessionAppDeploymentCommand, + uninstall: handleSessionAppDeploymentCommand, install_source: handleSessionAppDeploymentCommand, release_materialized_paths: async ({ req }) => await handleReleaseMaterializedPathsCommand({ req }), diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-capabilities-install-projection.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-capabilities-install-projection.test.ts index d195dd3475..8041eb4b7d 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-capabilities-install-projection.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-capabilities-install-projection.test.ts @@ -39,6 +39,7 @@ test('capabilities projects the install family from exactly one facts inspection expect.arrayContaining([ PUBLIC_COMMANDS.install, PUBLIC_COMMANDS.reinstall, + PUBLIC_COMMANDS.uninstall, PUBLIC_COMMANDS.installFromSource, PUBLIC_COMMANDS.push, ]), @@ -105,6 +106,7 @@ test('capabilities fails closed for every fact-owned command when facts inspecti expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.logs); expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.install); expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.reinstall); + expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.uninstall); expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.installFromSource); expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.push); expect(runtime.uses).toEqual([]); diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-capabilities.fixtures.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-capabilities.fixtures.ts index 812dbc7671..b17d64abf9 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-capabilities.fixtures.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-capabilities.fixtures.ts @@ -89,6 +89,7 @@ function createAdmissionFacts( appLogInspect: cell(options.appLogAvailable), captureScreenshot: screenshotFact, deployApp: cell(options.deployAvailable), + uninstallApp: cell(options.deployAvailable), materializeAppSource: cell(options.sourceAvailable), deployMaterializedApp: cell(options.sourceAvailable), sendPushNotification: cell(options.pushAvailable), diff --git a/packages/platform-android/src/deployment/runtime.test.ts b/packages/platform-android/src/deployment/runtime.test.ts index 1da8aa17d9..f64df6103e 100644 --- a/packages/platform-android/src/deployment/runtime.test.ts +++ b/packages/platform-android/src/deployment/runtime.test.ts @@ -2,6 +2,7 @@ import { beforeEach, expect, test, vi } from 'vitest'; import type { AndroidAppDeploymentExecutor } from '@agent-device/contracts/app-deployment-runtime'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; const native = vi.hoisted(() => ({ installAndroidArtifact: vi.fn(), @@ -193,3 +194,39 @@ test('keeps ordinary Android install successful when identity is unavailable', a ).resolves.toEqual({}); expect(native.inferAndroidAppName).not.toHaveBeenCalled(); }); + +test.each([ + [ + 'an installed package', + async (): Promise => 'com.example.demo', + { wasInstalled: true, packageName: 'com.example.demo' }, + ], + ['a missing package', notInstalled, { wasInstalled: false }], +] as const)('uninstall of %s', async (_name, resolveAppPackage, expected) => { + const host = hostFixture({ resolveAppPackage }); + const operations = createAndroidAppDeploymentOperations({ + host, + device, + signal: new AbortController().signal, + }); + + await expect(operations.uninstallApp?.({ app: 'com.example.demo' })).resolves.toEqual(expected); + expect(native.uninstallAndroidPackage).toHaveBeenCalledTimes(expected.wasInstalled ? 1 : 0); +}); + +test('uninstall refuses a name that matches no installed package', async () => { + const operations = createAndroidAppDeploymentOperations({ + host: hostFixture({ resolveAppPackage: notInstalled }), + device, + signal: new AbortController().signal, + }); + + await expect(operations.uninstallApp?.({ app: 'Demo' })).rejects.toMatchObject({ + code: 'APP_NOT_INSTALLED', + }); + expect(native.uninstallAndroidPackage).not.toHaveBeenCalled(); +}); + +async function notInstalled(): Promise { + throw new AppError('APP_NOT_INSTALLED', 'No package found'); +} diff --git a/packages/platform-android/src/deployment/runtime.ts b/packages/platform-android/src/deployment/runtime.ts index f618457356..250874d240 100644 --- a/packages/platform-android/src/deployment/runtime.ts +++ b/packages/platform-android/src/deployment/runtime.ts @@ -2,6 +2,8 @@ import type { AppDeploymentInput, AppDeploymentResult, AppDeploymentRuntimeOperations, + AppUninstallInput, + AppUninstallResult, DeployMaterializedAppInput, MaterializeAppSourceInput, MaterializedAppSource, @@ -12,6 +14,7 @@ import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runt import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { ensureAndroidReady } from '../readiness/runtime.ts'; +import { classifyAndroidAppTarget } from '../open-target.ts'; import { inferAndroidAppName, installAndroidArtifact, @@ -26,6 +29,7 @@ export function androidAppDeploymentFacts(device: DeviceInfo): Readonly<{ materializeAppSource: RuntimeOperationFact; deployMaterializedApp: RuntimeOperationFact; sendPushNotification: RuntimeOperationFact; + uninstallApp: RuntimeOperationFact; }> { const fact = androidDeployFact(device); return Object.freeze({ @@ -33,6 +37,7 @@ export function androidAppDeploymentFacts(device: DeviceInfo): Readonly<{ materializeAppSource: fact, deployMaterializedApp: fact, sendPushNotification: fact, + uninstallApp: fact, }); } @@ -53,6 +58,35 @@ export function createAndroidAppDeploymentOperations(params: { await deployPreparedAndroidApp(host, device, input, signal), sendPushNotification: async (input: PushNotificationInput) => await pushAndroidNotification(host, device, input, signal), + uninstallApp: async (input: AppUninstallInput) => + await uninstallAndroidApp(host, device, input, signal), + }); +} + +async function uninstallAndroidApp( + host: PlatformRuntimeHost, + device: DeviceInfo, + input: AppUninstallInput, + signal: AbortSignal, +): Promise { + return await host.androidDeployment.withInvalidatedAppResolutionCache(device, async () => { + if (device.booted !== true) { + await ensureAndroidReady(host, device, { headless: false }, signal); + } + let packageName: string; + try { + packageName = await host.androidDeployment.resolveAppPackage(device, input.app, { + installedOnly: true, + }); + } catch (error) { + const notInstalled = error instanceof AppError && error.code === 'APP_NOT_INSTALLED'; + if (notInstalled && classifyAndroidAppTarget(input.app) === 'package') { + return { wasInstalled: false }; + } + throw error; + } + await uninstallAndroidPackage(host, device, packageName, signal); + return { wasInstalled: true, packageName }; }); } diff --git a/packages/platform-apple/src/deployment/runtime.test.ts b/packages/platform-apple/src/deployment/runtime.test.ts index e61d347b0d..c52e912a47 100644 --- a/packages/platform-apple/src/deployment/runtime.test.ts +++ b/packages/platform-apple/src/deployment/runtime.test.ts @@ -227,6 +227,7 @@ test.each([ ]) { expect(operation.available).toBe(deployAvailable); } + expect(facts.uninstallApp).toBe(facts.deployApp); expect(facts.sendPushNotification.available).toBe(pushAvailable); if (unavailableReason) { expect(deployAvailable ? facts.sendPushNotification : facts.deployApp).toMatchObject({ @@ -625,3 +626,61 @@ test('clears a concurrently repopulated fuzzy resolution after a partial-failed expect(withInvalidatedAppResolutionCache).toHaveBeenCalledOnce(); expect(resolveFuzzyTarget).toHaveBeenCalledTimes(2); }); + +test.each([ + [ + 'iOS simulator', + appleDevice(), + ['simctl', 'uninstall', 'apple-deployment-fact', 'com.example.app'], + ], + [ + 'physical iOS CoreDevice', + appleDevice({ kind: 'device', iosPhysicalDeviceBackend: 'coredevice' }), + [ + 'devicectl', + 'device', + 'uninstall', + 'app', + '--device', + 'apple-deployment-fact', + 'com.example.app', + ], + ], +] as const)( + 'uninstall on the %s removes an installed bundle and skips a missing one', + async (_name, device, argv) => { + const resolveAppBundleId = vi.fn(async () => 'com.example.app'); + const run = xcrunLikeRun(bootedSimulatorListResult); + const operations = createAppleAppDeploymentOperations({ + host: deploymentHost( + { + resolveAppBundleId, + withInvalidatedAppResolutionCache: withoutInvalidatingAppResolutionCache, + } as unknown as AppleAppDeploymentExecutor, + run, + ), + device, + signal: new AbortController().signal, + }); + const uninstalls = () => + run.mock.calls + .map(([request]) => [request.tool, ...request.args]) + .filter((call) => call.includes('uninstall')); + + await expect(operations.uninstallApp?.({ app: 'Example' })).resolves.toEqual({ + wasInstalled: true, + bundleId: 'com.example.app', + }); + expect(resolveAppBundleId).toHaveBeenCalledWith(device, 'Example', { installedOnly: true }); + expect(uninstalls()).toEqual([argv]); + + resolveAppBundleId.mockRejectedValue(new AppError('APP_NOT_INSTALLED', 'No app found')); + await expect(operations.uninstallApp?.({ app: 'com.example.gone' })).resolves.toEqual({ + wasInstalled: false, + }); + await expect(operations.uninstallApp?.({ app: 'Gone' })).rejects.toMatchObject({ + code: 'APP_NOT_INSTALLED', + }); + expect(uninstalls()).toEqual([argv]); + }, +); diff --git a/packages/platform-apple/src/deployment/runtime.ts b/packages/platform-apple/src/deployment/runtime.ts index 22dd945e4a..a1439c9c0d 100644 --- a/packages/platform-apple/src/deployment/runtime.ts +++ b/packages/platform-apple/src/deployment/runtime.ts @@ -2,6 +2,8 @@ import type { AppDeploymentInput, AppDeploymentResult, AppDeploymentRuntimeOperations, + AppUninstallInput, + AppUninstallResult, DeployMaterializedAppInput, MaterializeAppSourceInput, PushNotificationInput, @@ -46,6 +48,7 @@ export function appleAppDeploymentFacts(device: DeviceInfo): Readonly<{ materializeAppSource: RuntimeOperationFact; deployMaterializedApp: RuntimeOperationFact; sendPushNotification: RuntimeOperationFact; + uninstallApp: RuntimeOperationFact; }> { const deploy = appleDeployFact(device); return Object.freeze({ @@ -53,6 +56,7 @@ export function appleAppDeploymentFacts(device: DeviceInfo): Readonly<{ materializeAppSource: deploy, deployMaterializedApp: deploy, sendPushNotification: applePushFact(device), + uninstallApp: deploy, }); } @@ -72,6 +76,8 @@ export function createAppleAppDeploymentOperations(params: { await host.appleDeployment.prepareArtifact(input, { signal }), deployMaterializedApp: async (input: DeployMaterializedAppInput) => await deployPreparedAppleApp(host, device, input, signal), + uninstallApp: async (input: AppUninstallInput) => + await uninstallResolvedAppleApp(host, device, input, signal), } : {}), ...(facts.sendPushNotification.available @@ -122,6 +128,29 @@ async function deployAppleApp( } } +async function uninstallResolvedAppleApp( + host: PlatformRuntimeHost, + device: DeviceInfo, + input: AppUninstallInput, + signal: AbortSignal, +): Promise { + return await host.appleDeployment.withInvalidatedAppResolutionCache(device, async () => { + await ensureAppleReady(host, device, signal); + let bundleId: string; + try { + bundleId = await host.appleDeployment.resolveAppBundleId(device, input.app, { + installedOnly: true, + }); + } catch (error) { + const notInstalled = error instanceof AppError && error.code === 'APP_NOT_INSTALLED'; + if (notInstalled && input.app.trim().includes('.')) return { wasInstalled: false }; + throw error; + } + await uninstallAppleApp(host, device, bundleId, signal); + return { wasInstalled: true, bundleId }; + }); +} + async function deployPreparedAppleApp( host: PlatformRuntimeHost, device: DeviceInfo, diff --git a/packages/platform-apple/src/runner-demand.ts b/packages/platform-apple/src/runner-demand.ts index 0bd6ec01f7..b665535be3 100644 --- a/packages/platform-apple/src/runner-demand.ts +++ b/packages/platform-apple/src/runner-demand.ts @@ -35,6 +35,7 @@ const APPLE_SIMULATOR_OPERATION_HOSTS: Readonly< materializeAppSource: 'simulator', deployMaterializedApp: 'simulator', sendPushNotification: 'simulator', + uninstallApp: 'simulator', appState: 'simulator', appLogStart: 'simulator', appLogInspect: 'simulator', diff --git a/packages/platform-harmonyos/src/deployment/runtime.ts b/packages/platform-harmonyos/src/deployment/runtime.ts index 86d7db2036..ef4cb8a0dd 100644 --- a/packages/platform-harmonyos/src/deployment/runtime.ts +++ b/packages/platform-harmonyos/src/deployment/runtime.ts @@ -25,6 +25,7 @@ export function harmonyAppDeploymentFacts(device: DeviceInfo): Readonly<{ materializeAppSource: RuntimeOperationFact; deployMaterializedApp: RuntimeOperationFact; sendPushNotification: RuntimeOperationFact; + uninstallApp: RuntimeOperationFact; }> { const deploy = device.kind === 'emulator' || device.kind === 'device' ? available : unsupportedKind; @@ -33,6 +34,7 @@ export function harmonyAppDeploymentFacts(device: DeviceInfo): Readonly<{ materializeAppSource: unsupportedLeaf, deployMaterializedApp: unsupportedLeaf, sendPushNotification: unsupportedLeaf, + uninstallApp: unsupportedLeaf, }); } diff --git a/packages/platform-web/src/runtime.test.ts b/packages/platform-web/src/runtime.test.ts index 9138c1e3d0..129df46109 100644 --- a/packages/platform-web/src/runtime.test.ts +++ b/packages/platform-web/src/runtime.test.ts @@ -522,6 +522,19 @@ test('install and reinstall share the runtime-owned unavailable deploy fact', as expect(binding.operations.deployApp).toBeUndefined(); }); +test('uninstall reports the runtime-owned unavailable uninstall fact', async () => { + const binding = await createWebPlatformRuntime(host({ mode: 'transport-composed' })).bind({ + device, + intent: { kind: 'ordinary' }, + scope: scope(), + }); + expect(binding.facts.operations.uninstallApp).toEqual({ + available: false, + reason: 'unsupported-platform-leaf', + }); + expect(binding.operations.uninstallApp).toBeUndefined(); +}); + // #1900: `install-from-source` resolves to `readyMaterializeAndDeployAppUse`, requiring all three // of these facts; none of them exist for a browser target. test('install-from-source reports the runtime-owned unavailable materialize and deploy facts', async () => { diff --git a/packages/platform-web/src/runtime.ts b/packages/platform-web/src/runtime.ts index 50b1798dd3..8042097880 100644 --- a/packages/platform-web/src/runtime.ts +++ b/packages/platform-web/src/runtime.ts @@ -435,6 +435,7 @@ function webRuntimeFacts( materializeAppSource: readinessUnavailable, deployMaterializedApp: readinessUnavailable, sendPushNotification: readinessUnavailable, + uninstallApp: readinessUnavailable, shutdownTarget: readinessUnavailable, ...webLifecycleFacts(device), }, diff --git a/packages/provider-limrun/src/deployment-runtime.ts b/packages/provider-limrun/src/deployment-runtime.ts index a03ffd1753..c7e7d1fda8 100644 --- a/packages/provider-limrun/src/deployment-runtime.ts +++ b/packages/provider-limrun/src/deployment-runtime.ts @@ -28,6 +28,10 @@ const pushUnavailable = unavailableFact( 'unsupported-provider-mode', 'Push notifications are unavailable for Limrun provider-owned devices.', ); +const uninstallUnavailable = unavailableFact( + 'unsupported-provider-mode', + 'App uninstall is unavailable for Limrun provider-owned devices.', +); export type LimrunAppDeploymentRuntimeOptions = Readonly<{ host: PlatformRuntimeHost; @@ -56,6 +60,7 @@ export function limrunAppDeploymentFacts( materializeAppSource: RuntimeOperationFact; deployMaterializedApp: RuntimeOperationFact; sendPushNotification: RuntimeOperationFact; + uninstallApp: RuntimeOperationFact; }> { const deployment = limrunDeploymentFact(options, device); return Object.freeze({ @@ -65,6 +70,9 @@ export function limrunAppDeploymentFacts( sendPushNotification: isActiveLimrunRuntimeSession(options, device) ? pushUnavailable : deploymentUnavailable, + uninstallApp: isActiveLimrunRuntimeSession(options, device) + ? uninstallUnavailable + : deploymentUnavailable, }); } diff --git a/packages/provider-limrun/src/facts-runtime.ts b/packages/provider-limrun/src/facts-runtime.ts index 63bba7fb8e..02d0fc798e 100644 --- a/packages/provider-limrun/src/facts-runtime.ts +++ b/packages/provider-limrun/src/facts-runtime.ts @@ -260,6 +260,7 @@ export function limrunAppLogRecoveryFacts( materializeAppSource: liveSessionUnavailable, deployMaterializedApp: liveSessionUnavailable, sendPushNotification: liveSessionUnavailable, + uninstallApp: liveSessionUnavailable, shutdownTarget: liveSessionUnavailable, ...limrunLifecycleFacts(device, false), }, diff --git a/packages/test-kit/src/runtime-operation-facts.ts b/packages/test-kit/src/runtime-operation-facts.ts index 0c958d0b59..778a1a7ed2 100644 --- a/packages/test-kit/src/runtime-operation-facts.ts +++ b/packages/test-kit/src/runtime-operation-facts.ts @@ -31,6 +31,7 @@ const unavailableDeploymentOperationFacts = Object.freeze({ materializeAppSource: unavailable, deployMaterializedApp: unavailable, sendPushNotification: unavailable, + uninstallApp: unavailable, }); /** Default fact for tests that are unrelated to explicit device shutdown. */ diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index fd842ce36a..f65ae9f46c 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -1098,6 +1098,12 @@ function createStubClient(params: { throw new Error('unexpected command call'); }; const command = createThrowingMethodGroup(); + const deployDemo: AgentDeviceClient['apps']['install'] = async () => ({ + app: 'Demo', + appPath: '/tmp/Demo.app', + platform: 'ios', + identifiers: { appId: 'com.example.demo' }, + }); return { command, devices: { @@ -1114,18 +1120,9 @@ function createStubClient(params: { artifacts: unexpectedCommandCall, }, apps: { - install: async () => ({ - app: 'Demo', - appPath: '/tmp/Demo.app', - platform: 'ios', - identifiers: { appId: 'com.example.demo' }, - }), - reinstall: async () => ({ - app: 'Demo', - appPath: '/tmp/Demo.app', - platform: 'ios', - identifiers: { appId: 'com.example.demo' }, - }), + install: deployDemo, + reinstall: deployDemo, + uninstall: unexpectedCommandCall, installFromSource: params.installFromSource, list: params.listApps ?? (async () => []), open: diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 313e20afcd..902c7f3548 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -640,7 +640,7 @@ test('proxy open resolves device key before allocating lease', async () => { fs.rmSync(tempRoot, { recursive: true, force: true }); }); -test('proxy install allocates a device lease before dispatch', async () => { +test.each(['install', 'uninstall'])('proxy %s allocates a lease first', async (command) => { const { tempRoot, stateDir, remoteConfigPath } = connectionWorkspace( 'agent-device-connect-proxy-install-', ); @@ -663,7 +663,7 @@ test('proxy install allocates a device lease before dispatch', async () => { }); const materialized = await materializeRemoteConnectionForCommand({ - command: 'install', + command, flags: { json: true, help: false, diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index 1b662d201a..6bb2e88d15 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -262,6 +262,8 @@ export function createAgentDeviceClient( await executeCommand('reinstall', options), resolveRequestSession(options), ), + uninstall: async (options) => + await executeCommand>('uninstall', options), installFromSource: async (options: AppInstallFromSourceOptions) => normalizeInstallFromSourceResult( await executeCommand('install-from-source', options), diff --git a/src/cli/commands/proxy-lease-device.ts b/src/cli/commands/proxy-lease-device.ts index 28494cbcee..5e8c04b6dd 100644 --- a/src/cli/commands/proxy-lease-device.ts +++ b/src/cli/commands/proxy-lease-device.ts @@ -22,6 +22,7 @@ const proxyLeaseAllocatingCommands: ReadonlySet = new Set([ PUBLIC_COMMANDS.open, PUBLIC_COMMANDS.install, PUBLIC_COMMANDS.reinstall, + PUBLIC_COMMANDS.uninstall, INTERNAL_COMMANDS.installSource, ]); diff --git a/src/client/client-types.ts b/src/client/client-types.ts index 25b493df43..77bb3be7ea 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -72,6 +72,7 @@ import type { AppStateCommandOptions, AppSwitcherCommandOptions, AppTriggerEventOptions, + AppUninstallOptions, AudioOptions, BackCommandOptions, BatchRunOptions, @@ -216,6 +217,7 @@ export type AgentDeviceClient = { apps: { install: (options: AppInstallOptions) => Promise; reinstall: (options: AppDeployOptions) => Promise; + uninstall: (options: AppUninstallOptions) => Promise>; installFromSource: ( options: AppInstallFromSourceOptions, ) => Promise; diff --git a/src/commands/management/install.ts b/src/commands/management/install.ts index 6258fa58e7..9c5b0e21a1 100644 --- a/src/commands/management/install.ts +++ b/src/commands/management/install.ts @@ -42,6 +42,14 @@ const reinstallCommandMetadata = defineFieldCommandMetadata( }, ); +const uninstallCommandMetadata = defineFieldCommandMetadata( + 'uninstall', + 'Remove an installed app from the selected device. A package name or bundle id that is not installed succeeds with wasInstalled: false.', + { + app: requiredField(stringField('Package name, bundle id, or installed app name.')), + }, +); + const installFromSourceCommandMetadata = defineFieldCommandMetadata( 'install-from-source', 'Install app builds from URLs, remote source specs, or CI artifacts resolved by a remote daemon.', @@ -64,6 +72,10 @@ const reinstallCliSchema = { positionalArgs: ['app', 'path'], } as const satisfies CommandSchemaOverride; +const uninstallCliSchema = { + positionalArgs: ['app'], +} as const satisfies CommandSchemaOverride; + const installFromSourceCliSchema = { usageOverride: 'install-from-source | install-from-source --github-actions-artifact ', @@ -78,6 +90,11 @@ const installCliReader: CliReader = (positionals, flags) => installInputFromCli( const reinstallCliReader: CliReader = (positionals, flags) => reinstallInputFromCli(positionals, flags); +const uninstallCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + app: requiredString(positionals[0], 'uninstall requires app'), +}); + const installFromSourceCliReader: CliReader = (positionals, flags) => ({ ...commonInputFromFlags(flags), source: resolveInstallSource(positionals, flags), @@ -93,6 +110,10 @@ const reinstallDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.reinstall, (i requiredPair(input.app, input.appPath), ); +const uninstallDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.uninstall, (input) => [ + requiredDaemonString(input.app, 'missing app'), +]); + const installFromSourceDaemonWriter: DaemonWriter = (input) => request(INTERNAL_COMMANDS.installSource, [], { ...input, @@ -127,6 +148,18 @@ const reinstallCommandFacet = defineCommandFacet({ cliOutputFormatter: managementCliOutputFormatters.reinstall, }); +const uninstallCommandFacet = defineCommandFacet({ + name: 'uninstall', + text: { + summary: 'Remove an installed app', + }, + metadata: uninstallCommandMetadata, + run: (client, input) => client.apps.uninstall(input), + cliSchema: uninstallCliSchema, + cliReader: uninstallCliReader, + daemonWriter: uninstallDaemonWriter, +}); + const installFromSourceCommandFacet = defineCommandFacet({ name: 'install-from-source', text: { @@ -143,6 +176,7 @@ const installFromSourceCommandFacet = defineCommandFacet({ export const installManagementCommandFacets = [ installCommandFacet, reinstallCommandFacet, + uninstallCommandFacet, installFromSourceCommandFacet, ] as const; diff --git a/src/platform-runtime-android-deployment-executor.test.ts b/src/platform-runtime-android-deployment-executor.test.ts index ea5df9401e..ce97987b7f 100644 --- a/src/platform-runtime-android-deployment-executor.test.ts +++ b/src/platform-runtime-android-deployment-executor.test.ts @@ -26,7 +26,10 @@ test('keeps root composition limited to artifact preparation and app identity re const executor = createAndroidAppDeploymentExecutor(); await expect(executor.resolveAppPackage(device, 'Example')).resolves.toBe('com.example.app'); - expect(resolveAndroidApp).toHaveBeenCalledWith(device, 'Example'); + expect(resolveAndroidApp).toHaveBeenCalledWith(device, 'Example', undefined); + resolveAndroidApp.mockResolvedValueOnce({ type: 'package', value: 'com.example.app' }); + await executor.resolveAppPackage(device, 'Example', { installedOnly: true }); + expect(resolveAndroidApp).toHaveBeenLastCalledWith(device, 'Example', { installedOnly: true }); expect(Object.keys(executor).sort()).toEqual([ 'bundletoolJar', 'prepareArtifact', diff --git a/src/platform-runtime-android-deployment-executor.ts b/src/platform-runtime-android-deployment-executor.ts index a61202d8f5..45420cd01a 100644 --- a/src/platform-runtime-android-deployment-executor.ts +++ b/src/platform-runtime-android-deployment-executor.ts @@ -16,9 +16,9 @@ export function createAndroidAppDeploymentExecutor(): AndroidAppDeploymentExecut const { prepareAndroidInstallArtifact } = await loadAndroidMechanics(); return await prepareAndroidInstallArtifact(input.source, options); }, - resolveAppPackage: async (device, app) => { + resolveAppPackage: async (device, app, options) => { const { resolveAndroidApp } = await loadAndroidMechanics(); - const resolved = await resolveAndroidApp(device, app); + const resolved = await resolveAndroidApp(device, app, options); if (resolved.type === 'intent') { const { AppError } = await import('@agent-device/kernel/errors'); throw new AppError('INVALID_ARGS', 'App uninstall requires a package name, not an intent'); diff --git a/src/platform-runtime-apple-deployment-executor.test.ts b/src/platform-runtime-apple-deployment-executor.test.ts index 5a0ddf3b29..14ba67d9e3 100644 --- a/src/platform-runtime-apple-deployment-executor.test.ts +++ b/src/platform-runtime-apple-deployment-executor.test.ts @@ -29,7 +29,9 @@ test('keeps only artifact and identity capabilities in the root adapter', async await expect(executor.resolveAppBundleId(device, 'Example')).resolves.toBe('com.example.app'); - expect(resolveIosApp).toHaveBeenCalledWith(device, 'Example'); + expect(resolveIosApp).toHaveBeenCalledWith(device, 'Example', undefined); + await executor.resolveAppBundleId(device, 'Example', { installedOnly: true }); + expect(resolveIosApp).toHaveBeenLastCalledWith(device, 'Example', { installedOnly: true }); expect(executor).not.toHaveProperty('install'); expect(executor).not.toHaveProperty('uninstall'); expect(executor).not.toHaveProperty('push'); diff --git a/src/platform-runtime-apple-deployment-executor.ts b/src/platform-runtime-apple-deployment-executor.ts index 94d962aeb5..5099809cc1 100644 --- a/src/platform-runtime-apple-deployment-executor.ts +++ b/src/platform-runtime-apple-deployment-executor.ts @@ -19,9 +19,9 @@ export function createAppleAppDeploymentExecutor(): AppleAppDeploymentExecutor { await import('@agent-device/platform-apple/install-artifact'); return await prepareIosInstallArtifact(input.source, options); }, - resolveAppBundleId: async (device, app) => { + resolveAppBundleId: async (device, app, options) => { const { resolveIosApp } = await import('@agent-device/platform-apple/app-resolution'); - return await resolveIosApp(device, app); + return await resolveIosApp(device, app, options); }, }); } diff --git a/test/integration/provider-scenarios/android-uninstall.test.ts b/test/integration/provider-scenarios/android-uninstall.test.ts new file mode 100644 index 0000000000..4d668d8bed --- /dev/null +++ b/test/integration/provider-scenarios/android-uninstall.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { AndroidAdbProvider } from '@agent-device/platform-android/mechanics'; +import { assertCommandCall } from './assertions.ts'; +import { createProviderScenarioHarness, withProviderScenarioResource } from './harness.ts'; +import { runProviderScenario } from './scenario.ts'; + +const PROVIDER_SCENARIO_ANDROID: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + target: 'mobile', + booted: true, +}; + +test('Provider-backed integration Android uninstall removes an installed package and no-ops on a missing one', async () => { + const adbCalls: string[][] = []; + const adbProvider: AndroidAdbProvider = { + exec: async (args) => { + adbCalls.push([...args]); + if (args.join(' ') === 'shell pm list packages') { + return { stdout: 'package:com.example.demo\n', stderr: '', exitCode: 0 }; + } + return { stdout: args[0] === 'uninstall' ? 'Success\n' : '', stderr: '', exitCode: 0 }; + }, + }; + + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + androidAdbProvider: () => adbProvider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }), + async (daemon) => { + daemon.publishSession('default', { + name: 'default', + device: PROVIDER_SCENARIO_ANDROID, + createdAt: Date.now(), + actions: [], + }); + + await runProviderScenario(daemon, [ + { + name: 'uninstall an installed package', + command: 'uninstall', + positionals: ['com.example.demo'], + flags: { platform: 'android' }, + expectData: { + platform: 'android', + app: 'com.example.demo', + wasInstalled: true, + appId: 'com.example.demo', + message: 'Uninstalled: com.example.demo', + }, + }, + { + name: 'uninstall a package nothing installed matches', + command: 'uninstall', + positionals: ['com.example.missing'], + flags: { platform: 'android' }, + expectData: { + platform: 'android', + app: 'com.example.missing', + wasInstalled: false, + message: 'Not installed: com.example.missing', + }, + }, + ]); + + assertCommandCall(adbCalls, ['uninstall', 'com.example.demo']); + assert.equal( + adbCalls.some((call) => call[0] === 'uninstall' && call.includes('com.example.missing')), + false, + 'a package nothing installed matches must not reach adb uninstall', + ); + }, + ); +}); diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 54dec4c1b7..77dc2d31c3 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -247,7 +247,7 @@ Domain client methods: - `client.devices.list()`, `capabilities()`, `boot()`, `shutdown()` - `client.sessions.list()`, `stateDir()`, `close()`, `saveScript()`, `artifacts()` -- `client.apps.install()`, `reinstall()`, `installFromSource()`, `list()`, `open()`, `close()`, `push()`, `triggerEvent()` +- `client.apps.install()`, `reinstall()`, `uninstall()`, `installFromSource()`, `list()`, `open()`, `close()`, `push()`, `triggerEvent()` - `client.materializations.release()` - `client.leases.allocate()`, `heartbeat()`, `release()`. Pass `retainOnClose: true` to `allocate()` when you release the lease yourself; session `close` then leaves it active until `release()`, expiry, or daemon shutdown. The returned lease has `retainOnClose: true` only when the daemon honored it. Keep heartbeating a retained lease between sessions: it expires after its `ttlMs` like any other lease. Pass `leaseId` (16 to 128 hex characters) to `allocate()` to name a new lease with an id the daemon has never issued, or to get back your live lease with that id when you retry with the same tenant, run, client, lease backend, and provider (and no device, or the leased one), then use the returned `leaseId`: a run that already holds a lease gets that one back, and an older daemon mints its own. - `client.metro.prepare()`, `reload()` diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 56e495d187..fdaeeb1129 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -578,6 +578,15 @@ agent-device reinstall com.example.app ./build/MyApp.app --platform ios - `reinstall ` uninstalls and installs in one command, for login/logout resets and deterministic test setup. Device coverage, formats, and remote upload match `install`. +## App uninstall + +```bash +agent-device uninstall com.example.app --platform android +``` + +- `uninstall ` removes an installed app, for example to start a test without the app installed. Android (ADB), iOS simulators, and CoreDevice iOS devices are supported. +- A package name or bundle id that is not installed succeeds with `wasInstalled: false`; another name that matches nothing installed fails with `APP_NOT_INSTALLED`. + ## App install from source URL ```bash From 36c92021f5e17954c906e9cf6d896f4275cabcec Mon Sep 17 00:00:00 2001 From: Mikolaj Adamowicz Date: Fri, 16 Oct 2026 12:45:16 +0000 Subject: [PATCH 2/5] chore(gates): register uninstall in command pin sets and coverage manifests --- .../registry-device-claim-policy.test.ts | 1 + .../__tests__/daemon-command-registry.test.ts | 1 + .../command-descriptor-timeout-policy.test.ts | 1 + src/commands/replay/replay-script.fixtures.ts | 1 + .../command-coverage/declarations.ts | 28 +++++++++++++++++++ test/integration/command-coverage/evidence.ts | 6 ++++ .../apple-platform-output-guard.test.ts | 1 + 7 files changed, 39 insertions(+) diff --git a/packages/command-registry/src/__tests__/registry-device-claim-policy.test.ts b/packages/command-registry/src/__tests__/registry-device-claim-policy.test.ts index 82b8b503af..7fe7e1bca6 100644 --- a/packages/command-registry/src/__tests__/registry-device-claim-policy.test.ts +++ b/packages/command-registry/src/__tests__/registry-device-claim-policy.test.ts @@ -49,6 +49,7 @@ test('every command that deviates from require-owner is a reviewed, diffable set 'push', 'reinstall', 'shutdown', + 'uninstall', ], observe: ['apps', 'appstate', 'capabilities', 'device', 'devices', 'doctor', 'takeover'], none: [ diff --git a/packages/daemon/src/core/__tests__/daemon-command-registry.test.ts b/packages/daemon/src/core/__tests__/daemon-command-registry.test.ts index b426e2bb90..cbc621a946 100644 --- a/packages/daemon/src/core/__tests__/daemon-command-registry.test.ts +++ b/packages/daemon/src/core/__tests__/daemon-command-registry.test.ts @@ -135,6 +135,7 @@ test('daemon command registry preserves replay and recording traits', () => { PUBLIC_COMMANDS.triggerAppEvent, PUBLIC_COMMANDS.tvRemote, PUBLIC_COMMANDS.type, + PUBLIC_COMMANDS.uninstall, PUBLIC_COMMANDS.viewport, PUBLIC_COMMANDS.wait, ]) { diff --git a/src/__tests__/command-descriptor-timeout-policy.test.ts b/src/__tests__/command-descriptor-timeout-policy.test.ts index c095b867a1..3b1bad5492 100644 --- a/src/__tests__/command-descriptor-timeout-policy.test.ts +++ b/src/__tests__/command-descriptor-timeout-policy.test.ts @@ -147,6 +147,7 @@ test('request envelopes deviating from the default are bounded, reviewed sets', prepare: 270_000, install: 180_000, reinstall: 180_000, + uninstall: 180_000, install_source: 180_000, longpress: 210_000, // fold: proven by the worst-case ledger test in diff --git a/src/commands/replay/replay-script.fixtures.ts b/src/commands/replay/replay-script.fixtures.ts index 8a6cea2a96..e763d7005a 100644 --- a/src/commands/replay/replay-script.fixtures.ts +++ b/src/commands/replay/replay-script.fixtures.ts @@ -179,6 +179,7 @@ const REPLAY_SCRIPT_LINE_PLANS = { trace: GENERIC_REPLAY_LINE, 'trigger-app-event': GENERIC_REPLAY_LINE, 'tv-remote': GENERIC_REPLAY_LINE, + uninstall: GENERIC_REPLAY_LINE, viewport: GENERIC_REPLAY_LINE, human_control: { waived: 'host-local control commands are never recorded in replay scripts' }, install_source: GENERIC_REPLAY_LINE, diff --git a/test/integration/command-coverage/declarations.ts b/test/integration/command-coverage/declarations.ts index 888fff4428..5388c655fb 100644 --- a/test/integration/command-coverage/declarations.ts +++ b/test/integration/command-coverage/declarations.ts @@ -19,6 +19,7 @@ import { ANDROID_FOLD_RUNTIME_CONTRACT_EVIDENCE, ANDROID_HOVER_RUNTIME_CONTRACT_EVIDENCE, ANDROID_TV_REMOTE_RUNTIME_CONTRACT_EVIDENCE, + ANDROID_UNINSTALL_CONTRACT_EVIDENCE, ANDROID_VIEWPORT_RUNTIME_CONTRACT_EVIDENCE, APPLE_ACTION_BUTTON_FACT_EVIDENCE, APPLE_HOVER_DENIAL_EVIDENCE, @@ -502,6 +503,33 @@ const COMMAND_COVERAGE_DECLARATIONS = { ), linux: linux.gap('No Linux-specific application reinstallation command evidence exists yet'), }, + [C.uninstall]: { + androidEmulator: androidEmulator.contract( + ANDROID_UNINSTALL_CONTRACT_EVIDENCE, + 'scripted ADB scenario removes an installed package and skips adb for a missing one', + ), + iosSimulator: iosSimulator.contract( + 'packages/platform-apple/src/deployment/runtime.test.ts', + 'uninstall on the %s removes an installed bundle and skips a missing one', + 'simctl uninstall dispatch through the shared Apple deployment operations', + ), + macos: macos.contract( + 'packages/platform-apple/src/deployment/runtime.test.ts', + 'classifies deployment facts for the %s denominator cell', + 'Apple deployment facts close application uninstall on the macOS host', + ), + tvos: tvos.contract( + 'packages/platform-apple/src/deployment/runtime.test.ts', + 'classifies deployment facts for the %s denominator cell', + 'shared Apple deployment facts admit app uninstall on tvOS simulators', + ), + web: web.contract( + 'packages/platform-web/src/runtime.test.ts', + 'uninstall reports the runtime-owned unavailable uninstall fact', + 'the web runtime fact rejects native app uninstall', + ), + linux: linux.gap('No Linux-specific application uninstall command evidence exists yet'), + }, [C.push]: { androidEmulator: androidEmulator.live( 'full:lifecycle-system', diff --git a/test/integration/command-coverage/evidence.ts b/test/integration/command-coverage/evidence.ts index f43f1720ee..ba50ae1bd6 100644 --- a/test/integration/command-coverage/evidence.ts +++ b/test/integration/command-coverage/evidence.ts @@ -50,6 +50,12 @@ export const ANDROID_FOLD_RUNTIME_CONTRACT_EVIDENCE: AndroidContractEvidence = [C.fold], 'Android admits the fold fact on emulators and refuses every other kind', ); +export const ANDROID_UNINSTALL_CONTRACT_EVIDENCE: AndroidContractEvidence = + defineAndroidContractEvidence( + 'test/integration/provider-scenarios/android-uninstall.test.ts', + [C.uninstall], + 'Provider-backed integration Android uninstall removes an installed package and no-ops on a missing one', + ); export const ANDROID_VIEWPORT_RUNTIME_CONTRACT_EVIDENCE: AndroidContractEvidence = defineAndroidContractEvidence( 'packages/daemon/src/execution/__tests__/viewport-runtime.test.ts', diff --git a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts index 3230bba540..fe2ccee83b 100644 --- a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts +++ b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts @@ -85,6 +85,7 @@ const DRIVEN_COMMANDS: Record = { [PUBLIC_COMMANDS.apps]: () => one(), [PUBLIC_COMMANDS.install]: ({ appPath }) => one(['com.example.demo', appPath]), [PUBLIC_COMMANDS.reinstall]: ({ appPath }) => one(['com.example.demo', appPath]), + [PUBLIC_COMMANDS.uninstall]: () => one(['com.example.demo']), [PUBLIC_COMMANDS.installFromSource]: ({ appPath }) => one([appPath]), [PUBLIC_COMMANDS.push]: ({ tmpDir }) => one([path.join(tmpDir, 'push-src.txt'), '/tmp/dest']), [PUBLIC_COMMANDS.triggerAppEvent]: () => one(['url', 'https://example.com']), From dd532cb3fd663d2633a1ac0d18246be0a1074991 Mon Sep 17 00:00:00 2001 From: Mikolaj Adamowicz Date: Fri, 16 Oct 2026 13:02:22 +0000 Subject: [PATCH 3/5] fix(apps): uninstall through the Android device readiness returns A stopped AVD is inventoried by its AVD name; resolve and remove the package through the live device ensureAndroidReady returns, and keep the handler harness's uninstall fact to the kinds the platform owners admit. --- .../handlers/session-command-harness.ts | 6 ++++- .../src/deployment/runtime.test.ts | 22 +++++++++++++++++++ .../src/deployment/runtime.ts | 11 +++++----- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/daemon/src/__tests__/support/handlers/session-command-harness.ts b/packages/daemon/src/__tests__/support/handlers/session-command-harness.ts index ec833d4482..d15bd2f6fb 100644 --- a/packages/daemon/src/__tests__/support/handlers/session-command-harness.ts +++ b/packages/daemon/src/__tests__/support/handlers/session-command-harness.ts @@ -280,7 +280,11 @@ function deploymentAvailability(device: DeviceInfo) { source: deploy && (device.platform === 'apple' || device.platform === 'android'), push: device.platform === 'android' || (device.platform === 'apple' && device.kind === 'simulator'), - uninstall: deploy && (device.platform === 'apple' || device.platform === 'android'), + uninstall: + deploy && + ((device.platform === 'apple' && (device.kind === 'simulator' || device.kind === 'device')) || + (device.platform === 'android' && + (device.kind === 'emulator' || device.kind === 'device'))), }; } diff --git a/packages/platform-android/src/deployment/runtime.test.ts b/packages/platform-android/src/deployment/runtime.test.ts index f64df6103e..98346a566b 100644 --- a/packages/platform-android/src/deployment/runtime.test.ts +++ b/packages/platform-android/src/deployment/runtime.test.ts @@ -230,3 +230,25 @@ test('uninstall refuses a name that matches no installed package', async () => { async function notInstalled(): Promise { throw new AppError('APP_NOT_INSTALLED', 'No package found'); } + +test('uninstall resolves and removes through the device readiness returns', async () => { + const live = { ...device, id: 'emulator-5556', booted: true }; + ensureAndroidReady.mockResolvedValueOnce(live); + const resolveAppPackage = vi.fn(async (): Promise => 'com.example.demo'); + const host = hostFixture({ resolveAppPackage }); + const signal = new AbortController().signal; + + await createAndroidAppDeploymentOperations({ + host, + device: { ...device, id: 'Pixel_9_API_35', booted: false }, + signal, + }).uninstallApp?.({ app: 'com.example.demo' }); + + expect(resolveAppPackage).toHaveBeenCalledWith(live, 'com.example.demo', { installedOnly: true }); + expect(native.uninstallAndroidPackage).toHaveBeenCalledWith( + host, + live, + 'com.example.demo', + signal, + ); +}); diff --git a/packages/platform-android/src/deployment/runtime.ts b/packages/platform-android/src/deployment/runtime.ts index 250874d240..bbfe538bb1 100644 --- a/packages/platform-android/src/deployment/runtime.ts +++ b/packages/platform-android/src/deployment/runtime.ts @@ -70,12 +70,13 @@ async function uninstallAndroidApp( signal: AbortSignal, ): Promise { return await host.androidDeployment.withInvalidatedAppResolutionCache(device, async () => { - if (device.booted !== true) { - await ensureAndroidReady(host, device, { headless: false }, signal); - } + const ready = + device.booted === true + ? device + : await ensureAndroidReady(host, device, { headless: false }, signal); let packageName: string; try { - packageName = await host.androidDeployment.resolveAppPackage(device, input.app, { + packageName = await host.androidDeployment.resolveAppPackage(ready, input.app, { installedOnly: true, }); } catch (error) { @@ -85,7 +86,7 @@ async function uninstallAndroidApp( } throw error; } - await uninstallAndroidPackage(host, device, packageName, signal); + await uninstallAndroidPackage(host, ready, packageName, signal); return { wasInstalled: true, packageName }; }); } From 6cdc305fd2c9c645c0cf862c074cca5ab0166c46 Mon Sep 17 00:00:00 2001 From: Mikolaj Adamowicz Date: Fri, 16 Oct 2026 16:34:40 +0200 Subject: [PATCH 4/5] fix(apps): invalidate Android app resolution under the live device A cold AVD boots under a different adb serial, so the cache invalidation now wraps the device that readiness returns. The uninstall handler test covers iOS simulator and CoreDevice removals, checks the runtime input, and asserts the typed refusal reason. Docs list uninstall among the commands macOS does not support, and the changelog names client.apps.uninstall. --- CHANGELOG.md | 2 +- .../__tests__/session-uninstall.test.ts | 69 +++++++++++++++---- .../src/deployment/runtime.test.ts | 12 +++- .../src/deployment/runtime.ts | 10 +-- website/docs/docs/commands.md | 2 +- 5 files changed, 73 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 679a8b79a6..8612bf1c6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Add `uninstall ` to remove an installed app on Android, iOS simulators, and CoreDevice iOS devices. A package name or bundle id that is not installed succeeds with `wasInstalled: false`. +- Add `uninstall ` and `client.apps.uninstall({ app })` to remove an installed app on Android, iOS simulators, and CoreDevice iOS devices. Both report `wasInstalled`. A package name or bundle id that is not installed succeeds with `wasInstalled: false`. - 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 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`. - `settings` refuses, before touching the device, a state its setting does not accept (`Invalid state: …`), a permission action or mode its target does not take (such as `permission deny accessibility`), and a stray extra argument (such as `clear-app-state `). The CLI and the daemon accept the same state spellings, in any casing or surrounding whitespace. diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-uninstall.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-uninstall.test.ts index 9dff1777cb..e8c512e0f7 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-uninstall.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-uninstall.test.ts @@ -2,7 +2,7 @@ import { daemonPlatformServicesFixture } from '../../../__tests__/support/platfo import { expect, test } from 'vitest'; import type { SessionState } from '../../../core/session-state.ts'; import { makeSessionStore } from '../../../__tests__/support/store-factory.ts'; -import { ANDROID_EMULATOR } from '@agent-device/test-kit'; +import { ANDROID_EMULATOR, IOS_DEVICE, IOS_SIMULATOR } from '@agent-device/test-kit'; import { handleSessionCommands, mockBindDeviceRuntime, @@ -15,7 +15,12 @@ async function uninstall(device: SessionState['device'], app: string) { sessionStore.publish('default', { name: 'default', createdAt: Date.now(), actions: [], device }); return await handleSessionCommands({ createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), + platformServices: daemonPlatformServicesFixture({ + appleSessionObservation: { + observeRunnerSession: async (deviceId) => ({ alive: true, sessionId: `${deviceId}:1` }), + resolveSoleForegroundApp: async () => undefined, + }, + }), req: { token: 't', session: 'default', command: 'uninstall', positionals: [app], flags: {} }, sessionName: 'default', logPath: '/tmp/daemon.log', @@ -28,19 +33,57 @@ async function uninstall(device: SessionState['device'], app: string) { test.each([ [ + 'an Android removal', + ANDROID_EMULATOR, { wasInstalled: true, packageName: 'com.example.app' }, - { wasInstalled: true, appId: 'com.example.app', message: 'Uninstalled: com.example.app' }, + { + platform: 'android', + wasInstalled: true, + appId: 'com.example.app', + message: 'Uninstalled: com.example.app', + }, + ], + [ + 'an Android no-op', + ANDROID_EMULATOR, + { wasInstalled: false }, + { platform: 'android', wasInstalled: false, message: 'Not installed: com.example.app' }, + ], + [ + 'an iOS simulator removal', + IOS_SIMULATOR, + { wasInstalled: true, bundleId: 'com.example.app' }, + { + platform: 'ios', + wasInstalled: true, + appId: 'com.example.app', + message: 'Uninstalled: com.example.app', + }, ], - [{ wasInstalled: false }, { wasInstalled: false, message: 'Not installed: com.example.app' }], -] as const)('uninstall reports %j through one admitted binding', async (removed, expected) => { - mockUninstallAppRuntime.mockResolvedValueOnce(removed); + [ + 'a CoreDevice removal', + IOS_DEVICE, + { wasInstalled: true, bundleId: 'com.example.app' }, + { + platform: 'ios', + wasInstalled: true, + appId: 'com.example.app', + message: 'Uninstalled: com.example.app', + }, + ], +] as const)( + 'uninstall reports %s through one admitted binding', + async (_name, device, removed, expected) => { + mockUninstallAppRuntime.mockResolvedValueOnce(removed); - await expect(uninstall(ANDROID_EMULATOR, 'com.example.app')).resolves.toEqual({ - ok: true, - data: { platform: 'android', app: 'com.example.app', ...expected }, - }); - expect(mockBindDeviceRuntime.mock.calls[0]?.[1]).toMatchObject({ required: ['uninstallApp'] }); -}); + await expect(uninstall(device, 'com.example.app')).resolves.toEqual({ + ok: true, + data: { app: 'com.example.app', ...expected }, + }); + expect(mockUninstallAppRuntime).toHaveBeenCalledWith({ app: 'com.example.app' }); + expect(mockBindDeviceRuntime.mock.calls[0]?.[1]).toMatchObject({ required: ['uninstallApp'] }); + }, +); test('uninstall is refused before binding where the runtime fact closes it', async () => { const harmony = { @@ -53,7 +96,7 @@ test('uninstall is refused before binding where the runtime fact closes it', asy await expect(uninstall(harmony, 'com.example.app')).resolves.toMatchObject({ ok: false, - error: { code: 'UNSUPPORTED_OPERATION' }, + error: { code: 'UNSUPPORTED_OPERATION', details: { reason: 'owner-capability-missing' } }, }); expect(mockBindDeviceRuntime).not.toHaveBeenCalled(); expect(mockUninstallAppRuntime).not.toHaveBeenCalled(); diff --git a/packages/platform-android/src/deployment/runtime.test.ts b/packages/platform-android/src/deployment/runtime.test.ts index 98346a566b..7a01d362a1 100644 --- a/packages/platform-android/src/deployment/runtime.test.ts +++ b/packages/platform-android/src/deployment/runtime.test.ts @@ -231,11 +231,18 @@ async function notInstalled(): Promise { throw new AppError('APP_NOT_INSTALLED', 'No package found'); } -test('uninstall resolves and removes through the device readiness returns', async () => { +test('uninstall resolves, removes and invalidates on the device readiness returns', async () => { const live = { ...device, id: 'emulator-5556', booted: true }; ensureAndroidReady.mockResolvedValueOnce(live); const resolveAppPackage = vi.fn(async (): Promise => 'com.example.demo'); - const host = hostFixture({ resolveAppPackage }); + const cacheDevices: DeviceInfo[] = []; + const host = hostFixture({ + resolveAppPackage, + withInvalidatedAppResolutionCache: async (cacheDevice, operation) => { + cacheDevices.push(cacheDevice); + return await operation(); + }, + }); const signal = new AbortController().signal; await createAndroidAppDeploymentOperations({ @@ -244,6 +251,7 @@ test('uninstall resolves and removes through the device readiness returns', asyn signal, }).uninstallApp?.({ app: 'com.example.demo' }); + expect(cacheDevices).toEqual([live]); expect(resolveAppPackage).toHaveBeenCalledWith(live, 'com.example.demo', { installedOnly: true }); expect(native.uninstallAndroidPackage).toHaveBeenCalledWith( host, diff --git a/packages/platform-android/src/deployment/runtime.ts b/packages/platform-android/src/deployment/runtime.ts index bbfe538bb1..877dccc1f4 100644 --- a/packages/platform-android/src/deployment/runtime.ts +++ b/packages/platform-android/src/deployment/runtime.ts @@ -69,11 +69,11 @@ async function uninstallAndroidApp( input: AppUninstallInput, signal: AbortSignal, ): Promise { - return await host.androidDeployment.withInvalidatedAppResolutionCache(device, async () => { - const ready = - device.booted === true - ? device - : await ensureAndroidReady(host, device, { headless: false }, signal); + const ready = + device.booted === true + ? device + : await ensureAndroidReady(host, device, { headless: false }, signal); + return await host.androidDeployment.withInvalidatedAppResolutionCache(ready, async () => { let packageName: string; try { packageName = await host.androidDeployment.resolveAppPackage(ready, input.app, { diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index fdaeeb1129..4658e4b5d8 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -304,7 +304,7 @@ agent-device snapshot -i --platform apple --target desktop - Prefer selectors and `@ref`s; window positions shift between runs, so raw x/y is less stable. Use `click --button secondary` for context menus, then run `snapshot -i` again. - On `frontmost-app` and `menubar`, `press` and `click` post synthetic mouse events (`desktop` is inspect-only). `--hold-ms` keeps the button down (at least 40 ms, default 60 ms), `--count` sends independent clicks, and `--double-tap` sends double-click pairs. Apps like Finder read two clicks at the default interval as one double-click; pass an `--interval-ms` longer than the system double-click time to keep them apart. Long schedules get the time they need, and a cancelled or interrupted click releases the button. `--jitter-px` is ignored on these surfaces. - macOS also supports `clipboard read|write`, `trigger-app-event`, `logs`, `network dump`, `audio probe`, `alert`, and part of `settings` (see [Settings helpers](#settings-helpers)). `audio probe` samples host system audio and needs Screen Recording permission; see [Debugging & Profiling](/docs/debugging-profiling#audio-probes). -- Mobile-only commands are unsupported on macOS: `boot`, `shutdown`, `home`, `orientation`, `app-switcher`, `action-button`, `fold`, `install`, `reinstall`, `install-from-source`, and `push`. +- Mobile-only commands are unsupported on macOS: `boot`, `shutdown`, `home`, `orientation`, `app-switcher`, `action-button`, `fold`, `install`, `reinstall`, `uninstall`, `install-from-source`, and `push`. Set `AGENT_DEVICE_MACOS_APP_BACKEND=native` to run macOS app sessions without XCTest. There is no Automation Mode overlay, the app can stay behind other windows, and your pointer stays yours while a drawn pointer shows each action. From 5d47c63e426600ae96077af62fadb6d70819dea9 Mon Sep 17 00:00:00 2001 From: Mikolaj Adamowicz Date: Fri, 16 Oct 2026 19:34:50 +0200 Subject: [PATCH 5/5] docs(changelog): scope uninstall to adb-backed Android targets Appium and Limrun Android sessions refuse uninstall, so the entry now names the backends it covers, the way the mobile-data entry does. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ee47ac05f..89d6a13133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Add `uninstall ` and `client.apps.uninstall({ app })` to remove an installed app on Android, iOS simulators, and CoreDevice iOS devices. Both report `wasInstalled`. A package name or bundle id that is not installed succeeds with `wasInstalled: false`. +- Add `uninstall ` 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. - 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 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`.