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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/maestro/src/internal/__tests__/program-ir-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions packages/maestro/src/internal/__tests__/runtime-port.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
63 changes: 50 additions & 13 deletions packages/maestro/src/internal/conformance-normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand All @@ -92,22 +93,38 @@ 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))
.map(canonicalizeUpstreamCommand);
}

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({
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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) {
Expand All @@ -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';
Expand Down Expand Up @@ -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':
Expand Down
12 changes: 12 additions & 0 deletions packages/maestro/src/internal/program-ir-command-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { stripUndefined } from './shared.ts';
import type {
MaestroAssertTrueCommand,
MaestroBackCommand,
MaestroClearStateCommand,
MaestroCommand,
MaestroEraseTextCommand,
MaestroExtendedWaitUntilCommand,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions packages/maestro/src/internal/program-ir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -265,6 +271,7 @@ export type MaestroCommand =
| MaestroBackCommand
| MaestroWaitForAnimationToEndCommand
| MaestroStopAppCommand
| MaestroClearStateCommand
| MaestroRunScriptCommand
| MaestroRunFlowCommand
| MaestroRepeatCommand
Expand Down
13 changes: 12 additions & 1 deletion packages/maestro/src/internal/runtime-port-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand All @@ -53,6 +55,7 @@ type MaestroRuntimeCommandHandlers = {
const MAESTRO_RUNTIME_COMMAND_HANDLERS = {
launchApp: executeLifecycleCommand,
stopApp: executeLifecycleCommand,
clearState: executeLifecycleCommand,
openLink: executeLifecycleCommand,
tapOn: executeTargetCommand,
doubleTapOn: executeTargetCommand,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/maestro/src/internal/runtime-port-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand Down
4 changes: 2 additions & 2 deletions packages/maestro/src/internal/support-matrix.ts
Original file line number Diff line number Diff line change
@@ -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.',
Expand Down
2 changes: 2 additions & 0 deletions scripts/fuzz/validation-arbitraries-maestro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ function validMaestroCommand(pick: number, salt: number): string[] {
() => ['- back'],
() => ['- hideKeyboard'],
() => ['- stopApp'],
() => ['- clearState'],
() => [`- clearState: ${text}`],
() => ['- scroll'],
() => ['- waitForAnimationToEnd'],
() => ['- eraseText'],
Expand Down
2 changes: 2 additions & 0 deletions scripts/maestro-conformance/build-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
4 changes: 4 additions & 0 deletions scripts/maestro-conformance/corpus/authored/clear-state.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
appId: com.example.app
---
- clearState
- clearState: another.app
8 changes: 8 additions & 0 deletions scripts/maestro-conformance/corpus/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
41 changes: 40 additions & 1 deletion scripts/maestro-conformance/fixtures/layer1-parser.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -4976,5 +5015,5 @@
]
}
],
"contentHash": "857715bbddad493e6242decf7d8f83a0d7ca2b1d7a1add871fa6b5c544ca29cb"
"contentHash": "3fab6cb48d4ac7ec2640faa223b86a1dd58439d1e181ce442a76a5f754efc725"
}
Loading
Loading