Skip to content
Merged
2 changes: 1 addition & 1 deletion src/__tests__/test-file-size-ratchet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const PINNED_TEST_FILE_LINES: Readonly<Record<string, number>> = Object.freeze({
'src/platforms/android/__tests__/snapshot.test.ts': 1660,
'src/platforms/apple/core/__tests__/runner-client.test.ts': 1615,
'src/__tests__/client.test.ts': 1598,
'test/integration/provider-scenarios/android-lifecycle.test.ts': 1597,
'test/integration/provider-scenarios/android-lifecycle.test.ts': 1559,
'src/utils/__tests__/daemon-client-lifecycle.test.ts': 1414,
'src/platforms/apple/core/__tests__/runner-command-retry.test.ts': 1327,
'src/__tests__/cli-client-commands.test.ts': 1317,
Expand Down
24 changes: 23 additions & 1 deletion src/commands/capture/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { alertCliReader, alertDaemonWriter } from './alert.ts';
import { diffCliReader } from './diff.ts';
import { snapshotCliOutput } from './output.ts';
import { screenshotCliReader, screenshotDaemonWriter } from './screenshot.ts';
import { settingsCliReader, settingsDaemonWriter } from './settings.ts';
import { settingsCliReader, settingsCommandFacet, settingsDaemonWriter } from './settings.ts';
import { snapshotCliReader } from './snapshot.ts';
import { waitCliReader, waitDaemonWriter } from './wait.ts';

Expand Down Expand Up @@ -187,4 +187,26 @@ describe('capture command interface', () => {
positionals: ['permission', 'grant', 'camera', 'limited'],
});
});

// #1796: the Android revoke warning rides `warnings`; the human CLI line must show it.
test('settings CLI output renders response warnings after the message', () => {
const warning = 'android.permission.CAMERA was granted before this revoke, and Android …';
const output = settingsCommandFacet.cliOutputFormatter!({
input: {},
result: { setting: 'permission', state: 'reset', message: 'Updated setting: permission' },
});
expect(output.text).toBe('Updated setting: permission');

const warned = settingsCommandFacet.cliOutputFormatter!({
input: {},
result: {
setting: 'permission',
state: 'reset',
message: 'Updated setting: permission',
warnings: [warning],
},
});
expect(warned.text).toBe(`Updated setting: permission\nWarning: ${warning}`);
expect(warned.data).toMatchObject({ warnings: [warning] });
});
});
5 changes: 4 additions & 1 deletion src/commands/capture/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts';
import { defineCommandFacet } from '../family/types.ts';
import { defineFieldCommandMetadata } from '../field-command-contract.ts';
import { messageWithWarningsOutput } from '../output-common.ts';

const SETTINGS_COMMAND_NAME = 'settings';
const settingsCommandDescription =
Expand Down Expand Up @@ -59,13 +60,15 @@ export const settingsCommandFacet = defineCommandFacet({
text: {
summary: 'Change OS settings and app permissions',
cliDetail:
'macOS supports only settings appearance <light|dark|toggle> and settings permission <grant|reset> <accessibility|screen-recording|input-monitoring>; wifi|airplane|location|animations remain unsupported on macOS. Mobile permission actions use the active session app.',
'macOS supports only settings appearance <light|dark|toggle> and settings permission <grant|reset> <accessibility|screen-recording|input-monitoring>; wifi|airplane|location|animations remain unsupported on macOS. Mobile permission actions use the active session app. On Android, deny|reset of a permission the app currently holds kills a running app; the response reports priorGrantState (granted|not_granted|unknown) and warns for granted and unknown, with open <app> --relaunch to restore it. Permission changes require a resolvable foreground user and fail without mutating if adb cannot report one.',
},
metadata: settingsCommandMetadata,
definition: settingsCommandDefinition,
cliSchema: settingsCliSchema,
cliReader: settingsCliReader,
daemonWriter: settingsDaemonWriter,
// Android permission revokes append a relaunch warning (#1796); render it for humans too.
cliOutputFormatter: messageWithWarningsOutput,
});

// fallow-ignore-next-line complexity
Expand Down
17 changes: 17 additions & 0 deletions src/commands/output-common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,23 @@ export function messageCliOutput(result: Record<string, unknown>): CliOutput {
return { data: result, text: readCommandMessage(result) };
}

/**
* `messageCliOutput` plus one `Warning:` line per entry of the response's `warnings`
* array — the composable warnings channel (`open`, `debug`, snapshot capture use it too),
* so a warning the daemon appended reaches the human CLI reader, not only `--json`.
*/
export const messageWithWarningsOutput = resultOutput(
(result: Record<string, unknown>): CliOutput => {
const output = messageCliOutput(result);
const warnings = Array.isArray(result.warnings)
? result.warnings.filter((warning): warning is string => typeof warning === 'string')
: [];
if (warnings.length === 0) return output;
const lines = [output.text, ...warnings.map((warning) => `Warning: ${warning}`)];
return { data: output.data, text: lines.filter(Boolean).join('\n') };
},
);

