feat: support standalone Maestro clearState command (#2366)

* feat: support standalone Maestro clearState command

Accept '- clearState' / '- clearState: <appId>' in Maestro YAML flows.
Unlike launchApp.clearState (clear-then-open), the standalone form clears
app state without relaunching, projecting to 'settings clear-app-state'
on the daemon. Covers the Rocket.Chat login-with-deeplink helper, which
previously failed with 'Maestro command "clearState" is not supported'.

* test(maestro): cover standalone clearState with authored corpus flow

Replace the UNVERIFIED_COMMANDS exemption with an authored
clear-state flow exercising default and explicit appIds, plus the
regenerated upstream parser fixture proving Maestro compatibility.

Live iOS Simulator evidence (iPhone 16, com.apple.mobilesafari):
- marker files in the data container, then replay '- clearState'
  (default) and '- clearState: <appId>' (explicit) via
  'replay --maestro'; both replay 1/1, wipe the container, and leave
  MobileSafari not running (no reopen).
This commit is contained in:
Rohit Bansal
2026-09-08 16:12:44 +05:30
committed by GitHub
parent 1f9d940bff
commit d11c8cf9d6
19 changed files with 249 additions and 20 deletions
@@ -475,6 +475,27 @@ describe('parseMaestroProgram', () => {
);
});
test('parses standalone clearState with an explicit or config app id', () => {
const program = parseMaestroProgram(
`appId: example.app
---
- clearState: example.app
- clearState
`,
{ sourcePath: '/flows/clear.yaml' },
);
assert.deepEqual(program.commands[0], {
kind: 'clearState',
source: { path: '/flows/clear.yaml', line: 3 },
appId: 'example.app',
});
assert.deepEqual(program.commands[1], {
kind: 'clearState',
source: { path: '/flows/clear.yaml', line: 4 },
});
});
test('preserves source paths for unsupported and malformed flows', () => {
const sourcePath = '/flows/includes/child.yaml';
assert.throws(
@@ -59,6 +59,7 @@ export function makeOperations(
resolveGestureViewport: async () => ({ x: 0, y: 0, width: 402, height: 874 }),
launchApp: noOp,
stopApp: noOp,
clearState: noOp,
openLink: noOp,
tapOn: noOp,
doubleTapOn: noOp,
@@ -122,6 +122,28 @@ describe('MaestroRuntimePort', () => {
});
});
test('dispatches standalone clearState with an explicit or config app id', async () => {
const calls: RecordedCall[] = [];
const operations = makeOperations({
clearState: vi.fn(async (input, context) => record(calls, 'clearState', input, context)),
});
const program = parseMaestroProgram(
[
'appId: com.example.checkout',
'---',
'- clearState: com.example.checkout',
'- clearState',
].join('\n'),
);
const result = await executeMaestroProgram(program, createMaestroRuntimePort(operations));
expect(result).toMatchObject({ executed: 2, skipped: 0 });
expect(calls.map(({ kind }) => kind)).toEqual(['clearState', 'clearState']);
expect(calls[0]).toMatchObject({ input: { appId: 'com.example.checkout' } });
expect(calls[1]).toMatchObject({ input: { appId: 'com.example.checkout' } });
});
test('preserves observation validity after visual waits and scripts', async () => {
const waitInvalidation = vi.fn();
const scriptInvalidation = vi.fn();
@@ -77,6 +77,7 @@ export type CanonicalCommand =
| { kind: 'takeScreenshot' }
| { kind: 'waitForAnimationToEnd'; timeout?: number | string }
| { kind: 'stopApp' }
| { kind: 'clearState'; appId?: string }
| { kind: 'repeat'; times: string | number }
| { kind: 'retry'; maxRetries?: string | number }
| { kind: 'runFlow'; label?: string; source: 'file' | 'commands' }
@@ -92,6 +93,27 @@ const UPSTREAM_CONFIG_TYPES = new Set(['ApplyConfigurationCommand', 'DefineVaria
type UpstreamCommand = { type: string; fields: Record<string, unknown> };
function canonicalizeUpstreamLifecycleCommand(
command: UpstreamCommand,
): CanonicalCommand | undefined {
const f = command.fields;
switch (command.type) {
case 'LaunchAppCommand':
return dropUndefined({
kind: 'launchApp' as const,
appId: str(f.appId),
clearState: bool(f.clearState),
stopApp: bool(f.stopApp),
});
case 'StopAppCommand':
return { kind: 'stopApp' };
case 'ClearStateCommand':
return dropUndefined({ kind: 'clearState' as const, appId: str(f.appId) });
default:
return undefined;
}
}
export function canonicalizeUpstreamFlow(commands: UpstreamCommand[]): CanonicalCommand[] {
return commands
.filter((command) => !UPSTREAM_CONFIG_TYPES.has(command.type))
@@ -99,15 +121,10 @@ export function canonicalizeUpstreamFlow(commands: UpstreamCommand[]): Canonical
}
function canonicalizeUpstreamCommand(command: UpstreamCommand): CanonicalCommand {
const lifecycle = canonicalizeUpstreamLifecycleCommand(command);
if (lifecycle) return lifecycle;
const f = command.fields;
switch (command.type) {
case 'LaunchAppCommand':
return dropUndefined({
kind: 'launchApp',
appId: str(f.appId),
clearState: bool(f.clearState),
stopApp: bool(f.stopApp),
});
case 'TapOnElementCommand': {
const repeat = asRecord(f.repeat);
return canonicalTap({
@@ -196,8 +213,6 @@ function canonicalizeUpstreamCommand(command: UpstreamCommand): CanonicalCommand
kind: 'waitForAnimationToEnd',
timeout: numLike(f.timeout) ?? str(f.timeout),
});
case 'StopAppCommand':
return { kind: 'stopApp' };
case 'RepeatCommand':
return { kind: 'repeat', times: numLike(f.times) ?? str(f.times) ?? '' };
case 'RetryCommand':
@@ -302,8 +317,19 @@ export function canonicalizeAgentCommands(
return program.commands.map((command) => canonicalizeAgentCommand(command, program.config));
}
function canonicalizeAgentCommand(
command: MaestroCommand,
type AgentLifecycleCommand = Extract<
MaestroCommand,
{ kind: 'launchApp' | 'stopApp' | 'clearState' }
>;
function isAgentLifecycleCommand(command: MaestroCommand): command is AgentLifecycleCommand {
return (
command.kind === 'launchApp' || command.kind === 'stopApp' || command.kind === 'clearState'
);
}
function canonicalizeAgentLifecycleCommand(
command: AgentLifecycleCommand,
config: MaestroProgram['config'],
): CanonicalCommand {
switch (command.kind) {
@@ -314,6 +340,19 @@ function canonicalizeAgentCommand(
clearState: command.clearState,
stopApp: command.stopApp,
});
case 'stopApp':
return { kind: 'stopApp' };
case 'clearState':
return dropUndefined({ kind: 'clearState', appId: command.appId ?? config.appId });
}
}
function canonicalizeAgentCommand(
command: MaestroCommand,
config: MaestroProgram['config'],
): CanonicalCommand {
if (isAgentLifecycleCommand(command)) return canonicalizeAgentLifecycleCommand(command, config);
switch (command.kind) {
case 'tapOn': {
const repeat = numLike(command.repeat) ?? 1;
const repeatIsNumber = typeof repeat === 'number';
@@ -408,8 +447,6 @@ function canonicalizeAgentCommand(
return { kind: 'takeScreenshot' };
case 'waitForAnimationToEnd':
return dropUndefined({ kind: 'waitForAnimationToEnd', timeout: numLike(command.timeout) });
case 'stopApp':
return { kind: 'stopApp' };
case 'repeat':
return { kind: 'repeat', times: numLike(command.times) ?? str(command.times) ?? '' };
case 'retry':
@@ -3,6 +3,7 @@ import { stripUndefined } from './shared.ts';
import type {
MaestroAssertTrueCommand,
MaestroBackCommand,
MaestroClearStateCommand,
MaestroCommand,
MaestroEraseTextCommand,
MaestroExtendedWaitUntilCommand,
@@ -122,6 +123,7 @@ const COMMAND_VALUE_PARSERS: Readonly<Record<string, CommandValueParser>> = {
back: parseBack,
waitForAnimationToEnd: parseWaitForAnimationToEnd,
stopApp: parseStopApp,
clearState: parseClearState,
runScript: parseMaestroRunScriptCommand,
runFlow: (value, node, context) =>
parseMaestroRunFlowCommand(value, node, context, parseMaestroCommandList),
@@ -447,6 +449,16 @@ function parseStopApp(
return { kind: 'stopApp', source, appId: readRequiredString(value, 'stopApp', context) };
}
function parseClearState(
value: Node | null,
commandNode: Node,
context: MaestroProgramParseContext,
): MaestroClearStateCommand {
const source = sourceAt(commandNode, context);
if (isNullNode(value)) return { kind: 'clearState', source };
return { kind: 'clearState', source, appId: readRequiredString(value, 'clearState', context) };
}
function parseLaunchArguments(
node: Node | null | undefined,
name: string,
@@ -207,6 +207,12 @@ export type MaestroStopAppCommand = {
appId?: string;
};
export type MaestroClearStateCommand = {
kind: 'clearState';
source: MaestroSourceLocation;
appId?: string;
};
export type MaestroRunScriptCommand = {
kind: 'runScript';
source: MaestroSourceLocation;
@@ -265,6 +271,7 @@ export type MaestroCommand =
| MaestroBackCommand
| MaestroWaitForAnimationToEndCommand
| MaestroStopAppCommand
| MaestroClearStateCommand
| MaestroRunScriptCommand
| MaestroRunFlowCommand
| MaestroRepeatCommand
@@ -29,7 +29,9 @@ type MaestroCommandOf<K extends MaestroRuntimeCommand['kind']> = Extract<
{ kind: K }
>;
type MaestroLifecycleCommand = MaestroCommandOf<'launchApp' | 'stopApp' | 'openLink'>;
type MaestroLifecycleCommand = MaestroCommandOf<
'launchApp' | 'stopApp' | 'clearState' | 'openLink'
>;
type MaestroTargetCommand = MaestroCommandOf<'tapOn' | 'doubleTapOn' | 'longPressOn'>;
type MaestroTextCommand = MaestroCommandOf<'inputText' | 'eraseText'>;
type MaestroNavigationCommand = MaestroCommandOf<
@@ -53,6 +55,7 @@ type MaestroRuntimeCommandHandlers = {
const MAESTRO_RUNTIME_COMMAND_HANDLERS = {
launchApp: executeLifecycleCommand,
stopApp: executeLifecycleCommand,
clearState: executeLifecycleCommand,
openLink: executeLifecycleCommand,
tapOn: executeTargetCommand,
doubleTapOn: executeTargetCommand,
@@ -77,6 +80,7 @@ const MAESTRO_RUNTIME_COMMAND_HANDLERS = {
const MAESTRO_COMMAND_REQUIRES_SETTLED_PREDECESSOR = {
launchApp: true,
stopApp: true,
clearState: true,
openLink: true,
tapOn: true,
doubleTapOn: true,
@@ -142,6 +146,13 @@ async function executeLifecycleCommand(
context,
'invalidate',
);
case 'clearState':
return await invokeOperation(
operations.clearState,
{ appId: command.appId ?? request.appId },
context,
'invalidate',
);
case 'openLink':
return await invokeOperation(
operations.openLink,
@@ -123,6 +123,7 @@ export type MaestroRuntimeOperations = {
readonly launchArguments?: MaestroLaunchArguments;
}>;
readonly stopApp: MaestroRuntimeOperation<{ readonly appId?: string }>;
readonly clearState: MaestroRuntimeOperation<{ readonly appId?: string }>;
readonly openLink: MaestroRuntimeOperation<{ readonly link: string }>;
readonly tapOn: MaestroRuntimeOperation<{
@@ -1,12 +1,12 @@
export const MAESTRO_COMPAT_SUPPORTED_CAPABILITIES = [
'Flows: launchApp; runFlow file/inline with platform, visibility, and limited boolean conditions; onFlowStart/onFlowComplete; repeat.times and retry.',
'Interactions: tapOn, doubleTapOn, longPressOn, inputText on the focused element, eraseText, openLink, hideKeyboard, basic pressKey, and back; selector targets poll until available and support recursive index, childOf, above, below, leftOf, rightOf, containsChild, containsDescendants, points, and optional; outer command labels are metadata, not target selectors.',
'Assertions and navigation: assertVisible, assertNotVisible, assertTrue (literal values and ${VAR} lookups only; "", "false", "0", "null", and "undefined" are falsy, everything else is truthy), extendedWaitUntil, scroll, scrollUntilVisible, absolute/percentage/target swipe, takeScreenshot, waitForAnimationToEnd, and stopApp.',
'Assertions and navigation: assertVisible, assertNotVisible, assertTrue (literal values and ${VAR} lookups only; "", "false", "0", "null", and "undefined" are falsy, everything else is truthy), extendedWaitUntil, scroll, scrollUntilVisible, absolute/percentage/target swipe, takeScreenshot, waitForAnimationToEnd, clearState, and stopApp.',
'Scripts: ordered runScript file/env scripts with http.post, json, and output variables.',
] as const;
export const MAESTRO_COMPAT_LIMITATIONS = [
'Runtime: iOS and Android only; launchApp.clearState supports Android and iOS simulators, launch arguments are Apple-only, and standalone device utility/state commands are unsupported.',
'Runtime: iOS and Android only; launchApp.clearState and standalone clearState support Android and iOS simulators, launch arguments are Apple-only, and other standalone device utility/state commands are unsupported.',
'Expressions: when.true supports boolean literals and maestro.platform comparisons; assertTrue supports literal values and ${VAR} lookups only; repeat.while, evalScript, and broader JavaScript expressions are unsupported.',
'Environment: flow env is the default, AD_VAR_* overrides it, and CLI -e KEY=VALUE wins over both.',
'Failure diagnostics: resolved targets and runFlow paths are rendered, while inputText payloads remain hidden; do not place secrets in diagnostic identifiers.',
@@ -22,6 +22,8 @@ function validMaestroCommand(pick: number, salt: number): string[] {
() => ['- back'],
() => ['- hideKeyboard'],
() => ['- stopApp'],
() => ['- clearState'],
() => [`- clearState: ${text}`],
() => ['- scroll'],
() => ['- waitForAnimationToEnd'],
() => ['- eraseText'],
@@ -54,6 +54,8 @@ const NOTES = {
'Coverage: above, below, leftOf, and rightOf recursively across target, assertion, wait, scroll, and swipe commands.',
'authored/numeric-variable-wait':
'Coverage: waitForAnimationToEnd timeout accepts a ${VAR} token and projects identically through the canonical model.',
'authored/clear-state':
'Coverage: standalone clearState with default and explicit appId (no upstream flow exercises it).',
'invalid/bad-swipe-direction': 'Lenient-guard: unknown SwipeDirection enum value.',
'invalid/unknown-command': 'Lenient-guard: unknown command name (tapOn typo).',
'invalid/malformed-selector': 'Lenient-guard: selector given as a sequence.',
@@ -0,0 +1,4 @@
appId: com.example.app
---
- clearState
- clearState: another.app
@@ -464,6 +464,14 @@
"note": "Lenient-guard: unknown field inside a selector map."
}
},
{
"id": "authored/clear-state",
"file": "authored/clear-state.yaml",
"origin": {
"kind": "authored",
"note": "Coverage: standalone clearState with default and explicit appId (no upstream flow exercises it)."
}
},
{
"id": "authored/doubletap",
"file": "authored/doubletap.yaml",
@@ -3219,6 +3219,45 @@
"message": "Unknown Property: bogusField"
}
},
{
"id": "authored/clear-state",
"file": "authored/clear-state.yaml",
"status": "parsed",
"commands": [
{
"type": "ApplyConfigurationCommand",
"fields": {
"config": {
"appId": "com.example.app",
"name": null,
"tags": [],
"ext": {},
"onFlowStart": null,
"onFlowComplete": null,
"properties": {}
},
"label": null,
"optional": false
}
},
{
"type": "ClearStateCommand",
"fields": {
"appId": "com.example.app",
"label": null,
"optional": false
}
},
{
"type": "ClearStateCommand",
"fields": {
"appId": "another.app",
"label": null,
"optional": false
}
}
]
},
{
"id": "authored/doubletap",
"file": "authored/doubletap.yaml",
@@ -4976,5 +5015,5 @@
]
}
],
"contentHash": "857715bbddad493e6242decf7d8f83a0d7ca2b1d7a1add871fa6b5c544ca29cb"
"contentHash": "3fab6cb48d4ac7ec2640faa223b86a1dd58439d1e181ce442a76a5f754efc725"
}
@@ -129,6 +129,45 @@ test('delegates lifecycle and coordinate gestures through public daemon commands
]);
});
test('projects standalone clearState to settings without opening the app', async () => {
const requests: DaemonRequest[] = [];
const invoke: DaemonInvokeFn = async (request) => {
requests.push(request);
return { ok: true, data: {} };
};
const port = createDaemonMaestroRuntimePort({
baseReq: makeBaseRequest({ flags: { platform: 'android', replayBackend: 'maestro' } }),
invoke,
dependencies: makeDependencies(),
platform: 'android',
});
await port.execute({
command: { kind: 'clearState', source: { line: 2 }, appId: 'com.example.app' },
generation: 0,
env: {},
invalidateObservation() {},
});
await port.execute({
command: { kind: 'clearState', source: { line: 3 } },
generation: 1,
env: {},
appId: 'com.example.session',
invalidateObservation() {},
});
expect(requests).toEqual([
expect.objectContaining({
command: 'settings',
positionals: ['clear-app-state', 'com.example.app'],
}),
expect.objectContaining({
command: 'settings',
positionals: ['clear-app-state', 'com.example.session'],
}),
]);
});
test('uses the direct viewport without snapshot and pairs it with the nested gesture request', async () => {
const requests: DaemonRequest[] = [];
const viewport = { x: 10, y: 20, width: 400, height: 800 };
@@ -48,6 +48,14 @@ describe('Maestro public operation projection', () => {
operation: { kind: 'stopApp' },
expected: { command: 'close', positionals: [], internal: { closeAppOnly: true } },
},
{
operation: { kind: 'clearState', appId: 'com.example' },
expected: { command: 'settings', positionals: ['clear-app-state', 'com.example'] },
},
{
operation: { kind: 'clearState' },
expected: { command: 'settings', positionals: ['clear-app-state'] },
},
{
operation: {
kind: 'openLink',
@@ -132,6 +132,10 @@ function createDaemonMaestroRuntimeParts(options: CreateDaemonMaestroRuntimeOper
const appId = input.appId ?? context.appId;
await invokeMutation({ kind: 'stopApp', ...(appId ? { appId } : {}) }, context);
},
clearState: async (input, context) => {
const appId = input.appId ?? context.appId;
await invokeMutation({ kind: 'clearState', ...(appId ? { appId } : {}) }, context);
},
openLink: async (input, context) => {
await invokeMutation(
{
@@ -20,6 +20,7 @@ export type MaestroPublicOperation =
launchArgs: string[];
}
| { kind: 'stopApp'; appId?: string }
| { kind: 'clearState'; appId?: string }
| { kind: 'openLink'; appId?: string; link: string; prewarmRunner: boolean }
| { kind: 'typeText'; text: string }
| {
@@ -45,6 +46,7 @@ export type ProjectedMaestroPublicOperation = Pick<DaemonRequest, 'command' | 'p
export function projectMaestroPublicOperation(
operation: MaestroPublicOperation,
): ProjectedMaestroPublicOperation {
if (operation.kind === 'clearState') return projectClearState(operation);
if (isAppOperation(operation)) return projectAppOperation(operation);
if (isCaptureOperation(operation)) return projectCaptureOperation(operation);
return projectInputOperation(operation);
@@ -96,6 +98,15 @@ function projectStopApp(
};
}
function projectClearState(
operation: Extract<MaestroPublicOperation, { kind: 'clearState' }>,
): ProjectedMaestroPublicOperation {
return {
command: 'settings',
positionals: operation.appId ? ['clear-app-state', operation.appId] : ['clear-app-state'],
};
}
function projectOpenLink(
operation: Extract<MaestroAppOperation, { kind: 'openLink' }>,
): ProjectedMaestroPublicOperation {
@@ -108,7 +119,7 @@ function projectOpenLink(
type MaestroInputOperation = Exclude<
MaestroPublicOperation,
MaestroAppOperation | MaestroCaptureOperation
MaestroAppOperation | MaestroCaptureOperation | { kind: 'clearState' }
>;
function projectInputOperation(operation: MaestroInputOperation): ProjectedMaestroPublicOperation {
+2 -2
View File
@@ -72,12 +72,12 @@ Supported subset:
- Flows: `launchApp`; `runFlow` file/inline with platform, visibility, and limited boolean conditions; `onFlowStart`/`onFlowComplete`; `repeat.times` and retry.
- Interactions: `tapOn`, `doubleTapOn`, `longPressOn`, `inputText` on the focused element, `eraseText`, `openLink`, `hideKeyboard`, basic `pressKey`, and `back`; selector targets poll until available and support recursive `index`, `childOf`, `above`, `below`, `leftOf`, `rightOf`, `containsChild`, `containsDescendants`, points, and `optional`; outer command labels are metadata, not target selectors.
- Assertions and navigation: `assertVisible`, `assertNotVisible`, `assertTrue` (literal values and `${VAR}` lookups only; `""`, `"false"`, `"0"`, `"null"`, and `"undefined"` are falsy, everything else is truthy), `extendedWaitUntil`, `scroll`, `scrollUntilVisible`, absolute/percentage/target `swipe`, `takeScreenshot`, `waitForAnimationToEnd`, and `stopApp`.
- Assertions and navigation: `assertVisible`, `assertNotVisible`, `assertTrue` (literal values and `${VAR}` lookups only; `""`, `"false"`, `"0"`, `"null"`, and `"undefined"` are falsy, everything else is truthy), `extendedWaitUntil`, `scroll`, `scrollUntilVisible`, absolute/percentage/target `swipe`, `takeScreenshot`, `waitForAnimationToEnd`, `clearState`, and `stopApp`.
- Scripts: ordered `runScript` file/env scripts with `http.post`, `json`, and `output` variables.
Boundaries:
- Runtime: iOS and Android only; `launchApp.clearState` supports Android and iOS simulators, launch arguments are Apple-only, and standalone device utility/state commands are unsupported.
- Runtime: iOS and Android only; `launchApp.clearState` and standalone `clearState` support Android and iOS simulators, launch arguments are Apple-only, and other standalone device utility/state commands are unsupported.
- Expressions: `when.true` supports boolean literals and `maestro.platform` comparisons; `assertTrue` supports literal values and `${VAR}` lookups only; `repeat.while`, `evalScript`, and broader JavaScript expressions are unsupported.
- Environment: flow `env` is the default, `AD_VAR_*` overrides it, and CLI `-e KEY=VALUE` wins over both.
- Failure diagnostics: resolved targets and `runFlow` paths are rendered, while `inputText` payloads remain hidden; do not place secrets in diagnostic identifiers.