/**
* ADR 0014: a reusable ref in a PARTIAL result renders in ready-to-copy
* `@eN~s<refsGeneration>` form so a human CLI caller can paste it into the next
Expand Down
93 changes: 93 additions & 0 deletions src/platforms/android/__tests__/permission-grant-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { parseAndroidRuntimePermissionGrants } from '../permission-grant-state.ts';

// Captured from `adb shell dumpsys package com.callstack.agentdevicelab` on a Pixel 7 / API 36
// emulator, trimmed to the sections that decide the answer. The indentation is load-bearing:
// `install permissions:` and each `User <id>:` sit at the same depth, and the runtime grants
// hang under the user block — which is the only reason the two can be told apart.
const DUMPSYS = [
'Packages:',
' Package [com.example.app] (5f3a1c2):',
' install permissions:',
' android.permission.MODIFY_AUDIO_SETTINGS: granted=true',
' android.permission.RECORD_AUDIO: granted=true',
' User 0: ceDataInode=1032405 installed=true hidden=false stopped=false',
' gids=[3003]',
' runtime permissions:',
' android.permission.RECORD_AUDIO: granted=false, flags=[ USER_SENSITIVE_WHEN_GRANTED]',
' android.permission.CAMERA: granted=true, flags=[ USER_SET]',
' User 10: ceDataInode=99 installed=true hidden=false stopped=false',
' runtime permissions:',
' android.permission.RECORD_AUDIO: granted=true, flags=[ USER_SET]',
'',
'Queries:',
' queryable via interaction:',
' User 0:',
'',
'Dexopt state:',
' [com.example.app]',
' path: /data/app/~~abc==/com.example.app-def==',
].join('\n');

// `undefined` is the answer for anything the dump does not place inside the requested user's
// runtime block: an install-permission grant (`pm revoke` cannot touch it), another profile's
// grant, or a permission that user never declared. The caller reports that as `unknown`.
test.each([
[0, 'android.permission.RECORD_AUDIO', 'not_granted', 'acting user overrides the install grant'],
[0, 'android.permission.CAMERA', 'granted', 'acting user'],
[10, 'android.permission.RECORD_AUDIO', 'granted', 'other profile, only when asked for'],
[10, 'android.permission.CAMERA', undefined, 'absent for that user'],
[0, 'android.permission.MODIFY_AUDIO_SETTINGS', undefined, 'install-only'],
[0, 'android.permission.INTERNET', undefined, 'never mentioned'],
] as const)('user %s reads %s as %s (%s)', (userId, permission, expected, _why) => {
assert.equal(parseAndroidRuntimePermissionGrants(DUMPSYS, userId)?.get(permission), expected);
});

// A missing block is not an empty one. Every input here means "the device did not tell us",
// which must stay distinguishable from "the app holds nothing".
test.each([
['no runtime block for that user', DUMPSYS, 11],
['empty output', '', 0],
['no Packages section', 'Activity Resolver Table:\n User 0:\n runtime permissions:', 0],
['a package section without the user', 'Packages:\n Package [com.example.app] (abc):', 0],
] as const)('%s reads as unknown', (_label, output, userId) => {
assert.equal(parseAndroidRuntimePermissionGrants(output, userId), undefined);
});

test('an empty runtime block answers not_granted for everything it could have listed', () => {
// Distinct from a missing block: the device reported the list and it was empty, so the app
// holds nothing — a claim the parser is allowed to make.
const grants = parseAndroidRuntimePermissionGrants(
[
'Packages:',
' Package [com.example.app] (5f3a1c2):',
' User 0: installed=true',
' runtime permissions:',
' User 10: installed=true',
].join('\n'),
0,
);
assert.notEqual(grants, undefined);
assert.equal(grants?.size, 0);
});

test('sections after Packages: cannot reopen the scan', () => {
// `Queries:` repeats `User 0:` with no grants, and `Shared users:` repeats runtime grant lines
// for the shared uid. Merging either would flip RECORD_AUDIO to granted for a user whose own
// block says otherwise — the same wrong-answer class as reading the install section.
const grants = parseAndroidRuntimePermissionGrants(
[
DUMPSYS,
'Shared users:',
' SharedUser [android.uid.shared] (a1b2):',
' User 0: ceDataInode=0 installed=true',
' runtime permissions:',
' android.permission.RECORD_AUDIO: granted=true, flags=[ USER_SET]',
].join('\n'),
0,
);

assert.equal(grants?.get('android.permission.RECORD_AUDIO'), 'not_granted');
assert.equal(grants?.get('android.permission.CAMERA'), 'granted');
});
Loading
Loading