From c38dc7e8b7cce8e0b9fa43fee5bebab30b152e21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 30 Jul 2026 18:13:03 +0200 Subject: [PATCH 1/2] refactor: isolate maestro engine package --- CONTEXT.md | 2 +- docs/adr/0012-interactive-replay.md | 2 +- fallow-baselines/health.json | 2 +- package.json | 11 +- packages/contracts/src/session-action.ts | 2 +- packages/maestro/package.json | 18 + packages/maestro/src/index.ts | 195 +++++ .../__tests__/engine-context.test.ts | 0 .../internal}/__tests__/engine-flow.test.ts | 0 .../src/internal}/__tests__/engine.test.ts | 0 .../internal}/__tests__/export-points.test.ts | 0 .../__tests__/program-ir-parser.test.ts | 0 .../__tests__/program-loader.test.ts | 0 .../src/internal}/__tests__/progress.test.ts | 0 .../internal}/__tests__/replay-plan.test.ts | 0 .../__tests__/runtime-port-fixtures.ts | 0 .../__tests__/runtime-port-geometry.test.ts | 0 .../__tests__/runtime-port-gestures.test.ts | 0 .../internal}/__tests__/runtime-port.test.ts | 0 .../__tests__/runtime-target-fixtures.ts | 0 .../__tests__/runtime-target-policy.test.ts | 0 .../__tests__/runtime-target-ranking.test.ts | 0 .../__tests__/runtime-targets-typed.test.ts | 0 .../__tests__/selector-regex.test.ts | 0 .../src/internal}/compatibility-errors.ts | 0 .../src/internal}/compatibility-policy.ts | 0 .../src/internal/conformance-normalize.ts | 6 +- .../maestro/src/internal}/engine-context.ts | 0 .../src/internal}/engine-expression.ts | 0 .../maestro/src/internal}/engine-flow.ts | 2 +- .../maestro/src/internal}/engine-types.ts | 0 .../maestro/src/internal}/engine.ts | 0 .../maestro/src/internal}/export-flow.ts | 111 +-- .../maestro/src/internal}/export-points.ts | 0 .../maestro/src/internal}/export-types.ts | 0 .../maestro/src/internal}/export-yaml.ts | 0 .../maestro/src/internal/facade-execution.ts | 304 ++++++++ .../internal}/program-ir-command-parser.ts | 2 +- .../src/internal}/program-ir-flow-parser.ts | 2 +- .../internal}/program-ir-gesture-parser.ts | 2 +- .../src/internal}/program-ir-parser.ts | 2 +- .../src/internal}/program-ir-values.ts | 2 +- .../maestro/src/internal}/program-ir.ts | 0 .../maestro/src/internal}/program-loader.ts | 2 +- .../maestro/src/internal}/progress.ts | 0 .../src/internal}/replay-plan-compilation.ts | 2 +- .../src/internal}/replay-plan-digest.ts | 2 +- .../src/internal}/replay-plan-execution.ts | 0 .../src/internal}/replay-plan-resume.ts | 0 .../internal}/replay-plan-step-execution.ts | 2 +- .../src/internal}/replay-plan-steps.ts | 2 +- .../src/internal}/replay-plan-types.ts | 0 .../maestro/src/internal}/replay-plan.ts | 0 .../src/internal}/runtime-port-commands.ts | 3 +- .../src/internal}/runtime-port-context.ts | 2 +- .../src/internal}/runtime-port-geometry.ts | 51 +- .../src/internal}/runtime-port-observation.ts | 0 .../src/internal}/runtime-port-types.ts | 0 .../src/internal}/runtime-target-policy.ts | 15 +- .../src/internal}/runtime-target-ranking.ts | 4 +- .../maestro/src/internal}/runtime-targets.ts | 38 +- .../maestro/src/internal}/selector-regex.ts | 0 .../src/internal}/selector-vocabulary.ts | 0 packages/maestro/src/internal/shared.ts | 54 ++ .../maestro/src/internal/snapshot-policy.ts | 156 ++++ .../maestro/src/internal}/support-matrix.ts | 0 packages/maestro/tsconfig.json | 12 + pnpm-lock.yaml | 241 ++++--- scripts/fuzz/targets.ts | 4 +- scripts/layering/daemon-modularity.test.ts | 6 +- scripts/layering/daemon-modularity.ts | 9 +- scripts/layering/model.ts | 1 + scripts/layering/package-boundaries.test.ts | 11 + scripts/maestro-conformance/README.md | 5 +- .../differential/invariants.test.ts | 2 +- .../differential/scenarios.ts | 4 +- scripts/maestro-conformance/verify.test.ts | 6 +- scripts/maestro-conformance/verify.ts | 47 +- .../__tests__/replay-maestro-export.test.ts} | 16 +- src/cli/commands/replay.ts | 11 +- .../__tests__/maestro-support-matrix.test.ts} | 4 +- src/cli/parser/cli-help.ts | 2 +- src/compat/maestro/daemon-runtime-port.ts | 593 ---------------- .../__tests__/daemon-runtime-port-fixtures.ts | 2 +- .../daemon-runtime-port-mutations.test.ts | 4 +- ...mon-runtime-port-observation-input.test.ts | 2 +- ...-runtime-port-observation-recovery.test.ts | 2 +- ...on-runtime-port-observation-scroll.test.ts | 2 +- .../daemon-runtime-port-observation.test.ts | 17 +- ...aemon-runtime-port-snapshot-source.test.ts | 2 +- .../daemon-runtime-port-support.test.ts | 0 ...aemon-runtime-port-target-geometry.test.ts | 2 +- ...daemon-runtime-port-target-refresh.test.ts | 2 +- .../daemon-runtime-port-targets.test.ts | 4 +- .../__tests__/daemon-runtime-port.test.ts | 23 +- .../daemon-runtime-public-operation.test.ts | 0 .../maestro/__tests__/run-script.test.ts | 0 .../wait-for-animation-to-end.test.ts | 2 +- .../daemon-runtime-port-observation.ts | 69 +- .../daemon-runtime-port-snapshot-source.ts | 6 +- .../maestro/daemon-runtime-port-support.ts | 22 +- .../adapters/maestro/daemon-runtime-port.ts | 314 ++++++++ .../daemon-runtime-public-operation.ts | 8 +- .../adapters/maestro/daemon-runtime-tap.ts | 266 +++++++ .../maestro/maestro-screenshot-comparison.ts | 10 +- .../adapters}/maestro/run-script-execution.ts | 4 +- .../maestro/wait-for-animation-to-end.ts | 0 .../__tests__/session-device-claims.test.ts | 4 + .../session-replay-maestro-failure.test.ts | 106 +-- .../__tests__/session-test-discovery.test.ts | 26 +- .../__tests__/session-test-runner.test.ts | 8 +- .../__tests__/session-test-suite.test.ts | 30 +- .../session-replay-maestro-failure.ts | 209 +----- .../session-replay-maestro-observer.ts | 84 +-- .../session-replay-maestro-response.ts | 44 +- .../session-replay-maestro-runtime.ts | 90 +-- src/daemon/handlers/session-replay-runtime.ts | 36 +- src/daemon/handlers/session-test-discovery.ts | 182 +---- src/daemon/replay-device-selection.ts | 28 +- src/daemon/replay-source-discovery.ts | 126 ++++ src/replay/__tests__/format.test.ts | 16 + src/replay/format.ts | 17 + src/snapshot/snapshot-processing.ts | 13 - .../android-test-suite.test.ts | 669 +++++++++--------- 124 files changed, 2525 insertions(+), 1898 deletions(-) create mode 100644 packages/maestro/package.json create mode 100644 packages/maestro/src/index.ts rename {src/compat/maestro => packages/maestro/src/internal}/__tests__/engine-context.test.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/__tests__/engine-flow.test.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/__tests__/engine.test.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/__tests__/export-points.test.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/__tests__/program-ir-parser.test.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/__tests__/program-loader.test.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/__tests__/progress.test.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/__tests__/replay-plan.test.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/__tests__/runtime-port-fixtures.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/__tests__/runtime-port-geometry.test.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/__tests__/runtime-port-gestures.test.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/__tests__/runtime-port.test.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/__tests__/runtime-target-fixtures.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/__tests__/runtime-target-policy.test.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/__tests__/runtime-target-ranking.test.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/__tests__/runtime-targets-typed.test.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/__tests__/selector-regex.test.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/compatibility-errors.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/compatibility-policy.ts (100%) rename scripts/maestro-conformance/normalize.ts => packages/maestro/src/internal/conformance-normalize.ts (99%) rename {src/compat/maestro => packages/maestro/src/internal}/engine-context.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/engine-expression.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/engine-flow.ts (98%) rename {src/compat/maestro => packages/maestro/src/internal}/engine-types.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/engine.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/export-flow.ts (87%) rename {src/compat/maestro => packages/maestro/src/internal}/export-points.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/export-types.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/export-yaml.ts (100%) create mode 100644 packages/maestro/src/internal/facade-execution.ts rename {src/compat/maestro => packages/maestro/src/internal}/program-ir-command-parser.ts (99%) rename {src/compat/maestro => packages/maestro/src/internal}/program-ir-flow-parser.ts (99%) rename {src/compat/maestro => packages/maestro/src/internal}/program-ir-gesture-parser.ts (99%) rename {src/compat/maestro => packages/maestro/src/internal}/program-ir-parser.ts (98%) rename {src/compat/maestro => packages/maestro/src/internal}/program-ir-values.ts (99%) rename {src/compat/maestro => packages/maestro/src/internal}/program-ir.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/program-loader.ts (94%) rename {src/compat/maestro => packages/maestro/src/internal}/progress.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/replay-plan-compilation.ts (97%) rename {src/compat/maestro => packages/maestro/src/internal}/replay-plan-digest.ts (91%) rename {src/compat/maestro => packages/maestro/src/internal}/replay-plan-execution.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/replay-plan-resume.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/replay-plan-step-execution.ts (99%) rename {src/compat/maestro => packages/maestro/src/internal}/replay-plan-steps.ts (99%) rename {src/compat/maestro => packages/maestro/src/internal}/replay-plan-types.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/replay-plan.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/runtime-port-commands.ts (99%) rename {src/compat/maestro => packages/maestro/src/internal}/runtime-port-context.ts (94%) rename {src/compat/maestro => packages/maestro/src/internal}/runtime-port-geometry.ts (88%) rename {src/compat/maestro => packages/maestro/src/internal}/runtime-port-observation.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/runtime-port-types.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/runtime-target-policy.ts (85%) rename {src/compat/maestro => packages/maestro/src/internal}/runtime-target-ranking.ts (97%) rename {src/compat/maestro => packages/maestro/src/internal}/runtime-targets.ts (77%) rename {src/compat/maestro => packages/maestro/src/internal}/selector-regex.ts (100%) rename {src/compat/maestro => packages/maestro/src/internal}/selector-vocabulary.ts (100%) create mode 100644 packages/maestro/src/internal/shared.ts create mode 100644 packages/maestro/src/internal/snapshot-policy.ts rename {src/compat/maestro => packages/maestro/src/internal}/support-matrix.ts (100%) create mode 100644 packages/maestro/tsconfig.json rename src/{compat/maestro/__tests__/export-flow.test.ts => cli/commands/__tests__/replay-maestro-export.test.ts} (89%) rename src/{compat/maestro/__tests__/support-matrix.test.ts => cli/parser/__tests__/maestro-support-matrix.test.ts} (94%) delete mode 100644 src/compat/maestro/daemon-runtime-port.ts rename src/{compat => daemon/adapters}/maestro/__tests__/daemon-runtime-port-fixtures.ts (94%) rename src/{compat => daemon/adapters}/maestro/__tests__/daemon-runtime-port-mutations.test.ts (97%) rename src/{compat => daemon/adapters}/maestro/__tests__/daemon-runtime-port-observation-input.test.ts (98%) rename src/{compat => daemon/adapters}/maestro/__tests__/daemon-runtime-port-observation-recovery.test.ts (98%) rename src/{compat => daemon/adapters}/maestro/__tests__/daemon-runtime-port-observation-scroll.test.ts (99%) rename src/{compat => daemon/adapters}/maestro/__tests__/daemon-runtime-port-observation.test.ts (96%) rename src/{compat => daemon/adapters}/maestro/__tests__/daemon-runtime-port-snapshot-source.test.ts (98%) rename src/{compat => daemon/adapters}/maestro/__tests__/daemon-runtime-port-support.test.ts (100%) rename src/{compat => daemon/adapters}/maestro/__tests__/daemon-runtime-port-target-geometry.test.ts (99%) rename src/{compat => daemon/adapters}/maestro/__tests__/daemon-runtime-port-target-refresh.test.ts (98%) rename src/{compat => daemon/adapters}/maestro/__tests__/daemon-runtime-port-targets.test.ts (98%) rename src/{compat => daemon/adapters}/maestro/__tests__/daemon-runtime-port.test.ts (97%) rename src/{compat => daemon/adapters}/maestro/__tests__/daemon-runtime-public-operation.test.ts (100%) rename src/{compat => daemon/adapters}/maestro/__tests__/run-script.test.ts (100%) rename src/{compat => daemon/adapters}/maestro/__tests__/wait-for-animation-to-end.test.ts (98%) rename src/{compat => daemon/adapters}/maestro/daemon-runtime-port-observation.ts (89%) rename src/{compat => daemon/adapters}/maestro/daemon-runtime-port-snapshot-source.ts (96%) rename src/{compat => daemon/adapters}/maestro/daemon-runtime-port-support.ts (95%) create mode 100644 src/daemon/adapters/maestro/daemon-runtime-port.ts rename src/{compat => daemon/adapters}/maestro/daemon-runtime-public-operation.ts (97%) create mode 100644 src/daemon/adapters/maestro/daemon-runtime-tap.ts rename src/{compat => daemon/adapters}/maestro/maestro-screenshot-comparison.ts (92%) rename src/{compat => daemon/adapters}/maestro/run-script-execution.ts (98%) rename src/{compat => daemon/adapters}/maestro/wait-for-animation-to-end.ts (100%) create mode 100644 src/daemon/replay-source-discovery.ts create mode 100644 src/replay/__tests__/format.test.ts create mode 100644 src/replay/format.ts diff --git a/CONTEXT.md b/CONTEXT.md index e4e6ea5603..8aef407282 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -436,7 +436,7 @@ Evidence: [ADR 0002](docs/adr/0002-persistent-platform-helper-sessions.md), [`find.test.ts`](src/daemon/handlers/__tests__/find.test.ts), [`snapshot-handler.test.ts`](src/daemon/handlers/__tests__/snapshot-handler.test.ts), [`snapshot-scoped-refs.test.ts`](src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts), -[`runtime-targets-typed.test.ts`](src/compat/maestro/__tests__/runtime-targets-typed.test.ts), and +[`runtime-targets-typed.test.ts`](packages/maestro/src/internal/__tests__/runtime-targets-typed.test.ts), and [`android-test-suite.test.ts`](test/integration/provider-scenarios/android-test-suite.test.ts). ## Testing Principles diff --git a/docs/adr/0012-interactive-replay.md b/docs/adr/0012-interactive-replay.md index 3d6452e326..b5a5356d6e 100644 --- a/docs/adr/0012-interactive-replay.md +++ b/docs/adr/0012-interactive-replay.md @@ -1148,7 +1148,7 @@ via `open --save-script` (`src/daemon/session-action-recorder.ts`, `||` alternates (`buildSelectorChainForNode(...).join(' || ')`, `src/commands/interaction/runtime/resolution.ts:242`, mirrored in `src/daemon/handlers/session-replay-heal.ts:131-135`); Maestro YAML flows import through `--maestro` -(`src/compat/maestro/`); progress is step-indexed (`stepIndex`/`stepTotal` in +(`packages/maestro/src/internal/`); progress is step-indexed (`stepIndex`/`stepTotal` in `emitReplayTestActionProgress`, `session-replay-runtime.ts:243-260`). Recovery was opt-in `--update`/`-u` healing (`replayUpdate` flag, diff --git a/fallow-baselines/health.json b/fallow-baselines/health.json index 489d56421f..ac677ef8ec 100644 --- a/fallow-baselines/health.json +++ b/fallow-baselines/health.json @@ -616,7 +616,7 @@ "src/platforms/apple/core/debug-symbols/utils.ts:high impact", "src/platforms/linux/snapshot.ts:high impact", "src/core/interaction-targeting.ts:high impact", - "src/compat/maestro/runtime-targets.ts:high impact", + "packages/maestro/src/internal/runtime-targets.ts:high impact", "src/utils/source-value.ts:high impact", "src/request/cancel.ts:high impact", "src/commands/interaction/selectors.ts:untested risk", diff --git a/package.json b/package.json index 1bbf8c36e8..53ee9812ad 100644 --- a/package.json +++ b/package.json @@ -145,7 +145,7 @@ "check:unit": "pnpm check:contention-retry && pnpm test:unit && pnpm test:smoke", "check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit", "prepack": "pnpm check:mcp-metadata && pnpm package:npm", - "typecheck": "tsc -b packages/kernel packages/contracts && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json", + "typecheck": "tsc -b packages/kernel packages/contracts packages/maestro && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json", "test-app:install": "pnpm install --dir examples/test-app", "test-app:start": "pnpm --dir examples/test-app start", "test-app:ios": "pnpm --dir examples/test-app ios", @@ -158,7 +158,7 @@ "test-app:maestro:android": "pnpm test-app:maestro --platform android", "test": "pnpm test:unit", "test:unit": "vitest run --project unit-core --project subprocess-stub", - "test:maestro-compat": "vitest run --project unit-core src/compat/maestro src/compat/__tests__/replay-input.test.ts", + "test:maestro-compat": "vitest run --project unit-core packages/maestro src/daemon/adapters/maestro src/compat/__tests__/replay-input.test.ts", "test:coverage": "vitest run --coverage", "test:coverage:ci": "node --experimental-strip-types scripts/lib/contention-retry-run.ts --coverage", "check:contention-retry": "node --experimental-strip-types --test scripts/lib/contention-retry-policy.test.ts", @@ -241,12 +241,12 @@ ], "dependencies": { "@limrun/api": "^0.24.5", - "pngjs": "^7.0.0", - "yaml": "^2.9.0" + "pngjs": "^7.0.0" }, "devDependencies": { "@agent-device/contracts": "workspace:*", "@agent-device/kernel": "workspace:*", + "@agent-device/maestro": "workspace:*", "@chenglou/freerange": "^0.0.1", "@stryker-mutator/core": "9.6.1", "@stryker-mutator/vitest-runner": "9.6.1", @@ -261,6 +261,7 @@ "tsdown": "^0.22.4", "typescript": "^7.0.2", "vite": "^8.0.16", - "vitest": "^4.1.8" + "vitest": "^4.1.8", + "yaml": "^2.9.0" } } diff --git a/packages/contracts/src/session-action.ts b/packages/contracts/src/session-action.ts index 8b6df54179..53970640e1 100644 --- a/packages/contracts/src/session-action.ts +++ b/packages/contracts/src/session-action.ts @@ -4,7 +4,7 @@ import type { TargetAnnotationV1 } from './target-annotation.ts'; // One recorded action in a session's script. // -// `replay/` reads and writes these (6 modules) and `compat/maestro/` exports them, so declaring the +// `replay/` reads and writes these and the Maestro package exports them, so declaring the // shape inside `daemon/types.ts` made both zones depend on the daemon server to describe a file // format neither of them asks the daemon to produce. The daemon still owns the RECORDING — it is // the only thing that appends actions; this is only the shape they are appended in. diff --git a/packages/maestro/package.json b/packages/maestro/package.json new file mode 100644 index 0000000000..2c62de2cb0 --- /dev/null +++ b/packages/maestro/package.json @@ -0,0 +1,18 @@ +{ + "name": "@agent-device/maestro", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Private Maestro compatibility engine for agent-device.", + "dependencies": { + "@agent-device/contracts": "workspace:*", + "@agent-device/kernel": "workspace:*", + "yaml": "^2.9.0" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + } +} diff --git a/packages/maestro/src/index.ts b/packages/maestro/src/index.ts new file mode 100644 index 0000000000..58b711ef6f --- /dev/null +++ b/packages/maestro/src/index.ts @@ -0,0 +1,195 @@ +import { + canonicalizeUpstreamFlow, + canonicalizeAgentCommands, + type CanonicalCommand, +} from './internal/conformance-normalize.ts'; +import { maestroTestFailure } from './internal/compatibility-errors.ts'; +import { + MAESTRO_COMPATIBILITY_PRESETS, + MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS, +} from './internal/compatibility-policy.ts'; +import { + type MaestroObservation, + type MaestroObservationCondition, + type MaestroObservationIdentity, + type MaestroRuntimeMetrics, + type MaestroRuntimePort, + type MaestroRuntimeCommand, +} from './internal/engine-types.ts'; +import { + exportReplayActionsToMaestro, + type MaestroExportOptions, + type MaestroExportResult, + type MaestroExportWarning, +} from './internal/export-flow.ts'; +import { parseMaestroProgram } from './internal/program-ir-parser.ts'; +import { SUPPORTED_MAESTRO_COMMAND_NAMES } from './internal/program-ir-command-parser.ts'; +import type { MaestroPlatform, MaestroSelector } from './internal/program-ir.ts'; +import { + executeMaestroRuntimeCommand, + maestroCommandRequiresSettledPredecessor, +} from './internal/runtime-port-commands.ts'; +import { operationContext } from './internal/runtime-port-context.ts'; +import { + maestroObservationMatches, + observeMaestroCondition, +} from './internal/runtime-port-observation.ts'; +import type { + MaestroDispatchSelector, + MaestroRuntimeOperationContext, + MaestroRuntimeOperationResult, + MaestroRuntimeReadContext, + MaestroRuntimeOperations, + MaestroSinglePointerGestureInput, + MaestroTargetMatch, + MaestroTargetQuery, +} from './internal/runtime-port-types.ts'; +import { resolveMaestroScrollableGesture } from './internal/runtime-port-geometry.ts'; +import { literalFromMaestroRegex } from './internal/selector-regex.ts'; +import { resolveMaestroTargetFromSnapshot } from './internal/runtime-targets.ts'; +import type { + MaestroTargetQuery as MaestroSnapshotTargetQuery, + MaestroTargetResolution, +} from './internal/runtime-targets.ts'; +import { + formatMaestroCompatibilityReference, + MAESTRO_COMPAT_LIMITATIONS, + MAESTRO_COMPAT_SUPPORTED_CAPABILITIES, + MAESTRO_COMPATIBILITY_ADR_URL, + MAESTRO_COMPATIBILITY_ISSUE_URL, +} from './internal/support-matrix.ts'; + +export type MaestroRuntimePortLifecycle = { + beforeExecute?(input: { + context: MaestroRuntimeOperationContext; + requiresSettledPredecessor: boolean; + visualStabilityBarrier: boolean; + }): Promise; + afterExecute?(input: { + context: MaestroRuntimeOperationContext; + visualStabilityReached: boolean; + }): void; + bindObservation?( + observation: Awaited>, + ): Awaited>; + readMetrics?(): NonNullable>>; +}; + +export type { + CanonicalCommand as MaestroCanonicalCommand, + MaestroDispatchSelector, + MaestroExportOptions, + MaestroExportResult, + MaestroExportWarning, + MaestroObservation, + MaestroObservationCondition, + MaestroObservationIdentity, + MaestroPlatform, + MaestroRuntimeMetrics, + MaestroRuntimeCommand, + MaestroRuntimeOperationContext, + MaestroRuntimeOperationResult, + MaestroRuntimeOperations, + MaestroRuntimePort, + MaestroRuntimeReadContext, + MaestroSelector, + MaestroSinglePointerGestureInput, + MaestroSnapshotTargetQuery, + MaestroTargetMatch, + MaestroTargetQuery, + MaestroTargetResolution, +}; + +export { + executeMaestroFlow, + inspectMaestroFlow, + rankMaestroFailureCandidates, + type MaestroActionEvent, + type MaestroCompletedActionEvent, + type MaestroExecutionObserver, + type MaestroExecutionOptions, + type MaestroExecutionOutcome, + type MaestroFailedAction, + type MaestroFailureCandidate, + type MaestroFlow, +} from './internal/facade-execution.ts'; + +export { + exportReplayActionsToMaestro, + formatMaestroCompatibilityReference, + literalFromMaestroRegex, + maestroObservationMatches, + maestroTestFailure, + MAESTRO_COMPATIBILITY_PRESETS, + MAESTRO_COMPATIBILITY_ADR_URL, + MAESTRO_COMPATIBILITY_ISSUE_URL, + MAESTRO_COMPAT_LIMITATIONS, + MAESTRO_COMPAT_SUPPORTED_CAPABILITIES, + MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS, + resolveMaestroScrollableGesture, + resolveMaestroTargetFromSnapshot, + SUPPORTED_MAESTRO_COMMAND_NAMES, +}; + +export const MAESTRO_CONFORMANCE_CONSTANTS = { + retryMaxRetries: MAESTRO_COMPATIBILITY_PRESETS.control.retryMaxRetries, + animationWaitThreshold: + MAESTRO_COMPATIBILITY_PRESETS.command.waitForAnimationToEndDifferencePercent, + animationWaitTimeoutMs: MAESTRO_COMPATIBILITY_PRESETS.command.waitForAnimationToEndTimeoutMs, + maxEraseCharacters: MAESTRO_COMPATIBILITY_PRESETS.command.eraseTextMaxCharacters, + swipeDurationMs: MAESTRO_COMPATIBILITY_PRESETS.command.swipeDurationMs, +} as const; + +export function parseMaestroConformanceSource( + source: string, + sourcePath: string, +): { commands: CanonicalCommand[]; kinds: Set } { + const program = parseMaestroProgram(source, { sourcePath }); + const kinds = new Set(); + collectCommandKinds(program.commands, kinds); + return { commands: canonicalizeAgentCommands(program), kinds }; +} + +export { canonicalizeUpstreamFlow as canonicalizeUpstreamMaestroFlow }; + +export function createMaestroRuntimePort( + operations: MaestroRuntimeOperations, + lifecycle: MaestroRuntimePortLifecycle = {}, +): MaestroRuntimePort { + return { + execute: async (request) => { + const context = operationContext(request, request.command); + const visualStabilityBarrier = request.command.kind === 'waitForAnimationToEnd'; + await lifecycle.beforeExecute?.({ + context, + requiresSettledPredecessor: + maestroCommandRequiresSettledPredecessor(request.command) && !visualStabilityBarrier, + visualStabilityBarrier, + }); + const result = await executeMaestroRuntimeCommand(request, operations); + lifecycle.afterExecute?.({ + context, + visualStabilityReached: visualStabilityBarrier && result.visualStabilityReached === true, + }); + delete result.visualStabilityReached; + return result; + }, + observe: async (request) => { + const observation = await observeMaestroCondition(request, operations); + return lifecycle.bindObservation?.(observation) ?? observation; + }, + ...(lifecycle.readMetrics ? { readMetrics: lifecycle.readMetrics } : {}), + }; +} + +function collectCommandKinds( + commands: Array<{ kind: string; commands?: unknown }>, + into: Set, +): void { + for (const command of commands) { + into.add(command.kind); + if (Array.isArray(command.commands)) { + collectCommandKinds(command.commands as Array<{ kind: string; commands?: unknown }>, into); + } + } +} diff --git a/src/compat/maestro/__tests__/engine-context.test.ts b/packages/maestro/src/internal/__tests__/engine-context.test.ts similarity index 100% rename from src/compat/maestro/__tests__/engine-context.test.ts rename to packages/maestro/src/internal/__tests__/engine-context.test.ts diff --git a/src/compat/maestro/__tests__/engine-flow.test.ts b/packages/maestro/src/internal/__tests__/engine-flow.test.ts similarity index 100% rename from src/compat/maestro/__tests__/engine-flow.test.ts rename to packages/maestro/src/internal/__tests__/engine-flow.test.ts diff --git a/src/compat/maestro/__tests__/engine.test.ts b/packages/maestro/src/internal/__tests__/engine.test.ts similarity index 100% rename from src/compat/maestro/__tests__/engine.test.ts rename to packages/maestro/src/internal/__tests__/engine.test.ts diff --git a/src/compat/maestro/__tests__/export-points.test.ts b/packages/maestro/src/internal/__tests__/export-points.test.ts similarity index 100% rename from src/compat/maestro/__tests__/export-points.test.ts rename to packages/maestro/src/internal/__tests__/export-points.test.ts diff --git a/src/compat/maestro/__tests__/program-ir-parser.test.ts b/packages/maestro/src/internal/__tests__/program-ir-parser.test.ts similarity index 100% rename from src/compat/maestro/__tests__/program-ir-parser.test.ts rename to packages/maestro/src/internal/__tests__/program-ir-parser.test.ts diff --git a/src/compat/maestro/__tests__/program-loader.test.ts b/packages/maestro/src/internal/__tests__/program-loader.test.ts similarity index 100% rename from src/compat/maestro/__tests__/program-loader.test.ts rename to packages/maestro/src/internal/__tests__/program-loader.test.ts diff --git a/src/compat/maestro/__tests__/progress.test.ts b/packages/maestro/src/internal/__tests__/progress.test.ts similarity index 100% rename from src/compat/maestro/__tests__/progress.test.ts rename to packages/maestro/src/internal/__tests__/progress.test.ts diff --git a/src/compat/maestro/__tests__/replay-plan.test.ts b/packages/maestro/src/internal/__tests__/replay-plan.test.ts similarity index 100% rename from src/compat/maestro/__tests__/replay-plan.test.ts rename to packages/maestro/src/internal/__tests__/replay-plan.test.ts diff --git a/src/compat/maestro/__tests__/runtime-port-fixtures.ts b/packages/maestro/src/internal/__tests__/runtime-port-fixtures.ts similarity index 100% rename from src/compat/maestro/__tests__/runtime-port-fixtures.ts rename to packages/maestro/src/internal/__tests__/runtime-port-fixtures.ts diff --git a/src/compat/maestro/__tests__/runtime-port-geometry.test.ts b/packages/maestro/src/internal/__tests__/runtime-port-geometry.test.ts similarity index 100% rename from src/compat/maestro/__tests__/runtime-port-geometry.test.ts rename to packages/maestro/src/internal/__tests__/runtime-port-geometry.test.ts diff --git a/src/compat/maestro/__tests__/runtime-port-gestures.test.ts b/packages/maestro/src/internal/__tests__/runtime-port-gestures.test.ts similarity index 100% rename from src/compat/maestro/__tests__/runtime-port-gestures.test.ts rename to packages/maestro/src/internal/__tests__/runtime-port-gestures.test.ts diff --git a/src/compat/maestro/__tests__/runtime-port.test.ts b/packages/maestro/src/internal/__tests__/runtime-port.test.ts similarity index 100% rename from src/compat/maestro/__tests__/runtime-port.test.ts rename to packages/maestro/src/internal/__tests__/runtime-port.test.ts diff --git a/src/compat/maestro/__tests__/runtime-target-fixtures.ts b/packages/maestro/src/internal/__tests__/runtime-target-fixtures.ts similarity index 100% rename from src/compat/maestro/__tests__/runtime-target-fixtures.ts rename to packages/maestro/src/internal/__tests__/runtime-target-fixtures.ts diff --git a/src/compat/maestro/__tests__/runtime-target-policy.test.ts b/packages/maestro/src/internal/__tests__/runtime-target-policy.test.ts similarity index 100% rename from src/compat/maestro/__tests__/runtime-target-policy.test.ts rename to packages/maestro/src/internal/__tests__/runtime-target-policy.test.ts diff --git a/src/compat/maestro/__tests__/runtime-target-ranking.test.ts b/packages/maestro/src/internal/__tests__/runtime-target-ranking.test.ts similarity index 100% rename from src/compat/maestro/__tests__/runtime-target-ranking.test.ts rename to packages/maestro/src/internal/__tests__/runtime-target-ranking.test.ts diff --git a/src/compat/maestro/__tests__/runtime-targets-typed.test.ts b/packages/maestro/src/internal/__tests__/runtime-targets-typed.test.ts similarity index 100% rename from src/compat/maestro/__tests__/runtime-targets-typed.test.ts rename to packages/maestro/src/internal/__tests__/runtime-targets-typed.test.ts diff --git a/src/compat/maestro/__tests__/selector-regex.test.ts b/packages/maestro/src/internal/__tests__/selector-regex.test.ts similarity index 100% rename from src/compat/maestro/__tests__/selector-regex.test.ts rename to packages/maestro/src/internal/__tests__/selector-regex.test.ts diff --git a/src/compat/maestro/compatibility-errors.ts b/packages/maestro/src/internal/compatibility-errors.ts similarity index 100% rename from src/compat/maestro/compatibility-errors.ts rename to packages/maestro/src/internal/compatibility-errors.ts diff --git a/src/compat/maestro/compatibility-policy.ts b/packages/maestro/src/internal/compatibility-policy.ts similarity index 100% rename from src/compat/maestro/compatibility-policy.ts rename to packages/maestro/src/internal/compatibility-policy.ts diff --git a/scripts/maestro-conformance/normalize.ts b/packages/maestro/src/internal/conformance-normalize.ts similarity index 99% rename from scripts/maestro-conformance/normalize.ts rename to packages/maestro/src/internal/conformance-normalize.ts index 5d90aa5a74..7f1149523f 100644 --- a/scripts/maestro-conformance/normalize.ts +++ b/packages/maestro/src/internal/conformance-normalize.ts @@ -12,8 +12,8 @@ import type { MaestroProgram, MaestroSelector, MaestroSwipeGesture, -} from '../../src/compat/maestro/program-ir.ts'; -import { MAESTRO_COMPATIBILITY_PRESETS } from '../../src/compat/maestro/compatibility-policy.ts'; +} from './program-ir.ts'; +import { MAESTRO_COMPATIBILITY_PRESETS } from './compatibility-policy.ts'; export type CanonicalSelector = { text?: string; @@ -434,7 +434,7 @@ function canonicalizeAgentCommand( case 'stopApp': return { kind: 'stopApp' }; case 'repeat': - return { kind: 'repeat', times: numLike(command.times) ?? str(command.times) }; + return { kind: 'repeat', times: numLike(command.times) ?? str(command.times) ?? '' }; case 'retry': return dropUndefined({ kind: 'retry', diff --git a/src/compat/maestro/engine-context.ts b/packages/maestro/src/internal/engine-context.ts similarity index 100% rename from src/compat/maestro/engine-context.ts rename to packages/maestro/src/internal/engine-context.ts diff --git a/src/compat/maestro/engine-expression.ts b/packages/maestro/src/internal/engine-expression.ts similarity index 100% rename from src/compat/maestro/engine-expression.ts rename to packages/maestro/src/internal/engine-expression.ts diff --git a/src/compat/maestro/engine-flow.ts b/packages/maestro/src/internal/engine-flow.ts similarity index 98% rename from src/compat/maestro/engine-flow.ts rename to packages/maestro/src/internal/engine-flow.ts index 365a4e2f4f..5d31a0bebf 100644 --- a/src/compat/maestro/engine-flow.ts +++ b/packages/maestro/src/internal/engine-flow.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; -import { createRequestCanceledError } from '../../request/cancel.ts'; +import { createRequestCanceledError } from './shared.ts'; import { MAESTRO_NUMERIC_FIELD_CONSTRAINTS, numericDescription, diff --git a/src/compat/maestro/engine-types.ts b/packages/maestro/src/internal/engine-types.ts similarity index 100% rename from src/compat/maestro/engine-types.ts rename to packages/maestro/src/internal/engine-types.ts diff --git a/src/compat/maestro/engine.ts b/packages/maestro/src/internal/engine.ts similarity index 100% rename from src/compat/maestro/engine.ts rename to packages/maestro/src/internal/engine.ts diff --git a/src/compat/maestro/export-flow.ts b/packages/maestro/src/internal/export-flow.ts similarity index 87% rename from src/compat/maestro/export-flow.ts rename to packages/maestro/src/internal/export-flow.ts index 1759db16ae..5c857518c4 100644 --- a/src/compat/maestro/export-flow.ts +++ b/packages/maestro/src/internal/export-flow.ts @@ -1,13 +1,6 @@ import type { SessionAction } from '@agent-device/contracts/session'; import { GESTURE_FLING_DURATION_MS } from '@agent-device/contracts/interaction'; -import { parseSelectorChain, type Selector } from '../../selectors/index.ts'; -import type { SelectorTerm } from '../../selectors/parse.ts'; import { AppError } from '@agent-device/kernel/errors'; -import { - parseReplayScriptDetailed, - readReplayScriptMetadata, - type ReplayScriptMetadata, -} from '../../replay/script.ts'; import { formatMaestroPoint } from './export-points.ts'; import { DEFAULT_MAESTRO_COMPATIBILITY_TIMING_POLICY } from './compatibility-policy.ts'; import type { MaestroExportCommand, MaestroExportConfig } from './export-types.ts'; @@ -25,6 +18,25 @@ export type MaestroExportResult = { warnings: MaestroExportWarning[]; }; +export type MaestroExportSelectorTerm = { + key: string; + value: string | boolean; +}; + +export type MaestroExportSelector = { + terms: MaestroExportSelectorTerm[]; +}; + +export type MaestroExportSelectorChain = { + selectors: MaestroExportSelector[]; +}; + +export type MaestroExportOptions = { + actionLines?: number[]; + metadata?: { env?: Record }; + parseSelector(expression: string): MaestroExportSelectorChain; +}; + type ExportContext = { config: MaestroExportConfig; warnings: MaestroExportWarning[]; @@ -36,7 +48,10 @@ type ConvertedAction = | { kind: 'config'; appId: string; commands: MaestroExportCommand[]; warnings?: string[] } | { kind: 'unsupported'; message: string }; -type ActionConverter = (action: SessionAction) => ConvertedAction; +type ActionConverter = ( + action: SessionAction, + parseSelector: MaestroExportOptions['parseSelector'], +) => ConvertedAction; type SwipeGeometry = { start: string; end: string; @@ -48,20 +63,9 @@ const STATE_SELECTOR_KEYS = new Set(MAESTRO_STATE_SELECTOR_KEYS); const LONG_PRESS_DURATION_WARNING = 'long-press duration exports as Maestro longPressOn; Maestro uses its default long-press duration'; -export function exportReplayScriptToMaestro(script: string): MaestroExportResult { - const parsed = parseReplayScriptDetailed(script); - return exportReplayActionsToMaestro(parsed.actions, { - actionLines: parsed.actionLines, - metadata: readReplayScriptMetadata(script), - }); -} - -function exportReplayActionsToMaestro( +export function exportReplayActionsToMaestro( actions: SessionAction[], - options: { - actionLines?: number[]; - metadata?: ReplayScriptMetadata; - } = {}, + options: MaestroExportOptions, ): MaestroExportResult { const context: ExportContext = { config: buildInitialConfig(options.metadata), @@ -72,7 +76,7 @@ function exportReplayActionsToMaestro( for (const [index, action] of actions.entries()) { const line = options.actionLines?.[index] ?? index + 1; - const converted = convertAction(action); + const converted = convertAction(action, options.parseSelector); switch (converted.kind) { case 'commands': commands.push(...converted.commands); @@ -109,7 +113,7 @@ function exportReplayActionsToMaestro( }; } -function buildInitialConfig(metadata: ReplayScriptMetadata | undefined): MaestroExportConfig { +function buildInitialConfig(metadata: MaestroExportOptions['metadata']): MaestroExportConfig { return metadata?.env && Object.keys(metadata.env).length > 0 ? { env: metadata.env } : {}; } @@ -129,9 +133,12 @@ const ACTION_CONVERTERS: Record = { swipe: convertSwipeAction, }; -function convertAction(action: SessionAction): ConvertedAction { +function convertAction( + action: SessionAction, + parseSelector: MaestroExportOptions['parseSelector'], +): ConvertedAction { return ( - ACTION_CONVERTERS[action.command]?.(action) ?? { + ACTION_CONVERTERS[action.command]?.(action, parseSelector) ?? { kind: 'unsupported', message: `${action.command} has no Maestro equivalent`, } @@ -172,10 +179,13 @@ function buildLaunchAppOptions(action: SessionAction): Record | return Object.keys(options).length > 0 ? options : undefined; } -function convertClickAction(action: SessionAction): ConvertedAction { +function convertClickAction( + action: SessionAction, + parseSelector: MaestroExportOptions['parseSelector'], +): ConvertedAction { const [first, second] = action.positionals; if (!first) return { kind: 'unsupported', message: `${action.command} requires a target` }; - const tapTarget = readTapTarget(first, second); + const tapTarget = readTapTarget(first, second, parseSelector); if (!tapTarget) return { kind: 'unsupported', message: 'tap target is not Maestro-compatible' }; const tapOptions = readRepeatedTapOptions(action); @@ -202,10 +212,13 @@ function convertClickAction(action: SessionAction): ConvertedAction { return { kind: 'commands', commands: [withTapOptions(tapTarget, tapOptions.options)] }; } -function convertLongPressAction(action: SessionAction): ConvertedAction { +function convertLongPressAction( + action: SessionAction, + parseSelector: MaestroExportOptions['parseSelector'], +): ConvertedAction { const [first, second] = action.positionals; if (!first) return { kind: 'unsupported', message: 'longpress requires a target' }; - const target = readTapTarget(first, second); + const target = readTapTarget(first, second, parseSelector); if (!target) return { kind: 'unsupported', message: 'longpress target is not Maestro-compatible' }; return { @@ -243,12 +256,15 @@ function readIgnoredRepeatedTapOptionWarnings( return warnings; } -function convertFillAction(action: SessionAction): ConvertedAction { +function convertFillAction( + action: SessionAction, + parseSelector: MaestroExportOptions['parseSelector'], +): ConvertedAction { const [target, text] = action.positionals; if (!target || text === undefined) { return { kind: 'unsupported', message: 'fill requires a target and text' }; } - const tapTarget = readTapTarget(target); + const tapTarget = readTapTarget(target, undefined, parseSelector); if (!tapTarget) return { kind: 'unsupported', message: 'fill target is not Maestro-compatible' }; return { kind: 'commands', @@ -276,7 +292,10 @@ function convertKeyboardAction(action: SessionAction): ConvertedAction { return { kind: 'unsupported', message: `keyboard ${subcommand ?? ''}`.trim() }; } -function convertWaitAction(action: SessionAction): ConvertedAction { +function convertWaitAction( + action: SessionAction, + parseSelector: MaestroExportOptions['parseSelector'], +): ConvertedAction { const [first, second] = action.positionals; if (!first) return { kind: 'unsupported', message: 'wait requires a target or duration' }; if (isNumber(first)) { @@ -304,7 +323,7 @@ function convertWaitAction(action: SessionAction): ConvertedAction { ], }; } - const selector = selectorExpressionToMaestro(first); + const selector = selectorExpressionToMaestro(first, parseSelector); if (!selector) return { kind: 'unsupported', message: 'wait selector is not Maestro-compatible' }; return { kind: 'commands', @@ -391,16 +410,23 @@ function readUnsupportedSwipeFlag(action: SessionAction): string | undefined { return undefined; } -function readTapTarget(first: string, second?: string): unknown | null { +function readTapTarget( + first: string, + second: string | undefined, + parseSelector: MaestroExportOptions['parseSelector'], +): unknown | null { if (isNumber(first) && isNumber(second)) return { point: formatMaestroPoint(first, second) }; if (first.startsWith('@')) return null; - return selectorExpressionToMaestro(first); + return selectorExpressionToMaestro(first, parseSelector); } -function selectorExpressionToMaestro(expression: string): unknown | null { - let chain: ReturnType; +function selectorExpressionToMaestro( + expression: string, + parseSelector: MaestroExportOptions['parseSelector'], +): unknown | null { + let chain: MaestroExportSelectorChain; try { - chain = parseSelectorChain(expression); + chain = parseSelector(expression); } catch { return expression.includes('=') || expression.includes('||') ? null : expression; } @@ -410,7 +436,7 @@ function selectorExpressionToMaestro(expression: string): unknown | null { return selectorToMaestro(chain.selectors[0]!); } -function readFallbackTextSelector(selectors: Selector[]): string | null { +function readFallbackTextSelector(selectors: MaestroExportSelector[]): string | null { if (selectors.length <= 1) return null; const values = selectors.flatMap((selector) => selector.terms.length === 1 && TEXT_SELECTOR_KEYS.has(selector.terms[0]!.key) @@ -423,7 +449,7 @@ function readFallbackTextSelector(selectors: Selector[]): string | null { return values.every((value) => value === first) ? first : null; } -function selectorToMaestro(selector: Selector): Record | null { +function selectorToMaestro(selector: MaestroExportSelector): Record | null { const result: Record = {}; for (const term of selector.terms) { if (!appendSelectorTerm(result, term)) return null; @@ -431,7 +457,10 @@ function selectorToMaestro(selector: Selector): Record | null { return Object.keys(result).length > 0 ? result : null; } -function appendSelectorTerm(result: Record, term: SelectorTerm): boolean { +function appendSelectorTerm( + result: Record, + term: MaestroExportSelectorTerm, +): boolean { if (TEXT_SELECTOR_KEYS.has(term.key)) { if (typeof term.value !== 'string' || result.id || result.text || result.label) return false; result[term.key] = term.value; diff --git a/src/compat/maestro/export-points.ts b/packages/maestro/src/internal/export-points.ts similarity index 100% rename from src/compat/maestro/export-points.ts rename to packages/maestro/src/internal/export-points.ts diff --git a/src/compat/maestro/export-types.ts b/packages/maestro/src/internal/export-types.ts similarity index 100% rename from src/compat/maestro/export-types.ts rename to packages/maestro/src/internal/export-types.ts diff --git a/src/compat/maestro/export-yaml.ts b/packages/maestro/src/internal/export-yaml.ts similarity index 100% rename from src/compat/maestro/export-yaml.ts rename to packages/maestro/src/internal/export-yaml.ts diff --git a/packages/maestro/src/internal/facade-execution.ts b/packages/maestro/src/internal/facade-execution.ts new file mode 100644 index 0000000000..791cff2c46 --- /dev/null +++ b/packages/maestro/src/internal/facade-execution.ts @@ -0,0 +1,304 @@ +import path from 'node:path'; +import { isDeepLinkTarget } from '@agent-device/contracts/command'; +import type { SessionRuntimeHints } from '@agent-device/kernel/contracts'; +import type { SnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot'; +import { executeMaestroPlan } from './engine.ts'; +import { + isMaestroControlCommandDescriptor, + type MaestroEngineEvent, + type MaestroEngineObserver, + type MaestroRuntimePort, +} from './engine-types.ts'; +import { parseMaestroProgram } from './program-ir-parser.ts'; +import type { + MaestroCommand, + MaestroPlatform, + MaestroProgram, + MaestroSourceLocation, +} from './program-ir.ts'; +import { createMaestroProgramLoader } from './program-loader.ts'; +import { formatMaestroCommandProgress } from './progress.ts'; +import { + compileMaestroReplayPlan, + evaluateMaestroReplayResume, + resolveMaestroReplayStartIndex, +} from './replay-plan.ts'; +import type { MaestroReplayPlan } from './replay-plan-types.ts'; +import { matchesMaestroTypedSelector } from './runtime-target-policy.ts'; +import { rankMaestroCandidates } from './runtime-target-ranking.ts'; + +const flowProgram = Symbol('maestroFlowProgram'); +const failureCommand = Symbol('maestroFailureCommand'); +const failurePlan = Symbol('maestroFailurePlan'); + +export type MaestroFlow = { + readonly sourcePath: string; + readonly name?: string; + readonly appTarget?: string; + readonly [flowProgram]: MaestroProgram; +}; + +export type MaestroActionEvent = { + readonly action: string; + readonly value?: string; + readonly source: MaestroSourceLocation; + readonly generation: number; + readonly stepIndex: number; + readonly stepTotal: number; +}; + +export type MaestroCompletedActionEvent = MaestroActionEvent & { + readonly durationMs: number; + readonly runtimeMetrics?: { + hierarchyCaptures: number; + screenshotCaptures: number; + tapRetries: number; + }; + readonly data?: Record; +}; + +export type MaestroFailedAction = MaestroActionEvent & { + readonly durationMs: number; + readonly runtimeMetrics?: MaestroCompletedActionEvent['runtimeMetrics']; + readonly error: unknown; + readonly artifactPaths: readonly string[]; + readonly isControl: boolean; + readonly redactions: readonly { name: string; value: string }[]; + readonly resume: + | { readonly allowed: true; readonly from: number; readonly planDigest: string } + | { + readonly allowed: false; + readonly from: number; + readonly planDigest: string; + readonly reason: string; + }; + readonly [failureCommand]: MaestroEngineEvent['command']; + readonly [failurePlan]: MaestroReplayPlan; +}; + +export type MaestroExecutionObserver = { + actionStarted?(event: MaestroActionEvent): void; + actionCompleted?(event: MaestroCompletedActionEvent): void; + actionFailed?(event: MaestroFailedAction): void; +}; + +export type MaestroExecutionOptions = { + readonly defaults?: Readonly>; + readonly env?: Readonly>; + readonly platform?: MaestroPlatform; + readonly target?: string; + readonly runtimeHints?: Readonly; + readonly from?: number; + readonly planDigest?: string; + readonly signal?: AbortSignal; + readonly observer?: MaestroExecutionObserver; +}; + +export type MaestroExecutionOutcome = + | { + readonly ok: true; + readonly replayed: number; + readonly planDigest: string; + readonly startIndex: number; + readonly artifactPaths: string[]; + readonly warnings?: string[]; + } + | { + readonly ok: false; + readonly error: unknown; + readonly failure?: MaestroFailedAction; + }; + +export type MaestroFailureCandidate = { + readonly node: SnapshotNode; + readonly basis: 'id' | 'label' | 'other'; +}; + +export function inspectMaestroFlow(source: string, sourcePath: string): MaestroFlow { + const program = parseMaestroProgram(source, { sourcePath }); + return { + sourcePath, + name: program.config.name, + appTarget: readStaticAppTarget(program), + [flowProgram]: program, + }; +} + +export async function executeMaestroFlow( + flow: MaestroFlow, + port: MaestroRuntimePort, + options: MaestroExecutionOptions = {}, +): Promise { + let failed: MaestroFailedAction | undefined; + try { + const loader = createMaestroProgramLoader(path.dirname(flow.sourcePath)); + const plan = await compileMaestroReplayPlan(flow[flowProgram], { + defaults: options.defaults, + env: options.env, + platform: options.platform, + target: options.target, + runtimeHints: options.runtimeHints, + loadProgram: loader, + signal: options.signal, + }); + const startIndex = resolveMaestroReplayStartIndex(plan, { + from: options.from, + planDigest: options.planDigest, + }); + const result = await executeMaestroPlan(plan, port, { + defaults: options.defaults, + env: options.env, + platform: options.platform, + target: options.target, + loadProgram: loader, + signal: options.signal, + startIndex, + observer: createObserver(plan, options.observer, (event) => { + failed = event; + }), + }); + return { + ok: true, + replayed: plan.total - startIndex, + planDigest: plan.digest, + startIndex, + artifactPaths: result.artifactPaths, + ...(result.warnings ? { warnings: result.warnings } : {}), + }; + } catch (error) { + return { ok: false, error: failed?.error ?? error, ...(failed ? { failure: failed } : {}) }; + } +} + +export function rankMaestroFailureCandidates( + failure: MaestroFailedAction, + snapshot: SnapshotState, +): MaestroFailureCandidate[] { + const query = suggestionQuery(failure[failureCommand]); + const platform = failure[failurePlan].platform; + if (!query || (platform !== 'android' && platform !== 'ios')) return []; + return rankMaestroCandidates(snapshot, query.selector, platform, query.childOf).ranked.map( + (node) => ({ node, basis: suggestionBasis(query.selector, node) }), + ); +} + +function createObserver( + plan: MaestroReplayPlan, + observer: MaestroExecutionObserver | undefined, + onFailure: (failure: MaestroFailedAction) => void, +): MaestroEngineObserver { + return { + commandStarted: (event) => observer?.actionStarted?.(actionView(event)), + commandCompleted: (event) => + observer?.actionCompleted?.({ + ...actionView(event), + durationMs: event.durationMs, + runtimeMetrics: event.runtimeMetrics, + data: event.data, + }), + commandFailed: (event) => { + const resume = evaluateMaestroReplayResume(plan, { + from: event.stepIndex, + planDigest: plan.digest, + }); + const failure: MaestroFailedAction = { + ...actionView(event), + durationMs: event.durationMs, + error: event.error, + artifactPaths: event.artifactPaths, + isControl: isMaestroControlCommandDescriptor(event.command), + redactions: + event.command.kind === 'inputText' && event.command.text.length > 0 + ? [{ name: 'inputText.text', value: event.command.text }] + : [], + resume: resume.allowed + ? { allowed: true, from: event.stepIndex, planDigest: plan.digest } + : { + allowed: false, + from: event.stepIndex, + planDigest: plan.digest, + reason: resume.reason, + }, + [failureCommand]: event.command, + [failurePlan]: plan, + }; + onFailure(failure); + observer?.actionFailed?.(failure); + }, + }; +} + +function actionView(event: MaestroEngineEvent): MaestroActionEvent { + const progress = formatMaestroCommandProgress(event.command); + return { + action: progress.command, + ...(progress.value ? { value: progress.value } : {}), + source: event.source, + generation: event.generation, + stepIndex: event.stepIndex, + stepTotal: event.stepTotal, + }; +} + +function readStaticAppTarget(program: MaestroProgram): string | undefined { + const candidates = [ + program.config.appId, + ...[...(program.config.onFlowStart ?? []), ...program.commands] + .filter((command) => command.kind === 'launchApp') + .map((command) => (command.kind === 'launchApp' ? command.appId : undefined)), + ]; + return candidates.find(isStaticAppTarget); +} + +function isStaticAppTarget(value: string | undefined): value is string { + return Boolean(value?.trim() && !value.includes('$') && !isDeepLinkTarget(value)); +} + +type SuggestionQuery = { + selector: Extract['target']; + childOf?: Extract['childOf']; +}; + +function suggestionQuery(command: MaestroEngineEvent['command']): SuggestionQuery | undefined { + switch (command.kind) { + case 'tapOn': + return command.target.space === 'target' + ? { selector: command.target.selector, childOf: command.childOf } + : undefined; + case 'doubleTapOn': + case 'longPressOn': + return command.target.space === 'target' ? { selector: command.target.selector } : undefined; + case 'assertVisible': + case 'assertNotVisible': + return { selector: command.target }; + case 'extendedWaitUntil': + return command.visible + ? { selector: command.visible } + : command.notVisible + ? { selector: command.notVisible } + : undefined; + case 'scrollUntilVisible': + return { selector: command.element }; + case 'swipe': + return command.gesture.kind === 'target' ? { selector: command.gesture.from } : undefined; + default: + return undefined; + } +} + +function suggestionBasis( + selector: SuggestionQuery['selector'], + node: SnapshotNode, +): MaestroFailureCandidate['basis'] { + if (selector.id !== undefined) return 'id'; + if ( + selector.text !== undefined && + matchesMaestroTypedSelector( + { ...node, label: undefined, value: undefined }, + { text: selector.text }, + ) + ) { + return 'id'; + } + return selector.text !== undefined || selector.label !== undefined ? 'label' : 'other'; +} diff --git a/src/compat/maestro/program-ir-command-parser.ts b/packages/maestro/src/internal/program-ir-command-parser.ts similarity index 99% rename from src/compat/maestro/program-ir-command-parser.ts rename to packages/maestro/src/internal/program-ir-command-parser.ts index 6523860a5c..a6b4c88aac 100644 --- a/src/compat/maestro/program-ir-command-parser.ts +++ b/packages/maestro/src/internal/program-ir-command-parser.ts @@ -1,5 +1,5 @@ import { isMap, isScalar, isSeq, type Node } from 'yaml'; -import { stripUndefined } from '../../utils/parsing.ts'; +import { stripUndefined } from './shared.ts'; import type { MaestroAssertNotVisibleCommand, MaestroAssertVisibleCommand, diff --git a/src/compat/maestro/program-ir-flow-parser.ts b/packages/maestro/src/internal/program-ir-flow-parser.ts similarity index 99% rename from src/compat/maestro/program-ir-flow-parser.ts rename to packages/maestro/src/internal/program-ir-flow-parser.ts index ab73c95249..b2c0c81d76 100644 --- a/src/compat/maestro/program-ir-flow-parser.ts +++ b/packages/maestro/src/internal/program-ir-flow-parser.ts @@ -24,7 +24,7 @@ import { type MaestroProgramParseContext, } from './program-ir-values.ts'; import { parseMaestroSelector } from './program-ir-gesture-parser.ts'; -import { stripUndefined } from '../../utils/parsing.ts'; +import { stripUndefined } from './shared.ts'; export type MaestroCommandListParser = ( node: Node | null | undefined, diff --git a/src/compat/maestro/program-ir-gesture-parser.ts b/packages/maestro/src/internal/program-ir-gesture-parser.ts similarity index 99% rename from src/compat/maestro/program-ir-gesture-parser.ts rename to packages/maestro/src/internal/program-ir-gesture-parser.ts index c22c44330a..fb265781eb 100644 --- a/src/compat/maestro/program-ir-gesture-parser.ts +++ b/packages/maestro/src/internal/program-ir-gesture-parser.ts @@ -1,5 +1,5 @@ import { isScalar, type Node } from 'yaml'; -import { stripUndefined } from '../../utils/parsing.ts'; +import { stripUndefined } from './shared.ts'; import type { MaestroCoordinate, MaestroDirection, diff --git a/src/compat/maestro/program-ir-parser.ts b/packages/maestro/src/internal/program-ir-parser.ts similarity index 98% rename from src/compat/maestro/program-ir-parser.ts rename to packages/maestro/src/internal/program-ir-parser.ts index 585b5f0893..a6705da3bb 100644 --- a/src/compat/maestro/program-ir-parser.ts +++ b/packages/maestro/src/internal/program-ir-parser.ts @@ -1,6 +1,6 @@ import { isMap, isSeq, LineCounter, parseAllDocuments, type Node } from 'yaml'; import { AppError } from '@agent-device/kernel/errors'; -import { stripUndefined } from '../../utils/parsing.ts'; +import { stripUndefined } from './shared.ts'; import type { MaestroProgram, MaestroProgramConfig, diff --git a/src/compat/maestro/program-ir-values.ts b/packages/maestro/src/internal/program-ir-values.ts similarity index 99% rename from src/compat/maestro/program-ir-values.ts rename to packages/maestro/src/internal/program-ir-values.ts index 678a55711b..d4f7198fb6 100644 --- a/src/compat/maestro/program-ir-values.ts +++ b/packages/maestro/src/internal/program-ir-values.ts @@ -1,6 +1,6 @@ import { isMap, isNode, isScalar, isSeq, LineCounter, type Node } from 'yaml'; import { AppError } from '@agent-device/kernel/errors'; -import { stripUndefined } from '../../utils/parsing.ts'; +import { stripUndefined } from './shared.ts'; import type { MaestroOptionalCommand, MaestroScalar, MaestroSourceLocation } from './program-ir.ts'; export type MaestroProgramParseContext = { diff --git a/src/compat/maestro/program-ir.ts b/packages/maestro/src/internal/program-ir.ts similarity index 100% rename from src/compat/maestro/program-ir.ts rename to packages/maestro/src/internal/program-ir.ts diff --git a/src/compat/maestro/program-loader.ts b/packages/maestro/src/internal/program-loader.ts similarity index 94% rename from src/compat/maestro/program-loader.ts rename to packages/maestro/src/internal/program-loader.ts index d042fd1a28..6f8f7505ad 100644 --- a/src/compat/maestro/program-loader.ts +++ b/packages/maestro/src/internal/program-loader.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import { createRequestCanceledError } from '../../request/cancel.ts'; +import { createRequestCanceledError } from './shared.ts'; import type { MaestroProgram } from './program-ir.ts'; import { parseMaestroProgram } from './program-ir-parser.ts'; diff --git a/src/compat/maestro/progress.ts b/packages/maestro/src/internal/progress.ts similarity index 100% rename from src/compat/maestro/progress.ts rename to packages/maestro/src/internal/progress.ts diff --git a/src/compat/maestro/replay-plan-compilation.ts b/packages/maestro/src/internal/replay-plan-compilation.ts similarity index 97% rename from src/compat/maestro/replay-plan-compilation.ts rename to packages/maestro/src/internal/replay-plan-compilation.ts index e55ca10e00..2e86e78599 100644 --- a/src/compat/maestro/replay-plan-compilation.ts +++ b/packages/maestro/src/internal/replay-plan-compilation.ts @@ -3,7 +3,7 @@ import type { MaestroProgram } from './program-ir.ts'; import { compileMaestroReplayPlanSteps } from './replay-plan-steps.ts'; import type { MaestroReplayPlan, MaestroReplayPlanOptions } from './replay-plan-types.ts'; import type { SessionRuntimeHints } from '@agent-device/kernel/contracts'; -import { stripUndefined } from '../../utils/parsing.ts'; +import { stripUndefined } from './shared.ts'; export async function compileMaestroReplayPlan( program: MaestroProgram, diff --git a/src/compat/maestro/replay-plan-digest.ts b/packages/maestro/src/internal/replay-plan-digest.ts similarity index 91% rename from src/compat/maestro/replay-plan-digest.ts rename to packages/maestro/src/internal/replay-plan-digest.ts index 0424f775e5..7e6b4d13ab 100644 --- a/src/compat/maestro/replay-plan-digest.ts +++ b/packages/maestro/src/internal/replay-plan-digest.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { canonicalJson } from '../../utils/canonical-json.ts'; +import { canonicalJson } from './shared.ts'; export function computeMaestroReplayPlanDigest(plan: { readonly platform?: string; diff --git a/src/compat/maestro/replay-plan-execution.ts b/packages/maestro/src/internal/replay-plan-execution.ts similarity index 100% rename from src/compat/maestro/replay-plan-execution.ts rename to packages/maestro/src/internal/replay-plan-execution.ts diff --git a/src/compat/maestro/replay-plan-resume.ts b/packages/maestro/src/internal/replay-plan-resume.ts similarity index 100% rename from src/compat/maestro/replay-plan-resume.ts rename to packages/maestro/src/internal/replay-plan-resume.ts diff --git a/src/compat/maestro/replay-plan-step-execution.ts b/packages/maestro/src/internal/replay-plan-step-execution.ts similarity index 99% rename from src/compat/maestro/replay-plan-step-execution.ts rename to packages/maestro/src/internal/replay-plan-step-execution.ts index 7ff9feef51..3348d28c6e 100644 --- a/src/compat/maestro/replay-plan-step-execution.ts +++ b/packages/maestro/src/internal/replay-plan-step-execution.ts @@ -1,5 +1,5 @@ import { AppError } from '@agent-device/kernel/errors'; -import { stripUndefined } from '../../utils/parsing.ts'; +import { stripUndefined } from './shared.ts'; import { isMaestroTestFailure, maestroTestFailure } from './compatibility-errors.ts'; import { MAESTRO_COMPATIBILITY_PRESETS, diff --git a/src/compat/maestro/replay-plan-steps.ts b/packages/maestro/src/internal/replay-plan-steps.ts similarity index 99% rename from src/compat/maestro/replay-plan-steps.ts rename to packages/maestro/src/internal/replay-plan-steps.ts index a1265407b4..685bc313b6 100644 --- a/src/compat/maestro/replay-plan-steps.ts +++ b/packages/maestro/src/internal/replay-plan-steps.ts @@ -1,5 +1,5 @@ import { createMaestroExecutionContext, type MaestroExecutionContext } from './engine-context.ts'; -import { stripUndefined } from '../../utils/parsing.ts'; +import { stripUndefined } from './shared.ts'; import { assertIncludePathAvailable, checkpointMaestroCancellation, diff --git a/src/compat/maestro/replay-plan-types.ts b/packages/maestro/src/internal/replay-plan-types.ts similarity index 100% rename from src/compat/maestro/replay-plan-types.ts rename to packages/maestro/src/internal/replay-plan-types.ts diff --git a/src/compat/maestro/replay-plan.ts b/packages/maestro/src/internal/replay-plan.ts similarity index 100% rename from src/compat/maestro/replay-plan.ts rename to packages/maestro/src/internal/replay-plan.ts diff --git a/src/compat/maestro/runtime-port-commands.ts b/packages/maestro/src/internal/runtime-port-commands.ts similarity index 99% rename from src/compat/maestro/runtime-port-commands.ts rename to packages/maestro/src/internal/runtime-port-commands.ts index c090af1f73..65f467f29e 100644 --- a/src/compat/maestro/runtime-port-commands.ts +++ b/packages/maestro/src/internal/runtime-port-commands.ts @@ -1,6 +1,5 @@ import { AppError } from '@agent-device/kernel/errors'; -import { pointInsideRect } from '../../utils/rect-center.ts'; -import { stripUndefined } from '../../utils/parsing.ts'; +import { pointInsideRect, stripUndefined } from './shared.ts'; import { maestroScrollDurationFromSpeed, MAESTRO_COMPATIBILITY_PRESETS, diff --git a/src/compat/maestro/runtime-port-context.ts b/packages/maestro/src/internal/runtime-port-context.ts similarity index 94% rename from src/compat/maestro/runtime-port-context.ts rename to packages/maestro/src/internal/runtime-port-context.ts index 6f4afb9eef..cb55bae2a9 100644 --- a/src/compat/maestro/runtime-port-context.ts +++ b/packages/maestro/src/internal/runtime-port-context.ts @@ -1,5 +1,5 @@ import type { MaestroObservationRequest, MaestroRuntimeRequest } from './engine-types.ts'; -import { stripUndefined } from '../../utils/parsing.ts'; +import { stripUndefined } from './shared.ts'; import type { MaestroCommand } from './program-ir.ts'; import type { MaestroRuntimeOperationContext, diff --git a/src/compat/maestro/runtime-port-geometry.ts b/packages/maestro/src/internal/runtime-port-geometry.ts similarity index 88% rename from src/compat/maestro/runtime-port-geometry.ts rename to packages/maestro/src/internal/runtime-port-geometry.ts index b7de30c7a1..c0cd1bcd6d 100644 --- a/src/compat/maestro/runtime-port-geometry.ts +++ b/packages/maestro/src/internal/runtime-port-geometry.ts @@ -2,12 +2,8 @@ import { AppError } from '@agent-device/kernel/errors'; import { buildInPageSwipeGesturePlan } from '@agent-device/contracts/interaction'; import { isPositiveFiniteRect } from '@agent-device/kernel/rect'; import type { Rect, SnapshotState } from '@agent-device/kernel/snapshot'; -import { - findLargestViewportRect, - findNearestScrollableContainer, - isScrollableSnapshotType, -} from '../../daemon/snapshot-presentation/tree.ts'; -import { pointInsideRect } from '../../utils/rect-center.ts'; +import { pointInsideRect } from './shared.ts'; +import { normalizeType } from './snapshot-policy.ts'; import { MAESTRO_COMPATIBILITY_PRESETS } from './compatibility-policy.ts'; import { resolveNumeric } from './engine-flow.ts'; import type { MaestroRuntimeRequest } from './engine-types.ts'; @@ -97,6 +93,49 @@ function selectMaestroScrollableViewport( return candidates.sort(compareViewportAreaDescending)[0]?.viewport; } +function findLargestViewportRect(nodes: SnapshotState['nodes']): Rect | undefined { + return nodes + .filter((node) => { + const type = normalizeType(node.type ?? ''); + return isPositiveFiniteRect(node.rect) && (type === 'application' || type === 'window'); + }) + .sort( + (left, right) => + right.rect!.width * right.rect!.height - left.rect!.width * left.rect!.height, + )[0]?.rect; +} + +function isScrollableSnapshotType(type: string | undefined): boolean { + const normalized = normalizeType(type ?? ''); + return ( + normalized === 'collectionview' || + normalized === 'table' || + normalized === 'scrollview' || + normalized === 'scrollarea' + ); +} + +function findNearestScrollableContainer( + node: SnapshotState['nodes'][number], + byIndex: ReadonlyMap, + options: { includeSelf?: boolean } = {}, +): SnapshotState['nodes'][number] | null { + let current = + options.includeSelf === true && isScrollableSnapshotType(node.type) + ? node + : typeof node.parentIndex === 'number' + ? byIndex.get(node.parentIndex) + : undefined; + const visited = new Set(); + while (current && !visited.has(current.index)) { + visited.add(current.index); + if (isScrollableSnapshotType(current.type)) return current; + current = + typeof current.parentIndex === 'number' ? byIndex.get(current.parentIndex) : undefined; + } + return null; +} + function findLargestPositiveRect( nodes: readonly SnapshotState['nodes'][number][], ): Rect | undefined { diff --git a/src/compat/maestro/runtime-port-observation.ts b/packages/maestro/src/internal/runtime-port-observation.ts similarity index 100% rename from src/compat/maestro/runtime-port-observation.ts rename to packages/maestro/src/internal/runtime-port-observation.ts diff --git a/src/compat/maestro/runtime-port-types.ts b/packages/maestro/src/internal/runtime-port-types.ts similarity index 100% rename from src/compat/maestro/runtime-port-types.ts rename to packages/maestro/src/internal/runtime-port-types.ts diff --git a/src/compat/maestro/runtime-target-policy.ts b/packages/maestro/src/internal/runtime-target-policy.ts similarity index 85% rename from src/compat/maestro/runtime-target-policy.ts rename to packages/maestro/src/internal/runtime-target-policy.ts index 24ef8181f2..41add1923e 100644 --- a/src/compat/maestro/runtime-target-policy.ts +++ b/packages/maestro/src/internal/runtime-target-policy.ts @@ -1,9 +1,8 @@ import type { MaestroSelector } from './program-ir.ts'; import type { SnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot'; -import { evaluateIsPredicate } from '../../selectors/predicates.ts'; -import { normalizeText } from '../../selectors/find.ts'; import { matchesMaestroRegex } from './selector-regex.ts'; -import { extractNodeText } from '../../snapshot/snapshot-processing.ts'; +import { extractNodeText, normalizeText } from './shared.ts'; +import { isMaestroNodeVisible } from './snapshot-policy.ts'; export type MaestroPlatform = 'ios' | 'android'; @@ -48,15 +47,7 @@ export function filterVisibleMaestroMatches(params: { matches: SnapshotNode[]; platform: MaestroPlatform; }): SnapshotNode[] { - return params.matches.filter( - (node) => - evaluateIsPredicate({ - predicate: 'visible', - node, - nodes: params.nodes, - platform: params.platform, - }).pass, - ); + return params.matches.filter((node) => isMaestroNodeVisible(node, params.nodes, params.platform)); } function matchesMaestroSelectorValue(value: string | undefined, query: string): boolean { diff --git a/src/compat/maestro/runtime-target-ranking.ts b/packages/maestro/src/internal/runtime-target-ranking.ts similarity index 97% rename from src/compat/maestro/runtime-target-ranking.ts rename to packages/maestro/src/internal/runtime-target-ranking.ts index 791a47f587..36cf90626b 100644 --- a/src/compat/maestro/runtime-target-ranking.ts +++ b/packages/maestro/src/internal/runtime-target-ranking.ts @@ -4,8 +4,8 @@ import { buildSnapshotNodeByIndex, isDescendantOfSnapshotNode, normalizeType, -} from '../../snapshot/snapshot-processing.ts'; -import { normalizeText } from '../../selectors/find.ts'; +} from './snapshot-policy.ts'; +import { normalizeText } from './shared.ts'; import type { MaestroSelector } from './program-ir.ts'; import { filterVisibleMaestroMatches, diff --git a/src/compat/maestro/runtime-targets.ts b/packages/maestro/src/internal/runtime-targets.ts similarity index 77% rename from src/compat/maestro/runtime-targets.ts rename to packages/maestro/src/internal/runtime-targets.ts index b85a99329a..c91b0d1b31 100644 --- a/src/compat/maestro/runtime-targets.ts +++ b/packages/maestro/src/internal/runtime-targets.ts @@ -1,18 +1,8 @@ -import { - attachRefs, - type Rect, - type SnapshotNode, - type SnapshotState, -} from '@agent-device/kernel/snapshot'; -import { - buildIosInteractiveSnapshotPresentation, - type IosInteractiveSnapshotPresentation, -} from '../../daemon/snapshot-presentation/ios/index.ts'; +import type { Rect, SnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot'; import type { MaestroSelector } from './program-ir.ts'; import type { MaestroPlatform } from './runtime-target-policy.ts'; import { rankMaestroCandidates, selectMaestroSnapshotMatch } from './runtime-target-ranking.ts'; -import { pointInsideRect } from '../../utils/rect-center.ts'; -import { stripUndefined } from '../../utils/parsing.ts'; +import { pointInsideRect, stripUndefined } from './shared.ts'; export type MaestroTargetQuery = { selector: MaestroSelector; @@ -45,7 +35,11 @@ export function resolveMaestroTargetFromSnapshot( snapshot: SnapshotState, query: MaestroTargetQuery, platform: MaestroPlatform, - options: { interactiveBounds?: boolean } = {}, + options: { + interactiveBounds?: boolean; + interactiveRects?: ReadonlyMap; + canonicalSnapshot?: SnapshotState; + } = {}, ): MaestroTargetResolution { const candidates = rankMaestroCandidates(snapshot, query.selector, platform, query.childOf); if (!candidates.parentMatched) { @@ -61,13 +55,9 @@ export function resolveMaestroTargetFromSnapshot( if (!target) { return failedTargetResolution(query, matches, rankedMatches, evidence); } - const presentation = - platform === 'ios' && (options.interactiveBounds || query.allowAtomicSelectorDispatch) - ? buildIosInteractiveSnapshotPresentation(snapshot.nodes) - : undefined; const rect = options.interactiveBounds === true - ? (presentation?.sourceNodes.get(target.node.index)?.rect ?? target.rect) + ? (options.interactiveRects?.get(target.node.index) ?? target.rect) : target.rect; return { ok: true, @@ -76,7 +66,7 @@ export function resolveMaestroTargetFromSnapshot( matches: rankedMatches.length, dispatchCandidates: platform === 'ios' && query.allowAtomicSelectorDispatch && !query.childOf - ? countCanonicalDispatchCandidates(snapshot, query, { ...target, rect }, presentation) + ? countCanonicalDispatchCandidates(query, { ...target, rect }, options.canonicalSnapshot) : 0, evidence, }; @@ -100,17 +90,11 @@ function failedTargetResolution( } function countCanonicalDispatchCandidates( - snapshot: SnapshotState, query: MaestroTargetQuery, target: { node: SnapshotNode; rect: Rect }, - presentation: IosInteractiveSnapshotPresentation | undefined, + canonicalSnapshot: SnapshotState | undefined, ): number { - const canonicalSnapshot = { - ...snapshot, - nodes: attachRefs( - (presentation ?? buildIosInteractiveSnapshotPresentation(snapshot.nodes)).nodes, - ), - }; + if (!canonicalSnapshot) return 0; const canonicalRankedMatches = rankMaestroCandidates( canonicalSnapshot, query.selector, diff --git a/src/compat/maestro/selector-regex.ts b/packages/maestro/src/internal/selector-regex.ts similarity index 100% rename from src/compat/maestro/selector-regex.ts rename to packages/maestro/src/internal/selector-regex.ts diff --git a/src/compat/maestro/selector-vocabulary.ts b/packages/maestro/src/internal/selector-vocabulary.ts similarity index 100% rename from src/compat/maestro/selector-vocabulary.ts rename to packages/maestro/src/internal/selector-vocabulary.ts diff --git a/packages/maestro/src/internal/shared.ts b/packages/maestro/src/internal/shared.ts new file mode 100644 index 0000000000..fb507f52dd --- /dev/null +++ b/packages/maestro/src/internal/shared.ts @@ -0,0 +1,54 @@ +import { AppError } from '@agent-device/kernel/errors'; +import type { Point, Rect, SnapshotNode } from '@agent-device/kernel/snapshot'; + +export function stripUndefined>(value: T): T { + return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)) as T; +} + +export function canonicalJson(value: unknown): string { + return JSON.stringify(sortKeysDeep(value)); +} + +function sortKeysDeep(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortKeysDeep); + if (!value || typeof value !== 'object') return value; + const record = value as Record; + return Object.fromEntries( + Object.keys(record) + .sort() + .map((key) => [key, sortKeysDeep(record[key])]), + ); +} + +export function pointInsideRect(rect: Rect): Point { + return { + x: interiorCoordinate(rect.x, rect.width), + y: interiorCoordinate(rect.y, rect.height), + }; +} + +function interiorCoordinate(origin: number, size: number): number { + if (size <= 1) return Math.floor(origin); + const min = Math.ceil(origin); + const max = Math.floor(origin + size - 1); + return Math.round(Math.min(max, Math.max(min, origin + size / 2))); +} + +export function normalizeText(value: string): string { + return value.trim().toLowerCase().replace(/\s+/g, ' '); +} + +export function extractNodeText(node: SnapshotNode): string { + return ( + [node.label, node.value, node.identifier] + .find((candidate) => typeof candidate === 'string' && candidate.length > 0) + ?.trim() ?? '' + ); +} + +export function createRequestCanceledError(): AppError { + return new AppError('COMMAND_FAILED', 'request canceled', { + reason: 'request_canceled', + hint: 'The request was canceled intentionally (explicit cancel or client disconnect) — no retry is needed unless the cancellation was unintended.', + }); +} diff --git a/packages/maestro/src/internal/snapshot-policy.ts b/packages/maestro/src/internal/snapshot-policy.ts new file mode 100644 index 0000000000..8db859144e --- /dev/null +++ b/packages/maestro/src/internal/snapshot-policy.ts @@ -0,0 +1,156 @@ +import { isPositiveFiniteRect } from '@agent-device/kernel/rect'; +import type { Rect, SnapshotNode } from '@agent-device/kernel/snapshot'; + +export function isMaestroNodeVisible( + node: SnapshotNode, + nodes: SnapshotNode[], + platform: 'android' | 'ios', +): boolean { + if (platform === 'android' && node.visibleToUser === false) return false; + if (isPositiveFiniteRect(node.rect)) { + return isVisibleInEffectiveViewport(node, nodes); + } + if (node.rect) return false; + if (platform !== 'android' && node.hittable === true) return true; + const anchor = findSnapshotAncestor(nodes, node, (parent) => + isUsefulVisibilityAnchor(parent, platform) ? parent : null, + ); + if (!anchor) return false; + if (!isPositiveFiniteRect(anchor.rect)) { + return platform !== 'android' && anchor.hittable === true; + } + return isVisibleInEffectiveViewport(anchor, nodes); +} + +export function buildSnapshotNodeByIndex(nodes: SnapshotNode[]): Map { + return new Map(nodes.map((node) => [node.index, node])); +} + +export function isDescendantOfSnapshotNode( + nodes: SnapshotNode[], + node: SnapshotNode, + ancestor: SnapshotNode, + nodeByIndex: ReadonlyMap, +): boolean { + return Boolean( + findSnapshotAncestor( + nodes, + node, + (candidate) => (candidate.index === ancestor.index ? candidate : null), + nodeByIndex, + ), + ); +} + +export function normalizeType(type: string): string { + let normalized = type + .trim() + .replace(/XCUIElementType/gi, '') + .replace(/^AX/, '') + .toLowerCase(); + const separator = Math.max(normalized.lastIndexOf('.'), normalized.lastIndexOf('/')); + if (separator !== -1) normalized = normalized.slice(separator + 1); + return normalized; +} + +function findSnapshotAncestor( + nodes: SnapshotNode[], + node: SnapshotNode, + resolve: (ancestor: SnapshotNode) => T | null, + nodeByIndex: ReadonlyMap = buildSnapshotNodeByIndex(nodes), +): T | null { + let current: SnapshotNode | undefined = node; + const visited = new Set(); + while (typeof current.parentIndex === 'number' && !visited.has(current.index)) { + visited.add(current.index); + current = nodeByIndex.get(current.parentIndex) ?? nodes[current.parentIndex]; + if (!current) return null; + const result = resolve(current); + if (result !== null) return result; + } + return null; +} + +function isUsefulVisibilityAnchor(node: SnapshotNode, platform: 'android' | 'ios'): boolean { + if (platform === 'android' && node.visibleToUser === false) return false; + const type = normalizeType(node.type ?? ''); + if ( + type.includes('application') || + type.includes('window') || + type.includes('scrollview') || + type.includes('tableview') || + type.includes('collectionview') || + type === 'table' || + type === 'list' || + type === 'listview' + ) { + return false; + } + return platform === 'android' + ? node.hittable === true && isPositiveFiniteRect(node.rect) + : node.hittable === true || isPositiveFiniteRect(node.rect); +} + +function isVisibleInEffectiveViewport(node: SnapshotNode, nodes: SnapshotNode[]): boolean { + if (!node.rect) return true; + const byIndex = buildSnapshotNodeByIndex(nodes); + const viewport = + findScrollableAncestorRect(node, byIndex) ?? resolveRootViewport(nodes, node.rect); + return viewport ? rectsOverlap(node.rect, viewport) : true; +} + +function findScrollableAncestorRect( + node: SnapshotNode, + byIndex: ReadonlyMap, +): Rect | null { + let current = typeof node.parentIndex === 'number' ? byIndex.get(node.parentIndex) : undefined; + const visited = new Set(); + while (current && !visited.has(current.index)) { + visited.add(current.index); + if (current.rect && isScrollableNode(current)) return current.rect; + current = + typeof current.parentIndex === 'number' ? byIndex.get(current.parentIndex) : undefined; + } + return null; +} + +function isScrollableNode(node: SnapshotNode): boolean { + const type = `${node.type ?? ''}`.toLowerCase(); + if ( + type.includes('scroll') || + type.includes('recyclerview') || + type.includes('listview') || + type.includes('gridview') || + type.includes('collectionview') || + type === 'table' + ) { + return true; + } + return `${node.role ?? ''} ${node.subrole ?? ''}`.toLowerCase().includes('scroll'); +} + +function resolveRootViewport(nodes: SnapshotNode[], target: Rect): Rect | null { + const viewportRects = nodes + .filter((node) => { + const type = (node.type ?? '').toLowerCase(); + return node.rect && (type.includes('application') || type.includes('window')); + }) + .map((node) => node.rect!) + .sort((left, right) => right.width * right.height - left.width * left.height); + const centerX = target.x + target.width / 2; + const centerY = target.y + target.height / 2; + return ( + viewportRects.find((rect) => containsPoint(rect, centerX, centerY)) ?? viewportRects[0] ?? null + ); +} + +function rectsOverlap(left: Rect, right: Rect): boolean { + return ( + Math.max(left.x, right.x) <= Math.min(left.x + left.width, right.x + right.width) && + Math.max(left.y, right.y) <= Math.min(left.y + left.height, right.y + right.height) + ); +} + +function containsPoint(rect: Rect, x: number, y: number): boolean { + return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height; +} diff --git a/src/compat/maestro/support-matrix.ts b/packages/maestro/src/internal/support-matrix.ts similarity index 100% rename from src/compat/maestro/support-matrix.ts rename to packages/maestro/src/internal/support-matrix.ts diff --git a/packages/maestro/tsconfig.json b/packages/maestro/tsconfig.json new file mode 100644 index 0000000000..935c871a4d --- /dev/null +++ b/packages/maestro/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationDir": "./dist-types", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa63e04142..531159834a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,9 +20,6 @@ importers: pngjs: specifier: ^7.0.0 version: 7.0.0 - yaml: - specifier: ^2.9.0 - version: 2.9.0 devDependencies: '@agent-device/contracts': specifier: workspace:* @@ -30,6 +27,9 @@ importers: '@agent-device/kernel': specifier: workspace:* version: link:packages/kernel + '@agent-device/maestro': + specifier: workspace:* + version: link:packages/maestro '@chenglou/freerange': specifier: ^0.0.1 version: 0.0.1 @@ -75,6 +75,9 @@ importers: vitest: specifier: ^4.1.8 version: 4.1.8(@types/node@22.19.21)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@22.19.21)(yaml@2.9.0)) + yaml: + specifier: ^2.9.0 + version: 2.9.0 packages/contracts: dependencies: @@ -84,14 +87,26 @@ importers: packages/kernel: {} + packages/maestro: + dependencies: + '@agent-device/contracts': + specifier: workspace:* + version: link:../contracts + '@agent-device/kernel': + specifier: workspace:* + version: link:../kernel + yaml: + specifier: ^2.9.0 + version: 2.9.0 + website: devDependencies: '@callstack/rspress-preset': specifier: ^0.6.6 - version: 0.6.6(@rsbuild/core@2.0.11)(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 0.6.6(@rsbuild/core@2.0.11)(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@rspress/core': specifier: ^2.0.12 - version: 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0) + version: 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2) packages: @@ -2562,8 +2577,8 @@ packages: pure-rand@8.4.2: resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} - qs@6.15.2: - resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} engines: {node: '>=0.6'} quansync@1.0.0: @@ -3137,7 +3152,7 @@ snapshots: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 @@ -3172,14 +3187,14 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@7.2.0) + '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 '@babel/traverse': 7.29.7(supports-color@7.2.0) semver: 6.3.1 transitivePeerDependencies: @@ -3187,24 +3202,24 @@ snapshots: '@babel/helper-globals@7.29.7': {} - '@babel/helper-member-expression-to-functions@7.29.7(supports-color@7.2.0)': + '@babel/helper-member-expression-to-functions@7.29.7': dependencies: '@babel/traverse': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': + '@babel/helper-module-imports@7.29.7': dependencies: '@babel/traverse': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 '@babel/traverse': 7.29.7(supports-color@7.2.0) transitivePeerDependencies: @@ -3216,16 +3231,16 @@ snapshots: '@babel/helper-plugin-utils@7.29.7': {} - '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@7.2.0) + '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 '@babel/traverse': 7.29.7(supports-color@7.2.0) transitivePeerDependencies: - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@7.2.0)': + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': dependencies: '@babel/traverse': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 @@ -3255,10 +3270,10 @@ snapshots: dependencies: '@babel/types': 7.29.7 - '@babel/plugin-proposal-decorators@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-proposal-decorators@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-decorators': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) transitivePeerDependencies: @@ -3279,7 +3294,7 @@ snapshots: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 @@ -3287,41 +3302,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.28.5(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/preset-typescript@7.28.5(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-validator-option': 7.29.7 '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) transitivePeerDependencies: - supports-color @@ -3355,11 +3370,11 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@callstack/rspress-preset@0.6.6(@rsbuild/core@2.0.11)(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@callstack/rspress-preset@0.6.6(@rsbuild/core@2.0.11)(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@callstack/rspress-theme': 0.6.6(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@rspress/core': 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0) - '@rspress/plugin-sitemap': 2.0.8(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0)) + '@callstack/rspress-theme': 0.6.6(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@rspress/core': 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2) + '@rspress/plugin-sitemap': 2.0.8(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2)) '@vercel/analytics': 2.0.1(react@19.2.7) rsbuild-plugin-open-graph: 1.1.2(@rsbuild/core@2.0.11) zod: 4.3.6 @@ -3375,9 +3390,9 @@ snapshots: - vue - vue-router - '@callstack/rspress-theme@0.6.6(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@callstack/rspress-theme@0.6.6(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@rspress/core': 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0) + '@rspress/core': 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -3592,7 +3607,7 @@ snapshots: - supports-color - utf-8-validate - '@mdx-js/mdx@3.1.1(supports-color@7.2.0)': + '@mdx-js/mdx@3.1.1': dependencies: '@types/estree': 1.0.8 '@types/estree-jsx': 1.0.5 @@ -3604,14 +3619,14 @@ snapshots: estree-util-is-identifier-name: 3.0.0 estree-util-scope: 1.0.0 estree-walker: 3.0.3 - hast-util-to-jsx-runtime: 2.3.6(supports-color@7.2.0) + hast-util-to-jsx-runtime: 2.3.6 markdown-extensions: 2.0.0 recma-build-jsx: 1.0.0 recma-jsx: 1.0.1(acorn@8.16.0) recma-stringify: 1.0.0 - rehype-recma: 1.0.0(supports-color@7.2.0) - remark-mdx: 3.1.1(supports-color@7.2.0) - remark-parse: 11.0.0(supports-color@7.2.0) + rehype-recma: 1.0.0 + remark-mdx: 3.1.1 + remark-parse: 11.0.0 remark-rehype: 11.1.2 source-map: 0.7.6 unified: 11.0.5 @@ -4010,9 +4025,9 @@ snapshots: optionalDependencies: '@rspack/core': 2.0.6(@swc/helpers@0.5.23) - '@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0)': + '@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2)': dependencies: - '@mdx-js/mdx': 3.1.1(supports-color@7.2.0) + '@mdx-js/mdx': 3.1.1 '@mdx-js/react': 3.1.1(@types/react@19.2.13)(react@19.2.7) '@rsbuild/core': 2.0.11 '@rsbuild/plugin-react': 2.0.0(@rsbuild/core@2.0.11)(@rspack/core@2.0.6(@swc/helpers@0.5.23)) @@ -4025,9 +4040,9 @@ snapshots: copy-to-clipboard: 3.3.3 flexsearch: 0.8.212 hast-util-heading-rank: 3.0.0 - hast-util-to-jsx-runtime: 2.3.6(supports-color@7.2.0) - mdast-util-mdx: 3.0.0(supports-color@7.2.0) - mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) + hast-util-to-jsx-runtime: 2.3.6 + mdast-util-mdx: 3.0.0 + mdast-util-mdxjs-esm: 2.0.1 medium-zoom: 1.1.0 nprogress: 0.2.0 react: 19.2.7 @@ -4038,11 +4053,11 @@ snapshots: react-router-dom: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) rehype-external-links: 3.0.0 rehype-raw: 7.0.0 - remark-cjk-friendly: 2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(unified@11.0.5) - remark-cjk-friendly-gfm-strikethrough: 2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(unified@11.0.5) - remark-gfm: 4.0.1(supports-color@7.2.0) - remark-mdx: 3.1.1(supports-color@7.2.0) - remark-parse: 11.0.0(supports-color@7.2.0) + remark-cjk-friendly: 2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5) + remark-cjk-friendly-gfm-strikethrough: 2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5) + remark-gfm: 4.0.1 + remark-mdx: 3.1.1 + remark-parse: 11.0.0 remark-stringify: 11.0.0 scroll-into-view-if-needed: 3.1.0 shiki: 4.0.2 @@ -4060,9 +4075,9 @@ snapshots: - micromark-util-types - supports-color - '@rspress/plugin-sitemap@2.0.8(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0))': + '@rspress/plugin-sitemap@2.0.8(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2))': dependencies: - '@rspress/core': 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0) + '@rspress/core': 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2) '@rspress/shared@2.0.12': dependencies: @@ -4172,9 +4187,9 @@ snapshots: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/generator': 7.29.7 '@babel/parser': 7.29.3 - '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7(supports-color@7.2.0)) '@stryker-mutator/api': 9.6.1 '@stryker-mutator/util': 9.6.1 angular-html-parser: 10.4.0 @@ -4805,7 +4820,7 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 - hast-util-to-estree@3.1.3(supports-color@7.2.0): + hast-util-to-estree@3.1.3: dependencies: '@types/estree': 1.0.8 '@types/estree-jsx': 1.0.5 @@ -4815,9 +4830,9 @@ snapshots: estree-util-attach-comments: 3.0.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) - mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) - mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 property-information: 7.1.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -4840,7 +4855,7 @@ snapshots: stringify-entities: 4.0.4 zwitch: 2.0.4 - hast-util-to-jsx-runtime@2.3.6(supports-color@7.2.0): + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 '@types/hast': 3.0.4 @@ -4849,9 +4864,9 @@ snapshots: devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) - mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) - mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 property-information: 7.1.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -5047,14 +5062,14 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - mdast-util-from-markdown@2.0.3(supports-color@7.2.0): + mdast-util-from-markdown@2.0.3: dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.2(supports-color@7.2.0) + micromark: 4.0.2 micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-string: 2.0.1 micromark-util-normalize-identifier: 2.0.1 @@ -5072,67 +5087,67 @@ snapshots: mdast-util-find-and-replace: 3.0.2 micromark-util-character: 2.1.1 - mdast-util-gfm-footnote@2.1.0(supports-color@7.2.0): + mdast-util-gfm-footnote@2.1.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: - supports-color - mdast-util-gfm-strikethrough@2.0.0(supports-color@7.2.0): + mdast-util-gfm-strikethrough@2.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-table@2.0.0(supports-color@7.2.0): + mdast-util-gfm-table@2.0.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-task-list-item@2.0.0(supports-color@7.2.0): + mdast-util-gfm-task-list-item@2.0.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm@3.1.0(supports-color@7.2.0): + mdast-util-gfm@3.1.0: dependencies: - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-gfm-autolink-literal: 2.0.1 - mdast-util-gfm-footnote: 2.1.0(supports-color@7.2.0) - mdast-util-gfm-strikethrough: 2.0.0(supports-color@7.2.0) - mdast-util-gfm-table: 2.0.0(supports-color@7.2.0) - mdast-util-gfm-task-list-item: 2.0.0(supports-color@7.2.0) + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-expression@2.0.1(supports-color@7.2.0): + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-jsx@3.2.0(supports-color@7.2.0): + mdast-util-mdx-jsx@3.2.0: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 @@ -5140,7 +5155,7 @@ snapshots: '@types/unist': 3.0.3 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 parse-entities: 4.0.2 stringify-entities: 4.0.4 @@ -5149,23 +5164,23 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-mdx@3.0.0(supports-color@7.2.0): + mdast-util-mdx@3.0.0: dependencies: - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) - mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) - mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) - mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdxjs-esm@2.0.1(supports-color@7.2.0): + mdast-util-mdxjs-esm@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -5224,11 +5239,11 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - micromark-extension-cjk-friendly-gfm-strikethrough@2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0)): + micromark-extension-cjk-friendly-gfm-strikethrough@2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2): dependencies: devlop: 1.1.0 get-east-asian-width: 1.5.0 - micromark: 4.0.2(supports-color@7.2.0) + micromark: 4.0.2 micromark-extension-cjk-friendly-util: 3.0.1(micromark-util-types@2.0.2) micromark-util-character: 2.1.1 micromark-util-chunked: 2.0.1 @@ -5245,10 +5260,10 @@ snapshots: optionalDependencies: micromark-util-types: 2.0.2 - micromark-extension-cjk-friendly@2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0)): + micromark-extension-cjk-friendly@2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2): dependencies: devlop: 1.1.0 - micromark: 4.0.2(supports-color@7.2.0) + micromark: 4.0.2 micromark-extension-cjk-friendly-util: 3.0.1(micromark-util-types@2.0.2) micromark-util-chunked: 2.0.1 micromark-util-resolve-all: 2.0.1 @@ -5479,7 +5494,7 @@ snapshots: micromark-util-types@2.0.2: {} - micromark@4.0.2(supports-color@7.2.0): + micromark@4.0.2: dependencies: '@types/debug': 4.1.13 debug: 4.4.3(supports-color@7.2.0) @@ -5667,7 +5682,7 @@ snapshots: pure-rand@8.4.2: {} - qs@6.15.2: + qs@6.15.1: dependencies: side-channel: 1.1.1 @@ -5762,17 +5777,17 @@ snapshots: hast-util-raw: 9.1.0 vfile: 6.0.3 - rehype-recma@1.0.0(supports-color@7.2.0): + rehype-recma@1.0.0: dependencies: '@types/estree': 1.0.8 '@types/hast': 3.0.4 - hast-util-to-estree: 3.1.3(supports-color@7.2.0) + hast-util-to-estree: 3.1.3 transitivePeerDependencies: - supports-color - remark-cjk-friendly-gfm-strikethrough@2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(unified@11.0.5): + remark-cjk-friendly-gfm-strikethrough@2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5): dependencies: - micromark-extension-cjk-friendly-gfm-strikethrough: 2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0)) + micromark-extension-cjk-friendly-gfm-strikethrough: 2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2) unified: 11.0.5 optionalDependencies: '@types/mdast': 4.0.4 @@ -5780,9 +5795,9 @@ snapshots: - micromark - micromark-util-types - remark-cjk-friendly@2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(unified@11.0.5): + remark-cjk-friendly@2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5): dependencies: - micromark-extension-cjk-friendly: 2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0)) + micromark-extension-cjk-friendly: 2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2) unified: 11.0.5 optionalDependencies: '@types/mdast': 4.0.4 @@ -5790,28 +5805,28 @@ snapshots: - micromark - micromark-util-types - remark-gfm@4.0.1(supports-color@7.2.0): + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 - mdast-util-gfm: 3.1.0(supports-color@7.2.0) + mdast-util-gfm: 3.1.0 micromark-extension-gfm: 3.0.0 - remark-parse: 11.0.0(supports-color@7.2.0) + remark-parse: 11.0.0 remark-stringify: 11.0.0 unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-mdx@3.1.1(supports-color@7.2.0): + remark-mdx@3.1.1: dependencies: - mdast-util-mdx: 3.0.0(supports-color@7.2.0) + mdast-util-mdx: 3.0.0 micromark-extension-mdxjs: 3.0.0 transitivePeerDependencies: - supports-color - remark-parse@11.0.0(supports-color@7.2.0): + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 micromark-util-types: 2.0.2 unified: 11.0.5 transitivePeerDependencies: @@ -6056,7 +6071,7 @@ snapshots: dependencies: des.js: 1.1.0 js-md4: 0.3.2 - qs: 6.15.2 + qs: 6.15.1 tunnel: 0.0.6 underscore: 1.13.8 diff --git a/scripts/fuzz/targets.ts b/scripts/fuzz/targets.ts index 16b88bba03..4e49e77e55 100644 --- a/scripts/fuzz/targets.ts +++ b/scripts/fuzz/targets.ts @@ -11,7 +11,7 @@ import { parseArgs } from '../../src/cli/parser/args.ts'; import { parseSelectorChain } from '../../src/selectors/parse.ts'; import { parseReplayScriptDetailed } from '../../src/replay/script.ts'; import { readCliBatchStepsJson } from '../../src/cli/batch-steps.ts'; -import { parseMaestroProgram } from '../../src/compat/maestro/program-ir-parser.ts'; +import { inspectMaestroFlow } from '@agent-device/maestro'; import type { FuzzTarget } from './target-types.ts'; // argv is carried as one string so a case (and its corpus entry, artifact, and repro @@ -94,7 +94,7 @@ export const FUZZ_TARGETS: readonly FuzzTarget[] = [ { name: 'maestro', description: 'parseMaestroProgram (Maestro compat)', - run: (input) => void parseMaestroProgram(input, { sourcePath: 'fuzz.yaml' }), + run: (input) => void inspectMaestroFlow(input, 'fuzz.yaml'), seeds: [ 'appId: com.example.app\n---\n- launchApp\n- tapOn: "Login"\n', 'appId: com.example.app\n---\n- tapOn:\n id: "login"\n', diff --git a/scripts/layering/daemon-modularity.test.ts b/scripts/layering/daemon-modularity.test.ts index 1a16ef7bf4..27dca1ffcd 100644 --- a/scripts/layering/daemon-modularity.test.ts +++ b/scripts/layering/daemon-modularity.test.ts @@ -66,7 +66,7 @@ test('external daemon/types.ts importer membership changes require the baseline const violations = checkDaemonModularityRatchets([...baselineEdges(), ...edges], []); assert.equal(violations.length, 1); - assert.match(violations[0]!.message, /may only shrink from the recorded 4/); + assert.match(violations[0]!.message, /may only shrink from the recorded 2/); const removed = checkDaemonModularityRatchets( [...baselineDaemonTypesEdges().slice(1), ...recordedMigrationEdges()], @@ -148,8 +148,8 @@ test('recorded replay-test migration imports are exempt until the import is dele test('internal trees reject deep imports globally, including from daemon', () => { const edges = resolveImportEdges( new Map([ - ['src/daemon/adapter.ts', "import type { Plan } from '../maestro/internal/plan.ts';"], - ['src/maestro/internal/plan.ts', 'export type Plan = { steps: number };'], + ['src/daemon/adapter.ts', "import type { Plan } from '@agent-device/maestro/internal/plan';"], + ['packages/maestro/src/internal/plan.ts', 'export type Plan = { steps: number };'], ]), ); diff --git a/scripts/layering/daemon-modularity.ts b/scripts/layering/daemon-modularity.ts index 4d8060b162..0f53d8000c 100644 --- a/scripts/layering/daemon-modularity.ts +++ b/scripts/layering/daemon-modularity.ts @@ -21,8 +21,6 @@ export const DAEMON_MODULARITY_BASELINE = { }, externalDaemonTypesImporters: [ 'src/client/client-normalizers.ts', - 'src/compat/maestro/daemon-runtime-port-support.ts', - 'src/compat/maestro/daemon-runtime-public-operation.ts', 'src/remote/daemon-artifacts.ts', ], } as const; @@ -60,12 +58,12 @@ export const LOGICAL_MODULE_POLICIES: readonly LogicalModulePolicy[] = [ 'src/platforms/', 'src/providers/', 'src/compat/', - 'src/maestro/', + 'packages/maestro/', ], }, { name: 'maestro', - roots: ['src/maestro/'], + roots: ['packages/maestro/src/'], forbiddenTargetRoots: ['src/daemon/', 'src/platforms/', 'src/providers/', 'src/ad-replay/'], }, { @@ -96,9 +94,8 @@ export const LOGICAL_MODULE_POLICIES: readonly LogicalModulePolicy[] = [ const ENGINE_FILE_PREFIXES = [ 'src/ad-replay/', - 'src/maestro/', + 'packages/maestro/src/', 'src/replay/', - 'src/compat/maestro/', 'src/daemon/handlers/session-replay', 'src/daemon/handlers/session-test', ] as const; diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index 0c58dc79fd..57e79f66c6 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -33,6 +33,7 @@ export type BackEdgeMap = Record; const TARGET_DAG_RANK = new Map([ ['cloud-webdriver', 1], ['contracts', 1], + ['maestro', 1], ['platforms', 1], ['recording', 1], ['replay', 1], diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 23fb6eed62..e99e2943b1 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -193,6 +193,13 @@ test('the real tree parses, declares, and passes R11', () => { assert.ok(contractsPackage, 'contracts package must exist'); assert.deepEqual([...contractsPackage.exportTargets.keys()].sort(), [...CONTRACT_EXPORTS].sort()); assert.deepEqual([...contractsPackage.workspaceDependencies], ['@agent-device/kernel']); + const maestroPackage = packages.find((pkg) => pkg.name === '@agent-device/maestro'); + assert.ok(maestroPackage, 'maestro package must exist'); + assert.deepEqual([...maestroPackage.exportTargets.keys()], ['@agent-device/maestro']); + assert.deepEqual([...maestroPackage.workspaceDependencies].sort(), [ + '@agent-device/contracts', + '@agent-device/kernel', + ]); assert.ok( rootWorkspaceDependencyNames(repoRoot).has('@agent-device/kernel'), 'root must declare the kernel workspace dependency', @@ -201,6 +208,10 @@ test('the real tree parses, declares, and passes R11', () => { rootWorkspaceDependencyNames(repoRoot).has('@agent-device/contracts'), 'root must declare the contracts workspace dependency', ); + assert.ok( + rootWorkspaceDependencyNames(repoRoot).has('@agent-device/maestro'), + 'root must declare the maestro workspace dependency', + ); assert.deepEqual(checkPackageBoundaries(repoRoot, new Set()), []); }); diff --git a/scripts/maestro-conformance/README.md b/scripts/maestro-conformance/README.md index 0bb2506052..6504923dd1 100644 --- a/scripts/maestro-conformance/README.md +++ b/scripts/maestro-conformance/README.md @@ -1,6 +1,6 @@ # Maestro conformance oracle -A three-layer oracle that proves the agent-device Maestro engine (`src/compat/maestro`) +A three-layer oracle that proves the private agent-device Maestro package (`packages/maestro`) stays faithful to a version-pinned upstream Maestro. It replaces the original hand-typed parser fixture, whose transcribed expectations let four bug classes slip through during #1217. Every expected value here is **generated from the @@ -97,7 +97,8 @@ failure, not a pass. teeth (`invalid/`). To add a flow: drop the `.yaml` in, add a note to `NOTES` in `build-manifest.mjs`, and regenerate. - [`fixtures/`](./fixtures) — the generated, checked-in layer-1/layer-2 captures. -- [`normalize.ts`](./normalize.ts) — canonical projection shared by both engines. +- `packages/maestro/src/internal/conformance-normalize.ts` — package-private canonical projection + shared by the facade's conformance operations. - [`verify.ts`](./verify.ts) / [`verify.test.ts`](./verify.test.ts) — the deterministic verifier. - [`expected-divergence.ts`](./expected-divergence.ts) — declared, on-the-record divergences. - [`differential/`](./differential) — layer-3 scenarios, runner, engine-side diff --git a/scripts/maestro-conformance/differential/invariants.test.ts b/scripts/maestro-conformance/differential/invariants.test.ts index 3181ed287c..6b44b4261f 100644 --- a/scripts/maestro-conformance/differential/invariants.test.ts +++ b/scripts/maestro-conformance/differential/invariants.test.ts @@ -6,7 +6,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; -import { MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS } from '../../../src/compat/maestro/compatibility-policy.ts'; +import { MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS } from '@agent-device/maestro'; import { DIFFERENTIAL_SCENARIOS } from './scenarios.ts'; import { type Invariant, evaluateInvariant, readTrace } from './invariants.ts'; diff --git a/scripts/maestro-conformance/differential/scenarios.ts b/scripts/maestro-conformance/differential/scenarios.ts index 1749fe9f6b..64c0315d94 100644 --- a/scripts/maestro-conformance/differential/scenarios.ts +++ b/scripts/maestro-conformance/differential/scenarios.ts @@ -17,7 +17,7 @@ // we do it engine-side via `engineInvariants` over agent-device's own replay // timing trace. Scenarios without invariants prove outcome parity ONLY; do not // read more into them than that. -import { MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS } from '../../../src/compat/maestro/compatibility-policy.ts'; +import { MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS } from '@agent-device/maestro'; import type { Invariant } from './invariants.ts'; /** Bundle id of the fixture app the workflow installs before running scenarios. */ @@ -115,7 +115,7 @@ export const DIFFERENTIAL_SCENARIOS: DifferentialScenario[] = [ // distinguish it, so claiming this scenario guards bug class 1 would be a // lie the pass/pass result cannot back up. That half is pinned exactly by a // pure unit test of the conversion - // (src/compat/maestro/__tests__/runtime-port-geometry.test.ts), and the + // (packages/maestro/src/internal/__tests__/runtime-port-geometry.test.ts), and the // parse-level half by layer 1 (bug-classes/percent-decimal-swipe). What this // scenario adds is only that percentage swipes work end-to-end on both. flow: 'differential/flows/percent-swipe.yaml', diff --git a/scripts/maestro-conformance/verify.test.ts b/scripts/maestro-conformance/verify.test.ts index 105699f8f6..93bfc80d8f 100644 --- a/scripts/maestro-conformance/verify.test.ts +++ b/scripts/maestro-conformance/verify.test.ts @@ -16,8 +16,7 @@ import { loadLayer1, loadLayer2, } from './verify.ts'; -import { parseMaestroProgram } from '../../src/compat/maestro/program-ir-parser.ts'; -import { canonicalizeAgentCommands } from './normalize.ts'; +import { parseMaestroConformanceSource } from '@agent-device/maestro'; import { DOCUMENTED_DEVIATIONS, FLOW_DIVERGENCES, @@ -94,8 +93,7 @@ test('layer 1: we-are-lenient flows preserve unresolved ${VAR} tokens in the age const flow = flowsById.get(result.id); assert.ok(flow, `missing layer-1 fixture for ${result.id}`); const script = fs.readFileSync(path.join(CORPUS_DIR, flow.file), 'utf8'); - const program = parseMaestroProgram(script, { sourcePath: flow.file }); - const canonical = canonicalizeAgentCommands(program); + const canonical = parseMaestroConformanceSource(script, flow.file).commands; assert.ok( JSON.stringify(canonical).includes('${'), `${result.id}: canonical model must preserve unresolved \${VAR} numeric tokens instead of substituting defaults`, diff --git a/scripts/maestro-conformance/verify.ts b/scripts/maestro-conformance/verify.ts index 71169e5bdb..24560d773e 100644 --- a/scripts/maestro-conformance/verify.ts +++ b/scripts/maestro-conformance/verify.ts @@ -14,15 +14,13 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { AppError } from '@agent-device/kernel/errors'; -import type { MaestroProgram } from '../../src/compat/maestro/program-ir.ts'; -import { parseMaestroProgram } from '../../src/compat/maestro/program-ir-parser.ts'; -import { SUPPORTED_MAESTRO_COMMAND_NAMES } from '../../src/compat/maestro/program-ir-command-parser.ts'; -import { MAESTRO_COMPATIBILITY_PRESETS } from '../../src/compat/maestro/compatibility-policy.ts'; import { - type CanonicalCommand, - canonicalizeAgentCommands, - canonicalizeUpstreamFlow, -} from './normalize.ts'; + canonicalizeUpstreamMaestroFlow, + MAESTRO_CONFORMANCE_CONSTANTS, + parseMaestroConformanceSource, + SUPPORTED_MAESTRO_COMMAND_NAMES, + type MaestroCanonicalCommand, +} from '@agent-device/maestro'; import { LAYER2_REFERENCE_ONLY, UNVERIFIED_COMMANDS } from './expected-divergence.ts'; // @ts-expect-error -- .mjs helper shared with regenerate.mjs; no type declarations. import { checkFixtureSeal } from './fixture-seal.mjs'; @@ -102,10 +100,10 @@ export function checkFixtureSeals(): SealResult[] { const AGENT_REJECTION_CODES = new Set(['INVALID_ARGS']); /** Parse a corpus flow with the live engine. `null` = a clean rejection. */ -function agentParseProgram(file: string): MaestroProgram | null { +function agentParseProgram(file: string): ReturnType | null { const script = fs.readFileSync(path.join(CORPUS_DIR, file), 'utf8'); try { - return parseMaestroProgram(script, { sourcePath: file }); + return parseMaestroConformanceSource(script, file); } catch (error) { if (error instanceof AppError && AGENT_REJECTION_CODES.has(error.code)) return null; throw new Error(`Parsing ${file} crashed instead of rejecting cleanly`, { cause: error }); @@ -114,11 +112,11 @@ function agentParseProgram(file: string): MaestroProgram | null { function agentParse(file: string): { status: 'parsed' | 'rejected'; - commands?: CanonicalCommand[]; + commands?: MaestroCanonicalCommand[]; } { const program = agentParseProgram(file); if (!program) return { status: 'rejected' }; - return { status: 'parsed', commands: canonicalizeAgentCommands(program) }; + return { status: 'parsed', commands: program.commands }; } function classifyFlow(fixtureFlow: Layer1Fixture['flows'][number]): FlowResult { @@ -141,7 +139,7 @@ function classifyFlow(fixtureFlow: Layer1Fixture['flows'][number]): FlowResult { if (agent.status === 'rejected') { return { ...base, classification: 'we-reject' }; } - const upstream = canonicalizeUpstreamFlow(fixtureFlow.commands ?? []); + const upstream = canonicalizeUpstreamMaestroFlow(fixtureFlow.commands ?? []); const agentCommands = agent.commands ?? []; const upstreamJson = JSON.stringify(upstream); const agentJson = JSON.stringify(agentCommands); @@ -161,15 +159,9 @@ export function classifyAllFlows(): FlowResult[] { // Layer 2 cross-check // --------------------------------------------------------------------------- -const P = MAESTRO_COMPATIBILITY_PRESETS; - /** Generated vector id → the live agent-device constant it must equal. */ const LAYER2_AGENT_CONSTANTS: Record = { - retryMaxRetries: P.control.retryMaxRetries, - animationWaitThreshold: P.command.waitForAnimationToEndDifferencePercent, - animationWaitTimeoutMs: P.command.waitForAnimationToEndTimeoutMs, - maxEraseCharacters: P.command.eraseTextMaxCharacters, - swipeDurationMs: P.command.swipeDurationMs, + ...MAESTRO_CONFORMANCE_CONSTANTS, }; export type Layer2Result = { @@ -211,22 +203,13 @@ export function agentKindsByCorpus(): Set { for (const flow of loadLayer1().flows) { // Rejected flows contribute nothing to coverage; a crash still throws. const program = agentParseProgram(flow.file); - if (program) collectKinds(program.commands, kinds); + if (program) { + for (const kind of program.kinds) kinds.add(kind); + } } return kinds; } -function collectKinds( - commands: Array<{ kind: string; commands?: unknown }>, - into: Set, -): void { - for (const command of commands) { - into.add(command.kind); - const nested = (command as { commands?: Array<{ kind: string }> }).commands; - if (Array.isArray(nested)) collectKinds(nested, into); - } -} - export function checkCoverage(): CoverageResult[] { const covered = agentKindsByCorpus(); return SUPPORTED_MAESTRO_COMMAND_NAMES.map((command) => ({ diff --git a/src/compat/maestro/__tests__/export-flow.test.ts b/src/cli/commands/__tests__/replay-maestro-export.test.ts similarity index 89% rename from src/compat/maestro/__tests__/export-flow.test.ts rename to src/cli/commands/__tests__/replay-maestro-export.test.ts index 4b9dcd5af6..083bc6dd90 100644 --- a/src/compat/maestro/__tests__/export-flow.test.ts +++ b/src/cli/commands/__tests__/replay-maestro-export.test.ts @@ -1,8 +1,9 @@ import { parseAllDocuments } from 'yaml'; import { describe, expect, test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; -import { exportReplayScriptToMaestro } from '../export-flow.ts'; -import { parseMaestroProgram } from '../program-ir-parser.ts'; +import { exportReplayActionsToMaestro, inspectMaestroFlow } from '@agent-device/maestro'; +import { parseReplayScriptDetailed, readReplayScriptMetadata } from '../../../replay/script.ts'; +import { parseSelectorChain } from '../../../selectors/index.ts'; describe('exportReplayScriptToMaestro', () => { test('exports app launch, selectors, input, keyboard, assertions, and screenshots', () => { @@ -17,7 +18,7 @@ screenshot "./artifacts/checkout" `); const docs = parseYamlDocs(result.yaml); - expect(() => parseMaestroProgram(result.yaml)).not.toThrow(); + expect(() => inspectMaestroFlow(result.yaml, 'export.yaml')).not.toThrow(); expect(docs).toEqual([ { appId: 'com.example.app', env: { USER: 'Ada' } }, [ @@ -165,6 +166,15 @@ get text id="status" }); }); +function exportReplayScriptToMaestro(script: string) { + const parsed = parseReplayScriptDetailed(script); + return exportReplayActionsToMaestro(parsed.actions, { + actionLines: parsed.actionLines, + metadata: readReplayScriptMetadata(script), + parseSelector: parseSelectorChain, + }); +} + function parseYamlDocs(script: string): unknown[] { return parseAllDocuments(script).map((doc) => doc.toJSON()); } diff --git a/src/cli/commands/replay.ts b/src/cli/commands/replay.ts index d6ab206b35..ee2fc7146d 100644 --- a/src/cli/commands/replay.ts +++ b/src/cli/commands/replay.ts @@ -1,7 +1,9 @@ import fs from 'node:fs'; import path from 'node:path'; -import { exportReplayScriptToMaestro } from '../../compat/maestro/export-flow.ts'; +import { exportReplayActionsToMaestro } from '@agent-device/maestro'; import { AppError } from '@agent-device/kernel/errors'; +import { parseReplayScriptDetailed, readReplayScriptMetadata } from '../../replay/script.ts'; +import { parseSelectorChain } from '../../selectors/index.ts'; import { resolveUserPath } from '../../utils/path-resolution.ts'; import { writeCommandOutput } from './shared.ts'; import type { ClientCommandHandler } from './router-types.ts'; @@ -38,7 +40,12 @@ async function handleReplayExportCommand({ const sourcePath = resolveUserPath(inputPath); const script = fs.readFileSync(sourcePath, 'utf8'); - const result = exportReplayScriptToMaestro(script); + const parsed = parseReplayScriptDetailed(script); + const result = exportReplayActionsToMaestro(parsed.actions, { + actionLines: parsed.actionLines, + metadata: readReplayScriptMetadata(script), + parseSelector: parseSelectorChain, + }); const outputPath = typeof flags.out === 'string' ? resolveUserPath(flags.out) : undefined; if (outputPath) { fs.mkdirSync(path.dirname(outputPath), { recursive: true }); diff --git a/src/compat/maestro/__tests__/support-matrix.test.ts b/src/cli/parser/__tests__/maestro-support-matrix.test.ts similarity index 94% rename from src/compat/maestro/__tests__/support-matrix.test.ts rename to src/cli/parser/__tests__/maestro-support-matrix.test.ts index 2a44cd8d52..4169a58bbb 100644 --- a/src/compat/maestro/__tests__/support-matrix.test.ts +++ b/src/cli/parser/__tests__/maestro-support-matrix.test.ts @@ -1,13 +1,13 @@ import fs from 'node:fs'; import { expect, test } from 'vitest'; -import { usageForCommand } from '../../../cli/parser/args.ts'; +import { usageForCommand } from '../args.ts'; import { getFlagDefinitions } from '../../../commands/cli-grammar/flag-registry.ts'; import { MAESTRO_COMPATIBILITY_ADR_URL, MAESTRO_COMPATIBILITY_ISSUE_URL, MAESTRO_COMPAT_LIMITATIONS, MAESTRO_COMPAT_SUPPORTED_CAPABILITIES, -} from '../support-matrix.ts'; +} from '@agent-device/maestro'; test('Maestro flag routes to the versioned compatibility help topic', () => { const flag = getFlagDefinitions().find((definition) => definition.key === 'replayMaestro'); diff --git a/src/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index ccdfa890ac..52a3ec0e37 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -3,7 +3,7 @@ import { formatMaestroCompatibilityReference, MAESTRO_COMPATIBILITY_ADR_URL, MAESTRO_COMPATIBILITY_ISSUE_URL, -} from '../../compat/maestro/support-matrix.ts'; +} from '@agent-device/maestro'; import { getCliCommandSchema, getCommandSchema, diff --git a/src/compat/maestro/daemon-runtime-port.ts b/src/compat/maestro/daemon-runtime-port.ts deleted file mode 100644 index e350255e5b..0000000000 --- a/src/compat/maestro/daemon-runtime-port.ts +++ /dev/null @@ -1,593 +0,0 @@ -import { AppError, asAppError } from '@agent-device/kernel/errors'; -import type { Rect } from '@agent-device/kernel/snapshot'; -import { emitDiagnostic, registerDiagnosticSensitiveValue } from '../../utils/diagnostics.ts'; -import { stripUndefined } from '../../utils/parsing.ts'; -import { executeRunScriptFile } from './run-script-execution.ts'; -import { - MAESTRO_COMPATIBILITY_PRESETS, - MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS, -} from './compatibility-policy.ts'; -import { maestroTestFailure } from './compatibility-errors.ts'; -import { - executeMaestroRuntimeCommand, - maestroCommandRequiresSettledPredecessor, -} from './runtime-port-commands.ts'; -import { operationContext } from './runtime-port-context.ts'; -import { observeMaestroCondition } from './runtime-port-observation.ts'; -import { waitForMaestroAnimationToEnd } from './wait-for-animation-to-end.ts'; -import { - withMaestroScreenshotBaseline, - type MaestroScreenshotBaseline, -} from './maestro-screenshot-comparison.ts'; -import type { - MaestroRuntimeMetrics, - MaestroRuntimePort, - MaestroRuntimeRequest, - MaestroRuntimeResult, -} from './engine-types.ts'; -import type { - MaestroDispatchSelector, - MaestroRuntimeOperationContext, - MaestroRuntimeReadContext, - MaestroRuntimeOperations, - MaestroTargetMatch, - MaestroTargetQuery, -} from './runtime-port-types.ts'; -import { pointInsideRect } from '../../utils/rect-center.ts'; -import { resolveMaestroScrollableGesture } from './runtime-port-geometry.ts'; -import { - MAESTRO_OBSERVATION_POLL_MS, - captureRetriableMaestroSnapshot, - observeTypedMaestroCondition, - resolveTypedMaestroTarget, - scrollUntilTypedMaestroTarget, - sleepWithinDeadline, - waitForTypedSnapshotStability, - maestroSnapshotSignature, - type MaestroSnapshotSource, -} from './daemon-runtime-port-observation.ts'; -import { createDaemonMaestroSnapshotSource } from './daemon-runtime-port-snapshot-source.ts'; -import { - artifactPathsFromData, - invokeMaestroPublicOperation, - launchArgumentValues, - observationFromMatch, - resolveScriptPath, - stringifyEnvironment, - type CreateDaemonMaestroRuntimeOperationsOptions, -} from './daemon-runtime-port-support.ts'; -import type { - MaestroClickOptions, - MaestroPublicOperation, -} from './daemon-runtime-public-operation.ts'; - -export type { CreateDaemonMaestroRuntimeOperationsOptions } from './daemon-runtime-port-support.ts'; - -function createDaemonMaestroRuntimeParts(options: CreateDaemonMaestroRuntimeOperationsOptions): { - operations: MaestroRuntimeOperations; - snapshots: MaestroSnapshotSource; - readMetrics: () => MaestroRuntimeMetrics; -} { - const snapshots = createDaemonMaestroSnapshotSource(options); - const metrics = { screenshotCaptures: 0, tapRetries: 0 }; - const platform = options.platform; - const invoke = (operation: MaestroPublicOperation) => { - if (operation.kind === 'screenshot') metrics.screenshotCaptures += 1; - return invokeMaestroPublicOperation(options, operation); - }; - const withMutation = async ( - mutation: () => Promise, - context: MaestroRuntimeOperationContext, - stability: 'none' | 'deferred' = 'none', - ): Promise => { - snapshots.invalidate(context.generation); - try { - return await mutation(); - } finally { - if (stability === 'deferred') snapshots.requireStability(context.generation); - } - }; - const invokeMutation = async ( - operation: MaestroPublicOperation, - context: MaestroRuntimeOperationContext, - stability: 'none' | 'deferred' = 'none', - ) => await withMutation(() => invoke(operation), context, stability); - const typeTextAndSettle = async ( - text: string, - context: MaestroRuntimeOperationContext, - ): Promise => { - registerDiagnosticSensitiveValue(text); - await invokeMutation({ kind: 'typeText', text }, context); - const stable = await waitForTypedSnapshotStability({ - timeoutMs: MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS, - context, - snapshot: snapshots.capture, - dependencies: options.dependencies, - }); - snapshots.prime(context.generation, stable.snapshot); - }; - - const operations: MaestroRuntimeOperations = { - platform, - resolveTarget: async (input, context) => - await resolveDaemonMaestroTarget({ input, context, snapshots, options }), - observe: async (input, context) => - await observeTypedMaestroCondition({ - condition: input.condition, - timeoutMs: input.timeoutMs, - context, - snapshot: snapshots.capture, - dependencies: options.dependencies, - platform, - }), - resolveGestureViewport: async (context) => { - const viewport = await options.dependencies.resolveGestureViewport(context); - if (!viewport) { - throw new AppError('COMMAND_FAILED', 'Unable to resolve Maestro gesture viewport.'); - } - return viewport; - }, - - launchApp: async (input, context) => { - const appId = input.appId ?? context.appId; - const launchArgs = [ - ...launchArgumentValues(input.arguments), - ...launchArgumentValues(input.launchArguments), - ]; - const clearState = input.clearState === true; - const relaunch = !clearState && input.stopApp !== false; - await invokeMutation( - { - kind: 'launchApp', - ...(appId ? { appId } : {}), - relaunch, - clearState, - launchArgs, - }, - context, - 'deferred', - ); - }, - stopApp: async (input, context) => { - const appId = input.appId ?? context.appId; - await invokeMutation({ kind: 'stopApp', ...(appId ? { appId } : {}) }, context); - }, - openLink: async (input, context) => { - await invokeMutation( - { - kind: 'openLink', - ...(context.appId ? { appId: context.appId } : {}), - link: input.link, - prewarmRunner: platform === 'ios', - }, - context, - 'deferred', - ); - }, - - tapOn: async (input, context) => - await tapTargetAndSettle(options, snapshots, metrics, input.target, context, { - click: { - count: input.repeat, - intervalMs: input.delay, - }, - retryIfNoChange: input.retryTapIfNoChange === true, - }), - doubleTapOn: async (input, context) => { - await withMutation( - () => - clickTarget( - options, - input.target.point, - stripUndefined({ - doubleTap: true, - intervalMs: input.delay, - }), - ), - context, - 'deferred', - ); - }, - longPressOn: async (input, context) => { - await withMutation( - () => - clickTarget(options, input.target.point, { - holdMs: MAESTRO_COMPATIBILITY_PRESETS.command.longPressDurationMs, - }), - context, - 'deferred', - ); - }, - gesture: async (input, context) => { - const data = await invokeMutation( - { - kind: 'swipe', - gesture: input, - ...(context.gestureViewport ? { viewport: context.gestureViewport } : {}), - }, - context, - 'deferred', - ); - return data ? { data } : undefined; - }, - inputText: async (input, context) => await typeTextAndSettle(input.text, context), - eraseText: async (input, context) => - await typeTextAndSettle( - '\b'.repeat( - input.charactersToErase ?? MAESTRO_COMPATIBILITY_PRESETS.command.eraseTextMaxCharacters, - ), - context, - ), - scroll: async (input, context) => { - await invokeMutation({ kind: 'scroll', direction: input.direction }, context, 'deferred'); - }, - scrollUntilVisible: async (input, context) => { - const match = await scrollUntilTypedMaestroTarget({ - selector: input.selector, - direction: input.direction, - timeoutMs: input.timeoutMs, - context, - snapshot: snapshots.capture, - dependencies: options.dependencies, - platform, - scroll: async (remainingMs, snapshot) => { - const gesture = resolveMaestroScrollableGesture( - snapshot, - input.selector, - input.direction, - input.durationMs, - platform, - ); - await invokeMutation( - gesture - ? { kind: 'swipe', ...gesture } - : { - kind: 'scroll', - direction: input.direction, - durationMs: input.durationMs, - }, - context, - ); - return ( - await waitForTypedSnapshotStability({ - timeoutMs: Math.min(MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS, remainingMs), - context, - snapshot: snapshots.capture, - dependencies: options.dependencies, - }) - ).snapshot; - }, - }); - if ( - match.visiblePercentage !== - MAESTRO_COMPATIBILITY_PRESETS.command.scrollUntilVisiblePercentage - ) { - throw maestroTestFailure('Maestro scrollUntilVisible target did not become visible.', { - selector: input.selector, - timeoutMs: input.timeoutMs, - }); - } - return { - observation: snapshots.bindObservation(observationFromMatch(input.selector, match)), - }; - }, - pressKey: async (input, context) => { - await invokeMutation({ kind: 'pressKey', key: input.key }, context, 'deferred'); - }, - back: async (_input, context) => { - await invokeMutation({ kind: 'pressKey', key: 'back' }, context, 'deferred'); - }, - hideKeyboard: async (_input, context) => { - await invokeMutation({ kind: 'pressKey', key: 'dismiss' }, context, 'deferred'); - }, - waitForAnimationToEnd: async (input, context) => { - const visualStabilityReached = await waitForMaestroAnimationToEnd({ - timeoutMs: - input.timeoutMs ?? MAESTRO_COMPATIBILITY_PRESETS.command.waitForAnimationToEndTimeoutMs, - now: options.dependencies.now, - signal: context.signal, - capture: async (screenshotPath) => { - await invoke({ - kind: 'screenshot', - path: screenshotPath, - stabilize: false, - ...(options.platform === 'ios' ? { captureBackend: 'runner' as const } : {}), - }); - }, - }); - return { visualStabilityReached }; - }, - takeScreenshot: async (input) => ({ - artifactPaths: artifactPathsFromData(await invoke({ kind: 'screenshot', path: input.path })), - }), - runScript: async (input, context) => ({ - outputEnv: executeRunScriptFile({ - scriptPath: resolveScriptPath(input.file, context, options.sourcePath), - env: { - ...context.env, - ...(input.env ? stringifyEnvironment(input.env) : {}), - }, - }), - }), - }; - return { - operations, - snapshots, - readMetrics: () => ({ ...snapshots.readMetrics(), ...metrics }), - }; -} - -async function resolveDaemonMaestroTarget(params: { - input: MaestroTargetQuery & { timeoutMs: number }; - context: MaestroRuntimeReadContext; - snapshots: MaestroSnapshotSource; - options: CreateDaemonMaestroRuntimeOperationsOptions; - allowObservationReuse?: boolean; -}): Promise { - const { input, context, snapshots, options } = params; - const deadline = options.dependencies.now() + input.timeoutMs; - let currentSnapshot = - params.allowObservationReuse === false ? undefined : snapshots.reuseObservation(context); - while (true) { - const captureStartedAt = options.dependencies.now(); - const reusedObservation = currentSnapshot !== undefined; - currentSnapshot ??= await captureRetriableMaestroSnapshot( - { context, snapshot: snapshots.capture, dependencies: options.dependencies }, - deadline, - ); - const match = resolveTypedMaestroTarget({ - query: input, - context, - snapshot: currentSnapshot, - platform: options.platform, - }); - if (canUseResolvedTarget(match, reusedObservation)) return match; - currentSnapshot = undefined; - if (reusedObservation) continue; - if (captureStartedAt >= deadline) return match; - await sleepWithinDeadline( - options.dependencies, - deadline, - MAESTRO_OBSERVATION_POLL_MS, - context.signal, - ); - } -} - -function isActionableTarget( - match: MaestroTargetMatch, -): match is MaestroTargetMatch & { rect: Rect } { - return match.matched && match.visible && match.rect !== undefined; -} - -function canUseResolvedTarget(match: MaestroTargetMatch, reusedObservation: boolean): boolean { - if (!isActionableTarget(match)) return false; - return !reusedObservation || match.dispatchSelector !== undefined; -} - -export function createDaemonMaestroRuntimePort( - options: CreateDaemonMaestroRuntimeOperationsOptions, -): MaestroRuntimePort { - const { operations, snapshots, readMetrics } = createDaemonMaestroRuntimeParts(options); - return { - execute: async (request: MaestroRuntimeRequest): Promise => { - const context = operationContext(request, request.command); - const visualStabilityBarrier = request.command.kind === 'waitForAnimationToEnd'; - if (maestroCommandRequiresSettledPredecessor(request.command) && !visualStabilityBarrier) { - await snapshots.settlePending(context); - } - const result = await executeMaestroRuntimeCommand(request, operations); - if (visualStabilityBarrier && result.visualStabilityReached === true) { - snapshots.consumeStabilityFromVisualWait(context); - } - delete result.visualStabilityReached; - return result; - }, - observe: async (request) => { - const observation = await observeMaestroCondition(request, operations); - return snapshots.bindObservation(observation); - }, - readMetrics, - }; -} - -async function tapTargetAndSettle( - options: CreateDaemonMaestroRuntimeOperationsOptions, - snapshots: MaestroSnapshotSource, - metrics: { screenshotCaptures: number; tapRetries: number }, - target: Parameters[0]['target'], - context: MaestroRuntimeOperationContext, - policy: { click: MaestroClickOptions; retryIfNoChange: boolean }, -): Promise { - const dispatch = async () => - await dispatchTapTarget(options, snapshots, target, context, policy.click); - if (!policy.retryIfNoChange) { - try { - await dispatch(); - } finally { - snapshots.requireStability(context.generation); - } - return; - } - - if (options.platform === 'ios') { - // Maestro retry parity requires pre-tap pixels; runtime metrics expose this unavoidable capture. - await withMaestroScreenshotBaseline({ - signal: context.signal, - capture: async (path) => { - metrics.screenshotCaptures += 1; - await invokeMaestroPublicOperation(options, { - kind: 'screenshot', - path, - stabilize: false, - captureBackend: 'runner', - }); - }, - run: async (baseline) => - await tapTargetWithRetry( - options, - snapshots, - metrics, - target, - context, - policy.click, - baseline, - ), - }); - return; - } - - await tapTargetWithRetry(options, snapshots, metrics, target, context, policy.click); -} - -async function tapTargetWithRetry( - options: CreateDaemonMaestroRuntimeOperationsOptions, - snapshots: MaestroSnapshotSource, - metrics: { screenshotCaptures: number; tapRetries: number }, - target: Parameters[0]['target'], - context: MaestroRuntimeOperationContext, - flags: MaestroClickOptions, - screenshotBaseline?: MaestroScreenshotBaseline, -): Promise { - const dispatch = async () => await dispatchTapTarget(options, snapshots, target, context, flags); - const baselineSignature = await resolveTapBaselineSignature( - target, - snapshots, - context, - screenshotBaseline, - ); - const settle = async () => - await waitForTypedSnapshotStability({ - timeoutMs: MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS, - context, - snapshot: snapshots.capture, - dependencies: options.dependencies, - }); - - try { - const observed = await executeTapRetryLoop({ - baselineSignature, - screenshotBaseline, - dispatch, - settle, - onRetry: () => { - metrics.tapRetries += 1; - }, - }); - snapshots.prime(context.generation, observed.snapshot); - } catch (error) { - snapshots.requireStability(context.generation); - throw error; - } -} - -async function resolveTapBaselineSignature( - target: Parameters[0]['target'], - snapshots: MaestroSnapshotSource, - context: MaestroRuntimeOperationContext, - screenshotBaseline: MaestroScreenshotBaseline | undefined, -): Promise { - if (target.resolution?.surfaceSignature) return target.resolution.surfaceSignature; - if (screenshotBaseline) return undefined; - return maestroSnapshotSignature(await snapshots.capture(context)); -} - -async function executeTapRetryLoop(params: { - readonly baselineSignature?: string; - readonly screenshotBaseline?: MaestroScreenshotBaseline; - readonly dispatch: () => Promise; - readonly settle: () => ReturnType; - readonly onRetry: () => void; -}) { - await params.dispatch(); - let observed = await params.settle(); - let attempts = 1; - while ( - (params.baselineSignature === undefined || observed.signature === params.baselineSignature) && - attempts < MAESTRO_COMPATIBILITY_PRESETS.command.retryTapMaxAttempts - ) { - if (params.screenshotBaseline && (await params.screenshotBaseline.matchesCurrent()) !== true) { - break; - } - params.onRetry(); - await params.dispatch(); - attempts += 1; - observed = await params.settle(); - } - return observed; -} - -async function dispatchTapTarget( - options: CreateDaemonMaestroRuntimeOperationsOptions, - snapshots: MaestroSnapshotSource, - target: Parameters[0]['target'], - context: MaestroRuntimeOperationContext, - flags: MaestroClickOptions, -): Promise { - const resolution = target.resolution; - const dispatchSelector = resolution?.dispatchSelector; - if (dispatchSelector && target.point) { - snapshots.invalidate(context.generation); - try { - await clickSelector(options, dispatchSelector, target.point, flags); - return; - } catch (error) { - if (!isAtomicSelectorFallbackError(error)) throw error; - const refreshed = await resolveDaemonMaestroTarget({ - input: resolution.query, - context, - snapshots, - options, - allowObservationReuse: false, - }); - if (!isActionableTarget(refreshed)) throw error; - snapshots.invalidate(context.generation); - await clickTarget(options, pointInsideRect(refreshed.rect), flags); - return; - } - } - snapshots.invalidate(context.generation); - await clickTarget(options, target.point, flags); -} - -async function clickSelector( - options: CreateDaemonMaestroRuntimeOperationsOptions, - selector: MaestroDispatchSelector, - expectedPoint: { x: number; y: number }, - flags: MaestroClickOptions, -): Promise { - emitDiagnostic({ - level: 'debug', - phase: 'maestro_tap_dispatch', - data: { kind: 'selector', selectorKey: selector.key, expectedPoint }, - }); - await invokeMaestroPublicOperation(options, { - kind: 'clickSelector', - selector, - expectedPoint, - options: flags, - }); -} - -function isAtomicSelectorFallbackError(error: unknown): boolean { - const code = asAppError(error).code; - return code === 'AMBIGUOUS_MATCH' || code === 'ELEMENT_NOT_FOUND' || code === 'ELEMENT_OFFSCREEN'; -} - -async function clickTarget( - options: CreateDaemonMaestroRuntimeOperationsOptions, - point: { x: number; y: number } | undefined, - flags: MaestroClickOptions, -): Promise { - if (!point) throw new AppError('COMMAND_FAILED', 'Maestro target did not resolve to a point.'); - emitDiagnostic({ - level: 'debug', - phase: 'maestro_tap_dispatch', - data: { kind: 'point', point }, - }); - await invokeMaestroPublicOperation(options, { - kind: 'clickPoint', - point, - options: flags, - }); -} diff --git a/src/compat/maestro/__tests__/daemon-runtime-port-fixtures.ts b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-fixtures.ts similarity index 94% rename from src/compat/maestro/__tests__/daemon-runtime-port-fixtures.ts rename to src/daemon/adapters/maestro/__tests__/daemon-runtime-port-fixtures.ts index 33691489f3..7c6f0dde53 100644 --- a/src/compat/maestro/__tests__/daemon-runtime-port-fixtures.ts +++ b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-fixtures.ts @@ -1,4 +1,4 @@ -import type { DaemonRequest } from '../../../daemon/types.ts'; +import type { DaemonRequest } from '../../../types.ts'; import type { SnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot'; import type { CreateDaemonMaestroRuntimeOperationsOptions } from '../daemon-runtime-port.ts'; diff --git a/src/compat/maestro/__tests__/daemon-runtime-port-mutations.test.ts b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-mutations.test.ts similarity index 97% rename from src/compat/maestro/__tests__/daemon-runtime-port-mutations.test.ts rename to src/daemon/adapters/maestro/__tests__/daemon-runtime-port-mutations.test.ts index a58185d4f4..7919511728 100644 --- a/src/compat/maestro/__tests__/daemon-runtime-port-mutations.test.ts +++ b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-mutations.test.ts @@ -1,8 +1,8 @@ import { expect, test } from 'vitest'; -import type { DaemonRequest } from '../../../daemon/types.ts'; +import type { MaestroRuntimeCommand } from '@agent-device/maestro'; +import type { DaemonRequest } from '../../../types.ts'; import { createDaemonMaestroRuntimePort } from '../daemon-runtime-port.ts'; import { MAESTRO_OBSERVATION_POLL_MS } from '../daemon-runtime-port-observation.ts'; -import type { MaestroRuntimeCommand } from '../engine-types.ts'; import { makeBaseRequest, makeDependencies } from './daemon-runtime-port-fixtures.ts'; test.each([ diff --git a/src/compat/maestro/__tests__/daemon-runtime-port-observation-input.test.ts b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-observation-input.test.ts similarity index 98% rename from src/compat/maestro/__tests__/daemon-runtime-port-observation-input.test.ts rename to src/daemon/adapters/maestro/__tests__/daemon-runtime-port-observation-input.test.ts index 1ae21b82b6..f7c014ce86 100644 --- a/src/compat/maestro/__tests__/daemon-runtime-port-observation-input.test.ts +++ b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-observation-input.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest'; -import type { DaemonRequest } from '../../../daemon/types.ts'; +import type { DaemonRequest } from '../../../types.ts'; import { createDaemonMaestroRuntimePort } from '../daemon-runtime-port.ts'; import { makeBaseRequest, makeDependencies } from './daemon-runtime-port-fixtures.ts'; diff --git a/src/compat/maestro/__tests__/daemon-runtime-port-observation-recovery.test.ts b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-observation-recovery.test.ts similarity index 98% rename from src/compat/maestro/__tests__/daemon-runtime-port-observation-recovery.test.ts rename to src/daemon/adapters/maestro/__tests__/daemon-runtime-port-observation-recovery.test.ts index 3152a34c83..ec6fc8a07c 100644 --- a/src/compat/maestro/__tests__/daemon-runtime-port-observation-recovery.test.ts +++ b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-observation-recovery.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest'; -import type { DaemonRequest } from '../../../daemon/types.ts'; +import type { DaemonRequest } from '../../../types.ts'; import { createDaemonMaestroRuntimePort } from '../daemon-runtime-port.ts'; import { MAESTRO_OBSERVATION_POLL_MS } from '../daemon-runtime-port-observation.ts'; import { makeBaseRequest, makeDependencies } from './daemon-runtime-port-fixtures.ts'; diff --git a/src/compat/maestro/__tests__/daemon-runtime-port-observation-scroll.test.ts b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-observation-scroll.test.ts similarity index 99% rename from src/compat/maestro/__tests__/daemon-runtime-port-observation-scroll.test.ts rename to src/daemon/adapters/maestro/__tests__/daemon-runtime-port-observation-scroll.test.ts index 015bb4fd6f..c5da2afa57 100644 --- a/src/compat/maestro/__tests__/daemon-runtime-port-observation-scroll.test.ts +++ b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-observation-scroll.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest'; -import type { DaemonInvokeFn, DaemonRequest } from '../../../daemon/types.ts'; +import type { DaemonInvokeFn, DaemonRequest } from '../../../types.ts'; import { createDaemonMaestroRuntimePort } from '../daemon-runtime-port.ts'; import { makeBaseRequest, makeDependencies } from './daemon-runtime-port-fixtures.ts'; diff --git a/src/compat/maestro/__tests__/daemon-runtime-port-observation.test.ts b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-observation.test.ts similarity index 96% rename from src/compat/maestro/__tests__/daemon-runtime-port-observation.test.ts rename to src/daemon/adapters/maestro/__tests__/daemon-runtime-port-observation.test.ts index c1c06adb4a..47f9fb7260 100644 --- a/src/compat/maestro/__tests__/daemon-runtime-port-observation.test.ts +++ b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-observation.test.ts @@ -1,8 +1,11 @@ import { expect, test } from 'vitest'; -import type { DaemonRequest } from '../../../daemon/types.ts'; +import { + executeMaestroFlow, + inspectMaestroFlow, + type MaestroObservation, +} from '@agent-device/maestro'; +import type { DaemonRequest } from '../../../types.ts'; import { createDaemonMaestroRuntimePort } from '../daemon-runtime-port.ts'; -import type { MaestroObservation } from '../engine-types.ts'; -import { parseMaestroProgram } from '../program-ir-parser.ts'; import { MAESTRO_OBSERVATION_POLL_MS, maestroSnapshotSignature, @@ -10,7 +13,6 @@ import { waitForTypedSnapshotStability, } from '../daemon-runtime-port-observation.ts'; import { makeBaseRequest, makeDependencies, makeSnapshot } from './daemon-runtime-port-fixtures.ts'; -import { executeMaestroProgram } from './runtime-port-fixtures.ts'; test('replaces pre-mutation evidence with the stable post-mutation snapshot', async () => { const requests: DaemonRequest[] = []; @@ -339,7 +341,7 @@ test('assertVisible and assertNotVisible scope duplicate matching children by ch return result; }; - const program = parseMaestroProgram( + const flow = inspectMaestroFlow( [ 'appId: com.example.app', '---', @@ -352,11 +354,12 @@ test('assertVisible and assertNotVisible scope duplicate matching children by ch ' childOf:', ' text: parent_id_3', ].join('\n'), + '/flows/observation.yaml', ); - const result = await executeMaestroProgram(program, port); + const result = await executeMaestroFlow(flow, port); - expect(result).toMatchObject({ executed: 2, skipped: 0 }); + expect(result).toMatchObject({ ok: true, replayed: 2 }); expect(observations).toHaveLength(2); const visible = observations[0]!; diff --git a/src/compat/maestro/__tests__/daemon-runtime-port-snapshot-source.test.ts b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-snapshot-source.test.ts similarity index 98% rename from src/compat/maestro/__tests__/daemon-runtime-port-snapshot-source.test.ts rename to src/daemon/adapters/maestro/__tests__/daemon-runtime-port-snapshot-source.test.ts index 58e8ec0320..e1665bca8b 100644 --- a/src/compat/maestro/__tests__/daemon-runtime-port-snapshot-source.test.ts +++ b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-snapshot-source.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest'; -import type { DaemonRequest } from '../../../daemon/types.ts'; +import type { DaemonRequest } from '../../../types.ts'; import { MAESTRO_OBSERVATION_POLL_MS } from '../daemon-runtime-port-observation.ts'; import { createDaemonMaestroSnapshotSource } from '../daemon-runtime-port-snapshot-source.ts'; import { makeBaseRequest, makeDependencies } from './daemon-runtime-port-fixtures.ts'; diff --git a/src/compat/maestro/__tests__/daemon-runtime-port-support.test.ts b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-support.test.ts similarity index 100% rename from src/compat/maestro/__tests__/daemon-runtime-port-support.test.ts rename to src/daemon/adapters/maestro/__tests__/daemon-runtime-port-support.test.ts diff --git a/src/compat/maestro/__tests__/daemon-runtime-port-target-geometry.test.ts b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-target-geometry.test.ts similarity index 99% rename from src/compat/maestro/__tests__/daemon-runtime-port-target-geometry.test.ts rename to src/daemon/adapters/maestro/__tests__/daemon-runtime-port-target-geometry.test.ts index a9f223a15b..3ae6f7f94c 100644 --- a/src/compat/maestro/__tests__/daemon-runtime-port-target-geometry.test.ts +++ b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-target-geometry.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest'; -import type { DaemonRequest } from '../../../daemon/types.ts'; +import type { DaemonRequest } from '../../../types.ts'; import { createDaemonMaestroRuntimePort } from '../daemon-runtime-port.ts'; import { makeBaseRequest, makeDependencies } from './daemon-runtime-port-fixtures.ts'; diff --git a/src/compat/maestro/__tests__/daemon-runtime-port-target-refresh.test.ts b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-target-refresh.test.ts similarity index 98% rename from src/compat/maestro/__tests__/daemon-runtime-port-target-refresh.test.ts rename to src/daemon/adapters/maestro/__tests__/daemon-runtime-port-target-refresh.test.ts index cbc0021e7f..567f550395 100644 --- a/src/compat/maestro/__tests__/daemon-runtime-port-target-refresh.test.ts +++ b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-target-refresh.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest'; -import type { DaemonInvokeFn, DaemonRequest } from '../../../daemon/types.ts'; +import type { DaemonInvokeFn, DaemonRequest } from '../../../types.ts'; import { createDaemonMaestroRuntimePort } from '../daemon-runtime-port.ts'; import { makeBaseRequest, makeDependencies } from './daemon-runtime-port-fixtures.ts'; diff --git a/src/compat/maestro/__tests__/daemon-runtime-port-targets.test.ts b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-targets.test.ts similarity index 98% rename from src/compat/maestro/__tests__/daemon-runtime-port-targets.test.ts rename to src/daemon/adapters/maestro/__tests__/daemon-runtime-port-targets.test.ts index 5ca9030843..6d2de1f11a 100644 --- a/src/compat/maestro/__tests__/daemon-runtime-port-targets.test.ts +++ b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port-targets.test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest'; import { promises as fs } from 'node:fs'; -import { PNG } from '../../../utils/png.ts'; -import type { DaemonInvokeFn, DaemonRequest } from '../../../daemon/types.ts'; +import { PNG } from '../../../../utils/png.ts'; +import type { DaemonInvokeFn, DaemonRequest } from '../../../types.ts'; import { createDaemonMaestroRuntimePort } from '../daemon-runtime-port.ts'; import { makeBaseRequest, makeDependencies } from './daemon-runtime-port-fixtures.ts'; diff --git a/src/compat/maestro/__tests__/daemon-runtime-port.test.ts b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port.test.ts similarity index 97% rename from src/compat/maestro/__tests__/daemon-runtime-port.test.ts rename to src/daemon/adapters/maestro/__tests__/daemon-runtime-port.test.ts index 3c14f46a00..43d13b9f85 100644 --- a/src/compat/maestro/__tests__/daemon-runtime-port.test.ts +++ b/src/daemon/adapters/maestro/__tests__/daemon-runtime-port.test.ts @@ -2,17 +2,16 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; -import type { DaemonInvokeFn, DaemonRequest } from '../../../daemon/types.ts'; -import { PNG } from '../../../utils/png.ts'; +import { executeMaestroFlow, inspectMaestroFlow } from '@agent-device/maestro'; +import type { DaemonInvokeFn, DaemonRequest } from '../../../types.ts'; +import { PNG } from '../../../../utils/png.ts'; import { emitDiagnostic, flushDiagnosticsToSessionFile, withDiagnosticsScope, -} from '../../../utils/diagnostics.ts'; +} from '../../../../utils/diagnostics.ts'; import { createDaemonMaestroRuntimePort } from '../daemon-runtime-port.ts'; import { MAESTRO_OBSERVATION_POLL_MS } from '../daemon-runtime-port-observation.ts'; -import { parseMaestroProgram } from '../program-ir-parser.ts'; -import { executeMaestroProgram } from './runtime-port-fixtures.ts'; import { makeBaseRequest, makeDependencies, makeSnapshot } from './daemon-runtime-port-fixtures.ts'; test('registers Maestro inputText as sensitive before nested platform work', async () => { @@ -664,7 +663,7 @@ test('waitForAnimationToEnd between two taps does not throw a stability-generati platform: 'ios', }); - const program = parseMaestroProgram( + const flow = inspectMaestroFlow( [ 'appId: com.callstack.agentdevicelab', '---', @@ -674,11 +673,12 @@ test('waitForAnimationToEnd between two taps does not throw a stability-generati '- tapOn:', ' text: Catalog', ].join('\n'), + '/flows/settled.yaml', ); - const result = await executeMaestroProgram(program, port); + const result = await executeMaestroFlow(flow, port); - expect(result).toMatchObject({ executed: 3, skipped: 0, generation: 2 }); + expect(result).toMatchObject({ ok: true, replayed: 3 }); expect(requests.map(({ command }) => command)).toEqual([ 'snapshot', 'click', @@ -736,7 +736,7 @@ test('timed-out waitForAnimationToEnd retains the pending hierarchy settle', asy platform: 'ios', }); - const program = parseMaestroProgram( + const flow = inspectMaestroFlow( [ 'appId: com.callstack.agentdevicelab', '---', @@ -746,11 +746,12 @@ test('timed-out waitForAnimationToEnd retains the pending hierarchy settle', asy '- tapOn:', ' text: Catalog', ].join('\n'), + '/flows/timed-out.yaml', ); - const result = await executeMaestroProgram(program, port); + const result = await executeMaestroFlow(flow, port); - expect(result).toMatchObject({ executed: 3, skipped: 0, generation: 2 }); + expect(result).toMatchObject({ ok: true, replayed: 3 }); expect(clock.value).toBe(MAESTRO_OBSERVATION_POLL_MS); expect(requests.map(({ command }) => command)).toEqual([ 'snapshot', diff --git a/src/compat/maestro/__tests__/daemon-runtime-public-operation.test.ts b/src/daemon/adapters/maestro/__tests__/daemon-runtime-public-operation.test.ts similarity index 100% rename from src/compat/maestro/__tests__/daemon-runtime-public-operation.test.ts rename to src/daemon/adapters/maestro/__tests__/daemon-runtime-public-operation.test.ts diff --git a/src/compat/maestro/__tests__/run-script.test.ts b/src/daemon/adapters/maestro/__tests__/run-script.test.ts similarity index 100% rename from src/compat/maestro/__tests__/run-script.test.ts rename to src/daemon/adapters/maestro/__tests__/run-script.test.ts diff --git a/src/compat/maestro/__tests__/wait-for-animation-to-end.test.ts b/src/daemon/adapters/maestro/__tests__/wait-for-animation-to-end.test.ts similarity index 98% rename from src/compat/maestro/__tests__/wait-for-animation-to-end.test.ts rename to src/daemon/adapters/maestro/__tests__/wait-for-animation-to-end.test.ts index 1a6f736b47..f1d1a5d345 100644 --- a/src/compat/maestro/__tests__/wait-for-animation-to-end.test.ts +++ b/src/daemon/adapters/maestro/__tests__/wait-for-animation-to-end.test.ts @@ -1,7 +1,7 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { expect, test } from 'vitest'; -import { PNG } from '../../../utils/png.ts'; +import { PNG } from '../../../../utils/png.ts'; import { waitForMaestroAnimationToEnd } from '../wait-for-animation-to-end.ts'; test('captures an explicit zero-timeout pair and accepts exactly 0.005% RGB difference', async () => { diff --git a/src/compat/maestro/daemon-runtime-port-observation.ts b/src/daemon/adapters/maestro/daemon-runtime-port-observation.ts similarity index 89% rename from src/compat/maestro/daemon-runtime-port-observation.ts rename to src/daemon/adapters/maestro/daemon-runtime-port-observation.ts index 1704df11a0..149f924eac 100644 --- a/src/compat/maestro/daemon-runtime-port-observation.ts +++ b/src/daemon/adapters/maestro/daemon-runtime-port-observation.ts @@ -1,31 +1,30 @@ import { createHash } from 'node:crypto'; -import { createRequestCanceledError } from '../../request/cancel.ts'; +import { + literalFromMaestroRegex, + maestroObservationMatches, + MAESTRO_COMPATIBILITY_PRESETS, + resolveMaestroTargetFromSnapshot, + type MaestroDispatchSelector, + type MaestroObservation, + type MaestroObservationCondition, + type MaestroPlatform, + type MaestroRuntimeMetrics, + type MaestroRuntimeReadContext, + type MaestroSelector, + type MaestroSnapshotTargetQuery, + type MaestroTargetMatch, + type MaestroTargetQuery, +} from '@agent-device/maestro'; +import { attachRefs } from '@agent-device/kernel/snapshot'; +import { createRequestCanceledError } from '../../../request/cancel.ts'; import { getSnapshotReferenceFrame, type TouchReferenceFrame, -} from '../../daemon/touch-reference-frame.ts'; +} from '../../touch-reference-frame.ts'; import { AppError } from '@agent-device/kernel/errors'; import { isPositiveFiniteRect, rectContains } from '@agent-device/kernel/rect'; import type { Rect, SnapshotState } from '@agent-device/kernel/snapshot'; -import type { - MaestroObservation, - MaestroObservationCondition, - MaestroRuntimeMetrics, -} from './engine-types.ts'; -import { MAESTRO_COMPATIBILITY_PRESETS } from './compatibility-policy.ts'; -import type { MaestroPlatform, MaestroSelector } from './program-ir.ts'; -import { literalFromMaestroRegex } from './selector-regex.ts'; -import { - resolveMaestroTargetFromSnapshot, - type MaestroTargetQuery as SnapshotTargetQuery, -} from './runtime-targets.ts'; -import type { - MaestroDispatchSelector, - MaestroRuntimeReadContext, - MaestroTargetMatch, - MaestroTargetQuery, -} from './runtime-port-types.ts'; -import { maestroObservationMatches } from './runtime-port-observation.ts'; +import { buildIosInteractiveSnapshotPresentation } from '../../snapshot-presentation/ios/index.ts'; export const MAESTRO_OBSERVATION_POLL_MS = MAESTRO_COMPATIBILITY_PRESETS.observation.pollIntervalMs; export type DaemonMaestroRuntimeDependencies = { @@ -93,18 +92,36 @@ export function resolveTypedMaestroTarget(params: { } function resolveTargetFromSnapshot(params: { - readonly query: SnapshotTargetQuery & { readonly includeSurfaceSignature?: boolean }; + readonly query: MaestroSnapshotTargetQuery & { + readonly includeSurfaceSignature?: boolean; + }; readonly context: MaestroRuntimeReadContext; readonly snapshot: SnapshotState; readonly platform: Extract; readonly mode: MaestroTargetResolutionMode; }): MaestroTargetMatch { const frame = getSnapshotReferenceFrame(params.snapshot); + const presentation = + params.platform === 'ios' + ? buildIosInteractiveSnapshotPresentation(params.snapshot.nodes) + : undefined; const resolution = resolveMaestroTargetFromSnapshot( params.snapshot, params.query, params.platform, - { interactiveBounds: params.mode === 'tap' }, + { + interactiveBounds: params.mode === 'tap', + interactiveRects: presentation + ? new Map( + [...presentation.sourceNodes].flatMap(([index, node]) => + node.rect ? [[index, node.rect] as const] : [], + ), + ) + : undefined, + canonicalSnapshot: presentation + ? { ...params.snapshot, nodes: attachRefs(presentation.nodes) } + : undefined, + }, ); return targetMatchFromResolution( resolution, @@ -243,7 +260,7 @@ function targetMatchFromResolution( snapshot: SnapshotState, generation: number, frame: TouchReferenceFrame | undefined, - query: SnapshotTargetQuery & { includeSurfaceSignature?: boolean }, + query: MaestroSnapshotTargetQuery & { includeSurfaceSignature?: boolean }, platform: Extract, mode: MaestroTargetResolutionMode, ): MaestroTargetMatch { @@ -297,7 +314,7 @@ function visibleScreenPercentage(rect: Rect, viewport: Rect): number { } function resolveAtomicIosDispatchSelector(params: { - query: SnapshotTargetQuery; + query: MaestroSnapshotTargetQuery; platform: Extract; mode: MaestroTargetResolutionMode; dispatchCandidates: number; @@ -308,7 +325,7 @@ function resolveAtomicIosDispatchSelector(params: { } function allowsAtomicIosDispatch( - query: SnapshotTargetQuery, + query: MaestroSnapshotTargetQuery, platform: Extract, mode: MaestroTargetResolutionMode, ): boolean { diff --git a/src/compat/maestro/daemon-runtime-port-snapshot-source.ts b/src/daemon/adapters/maestro/daemon-runtime-port-snapshot-source.ts similarity index 96% rename from src/compat/maestro/daemon-runtime-port-snapshot-source.ts rename to src/daemon/adapters/maestro/daemon-runtime-port-snapshot-source.ts index 7b33ed82fa..878b367927 100644 --- a/src/compat/maestro/daemon-runtime-port-snapshot-source.ts +++ b/src/daemon/adapters/maestro/daemon-runtime-port-snapshot-source.ts @@ -1,7 +1,9 @@ import { AppError } from '@agent-device/kernel/errors'; import type { SnapshotState } from '@agent-device/kernel/snapshot'; -import type { MaestroObservationIdentity } from './engine-types.ts'; -import { MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS } from './compatibility-policy.ts'; +import { + MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS, + type MaestroObservationIdentity, +} from '@agent-device/maestro'; import { waitForTypedSnapshotStability, type MaestroSnapshotReader, diff --git a/src/compat/maestro/daemon-runtime-port-support.ts b/src/daemon/adapters/maestro/daemon-runtime-port-support.ts similarity index 95% rename from src/compat/maestro/daemon-runtime-port-support.ts rename to src/daemon/adapters/maestro/daemon-runtime-port-support.ts index dd31a5f595..2527a9188f 100644 --- a/src/compat/maestro/daemon-runtime-port-support.ts +++ b/src/daemon/adapters/maestro/daemon-runtime-port-support.ts @@ -1,23 +1,23 @@ import path from 'node:path'; -import type { CommandFlags } from '../../core/dispatch.ts'; +import type { + MaestroObservation, + MaestroPlatform, + MaestroRuntimeOperationContext, + MaestroRuntimeReadContext, + MaestroTargetMatch, + MaestroTargetQuery, +} from '@agent-device/maestro'; +import type { CommandFlags } from '../../../core/dispatch.ts'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse, DaemonResponseData, -} from '../../daemon/types.ts'; +} from '../../types.ts'; import { AppError } from '@agent-device/kernel/errors'; -import type { MaestroPlatform } from './program-ir.ts'; -import type { MaestroObservation } from './engine-types.ts'; -import type { - MaestroRuntimeOperationContext, - MaestroRuntimeReadContext, - MaestroTargetMatch, - MaestroTargetQuery, -} from './runtime-port-types.ts'; import type { Rect } from '@agent-device/kernel/snapshot'; import type { DaemonMaestroRuntimeDependencies } from './daemon-runtime-port-observation.ts'; -import { stripUndefined } from '../../utils/parsing.ts'; +import { stripUndefined } from '../../../utils/parsing.ts'; import { projectMaestroPublicOperation, type MaestroPublicOperation, diff --git a/src/daemon/adapters/maestro/daemon-runtime-port.ts b/src/daemon/adapters/maestro/daemon-runtime-port.ts new file mode 100644 index 0000000000..b883752f13 --- /dev/null +++ b/src/daemon/adapters/maestro/daemon-runtime-port.ts @@ -0,0 +1,314 @@ +import { AppError } from '@agent-device/kernel/errors'; +import { + createMaestroRuntimePort, + maestroTestFailure, + MAESTRO_COMPATIBILITY_PRESETS, + MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS, + resolveMaestroScrollableGesture, + type MaestroRuntimeMetrics, + type MaestroRuntimeOperationContext, + type MaestroRuntimeOperations, + type MaestroRuntimePort, +} from '@agent-device/maestro'; +import { registerDiagnosticSensitiveValue } from '../../../utils/diagnostics.ts'; +import { stripUndefined } from '../../../utils/parsing.ts'; +import { executeRunScriptFile } from './run-script-execution.ts'; +import { waitForMaestroAnimationToEnd } from './wait-for-animation-to-end.ts'; +import { + observeTypedMaestroCondition, + scrollUntilTypedMaestroTarget, + waitForTypedSnapshotStability, + type MaestroSnapshotSource, +} from './daemon-runtime-port-observation.ts'; +import { createDaemonMaestroSnapshotSource } from './daemon-runtime-port-snapshot-source.ts'; +import { + artifactPathsFromData, + invokeMaestroPublicOperation, + launchArgumentValues, + observationFromMatch, + resolveScriptPath, + stringifyEnvironment, + type CreateDaemonMaestroRuntimeOperationsOptions, +} from './daemon-runtime-port-support.ts'; +import type { MaestroPublicOperation } from './daemon-runtime-public-operation.ts'; +import { + clickMaestroTargetPoint, + resolveDaemonMaestroTarget, + tapTargetAndSettle, +} from './daemon-runtime-tap.ts'; + +export type { CreateDaemonMaestroRuntimeOperationsOptions } from './daemon-runtime-port-support.ts'; + +function createDaemonMaestroRuntimeParts(options: CreateDaemonMaestroRuntimeOperationsOptions): { + operations: MaestroRuntimeOperations; + snapshots: MaestroSnapshotSource; + readMetrics: () => MaestroRuntimeMetrics; +} { + const snapshots = createDaemonMaestroSnapshotSource(options); + const metrics = { screenshotCaptures: 0, tapRetries: 0 }; + const platform = options.platform; + const invoke = (operation: MaestroPublicOperation) => { + if (operation.kind === 'screenshot') metrics.screenshotCaptures += 1; + return invokeMaestroPublicOperation(options, operation); + }; + const withMutation = async ( + mutation: () => Promise, + context: MaestroRuntimeOperationContext, + stability: 'none' | 'deferred' = 'none', + ): Promise => { + snapshots.invalidate(context.generation); + try { + return await mutation(); + } finally { + if (stability === 'deferred') snapshots.requireStability(context.generation); + } + }; + const invokeMutation = async ( + operation: MaestroPublicOperation, + context: MaestroRuntimeOperationContext, + stability: 'none' | 'deferred' = 'none', + ) => await withMutation(() => invoke(operation), context, stability); + const typeTextAndSettle = async ( + text: string, + context: MaestroRuntimeOperationContext, + ): Promise => { + registerDiagnosticSensitiveValue(text); + await invokeMutation({ kind: 'typeText', text }, context); + const stable = await waitForTypedSnapshotStability({ + timeoutMs: MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS, + context, + snapshot: snapshots.capture, + dependencies: options.dependencies, + }); + snapshots.prime(context.generation, stable.snapshot); + }; + + const operations: MaestroRuntimeOperations = { + platform, + resolveTarget: async (input, context) => + await resolveDaemonMaestroTarget({ input, context, snapshots, options }), + observe: async (input, context) => + await observeTypedMaestroCondition({ + condition: input.condition, + timeoutMs: input.timeoutMs, + context, + snapshot: snapshots.capture, + dependencies: options.dependencies, + platform, + }), + resolveGestureViewport: async (context) => { + const viewport = await options.dependencies.resolveGestureViewport(context); + if (!viewport) { + throw new AppError('COMMAND_FAILED', 'Unable to resolve Maestro gesture viewport.'); + } + return viewport; + }, + + launchApp: async (input, context) => { + const appId = input.appId ?? context.appId; + const launchArgs = [ + ...launchArgumentValues(input.arguments), + ...launchArgumentValues(input.launchArguments), + ]; + const clearState = input.clearState === true; + const relaunch = !clearState && input.stopApp !== false; + await invokeMutation( + { + kind: 'launchApp', + ...(appId ? { appId } : {}), + relaunch, + clearState, + launchArgs, + }, + context, + 'deferred', + ); + }, + stopApp: async (input, context) => { + const appId = input.appId ?? context.appId; + await invokeMutation({ kind: 'stopApp', ...(appId ? { appId } : {}) }, context); + }, + openLink: async (input, context) => { + await invokeMutation( + { + kind: 'openLink', + ...(context.appId ? { appId: context.appId } : {}), + link: input.link, + prewarmRunner: platform === 'ios', + }, + context, + 'deferred', + ); + }, + + tapOn: async (input, context) => + await tapTargetAndSettle(options, snapshots, metrics, input.target, context, { + click: { + count: input.repeat, + intervalMs: input.delay, + }, + retryIfNoChange: input.retryTapIfNoChange === true, + }), + doubleTapOn: async (input, context) => { + await withMutation( + () => + clickMaestroTargetPoint( + options, + input.target.point, + stripUndefined({ + doubleTap: true, + intervalMs: input.delay, + }), + ), + context, + 'deferred', + ); + }, + longPressOn: async (input, context) => { + await withMutation( + () => + clickMaestroTargetPoint(options, input.target.point, { + holdMs: MAESTRO_COMPATIBILITY_PRESETS.command.longPressDurationMs, + }), + context, + 'deferred', + ); + }, + gesture: async (input, context) => { + const data = await invokeMutation( + { + kind: 'swipe', + gesture: input, + ...(context.gestureViewport ? { viewport: context.gestureViewport } : {}), + }, + context, + 'deferred', + ); + return data ? { data } : undefined; + }, + inputText: async (input, context) => await typeTextAndSettle(input.text, context), + eraseText: async (input, context) => + await typeTextAndSettle( + '\b'.repeat( + input.charactersToErase ?? MAESTRO_COMPATIBILITY_PRESETS.command.eraseTextMaxCharacters, + ), + context, + ), + scroll: async (input, context) => { + await invokeMutation({ kind: 'scroll', direction: input.direction }, context, 'deferred'); + }, + scrollUntilVisible: async (input, context) => { + const match = await scrollUntilTypedMaestroTarget({ + selector: input.selector, + direction: input.direction, + timeoutMs: input.timeoutMs, + context, + snapshot: snapshots.capture, + dependencies: options.dependencies, + platform, + scroll: async (remainingMs, snapshot) => { + const gesture = resolveMaestroScrollableGesture( + snapshot, + input.selector, + input.direction, + input.durationMs, + platform, + ); + await invokeMutation( + gesture + ? { kind: 'swipe', ...gesture } + : { + kind: 'scroll', + direction: input.direction, + durationMs: input.durationMs, + }, + context, + ); + return ( + await waitForTypedSnapshotStability({ + timeoutMs: Math.min(MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS, remainingMs), + context, + snapshot: snapshots.capture, + dependencies: options.dependencies, + }) + ).snapshot; + }, + }); + if ( + match.visiblePercentage !== + MAESTRO_COMPATIBILITY_PRESETS.command.scrollUntilVisiblePercentage + ) { + throw maestroTestFailure('Maestro scrollUntilVisible target did not become visible.', { + selector: input.selector, + timeoutMs: input.timeoutMs, + }); + } + return { + observation: snapshots.bindObservation(observationFromMatch(input.selector, match)), + }; + }, + pressKey: async (input, context) => { + await invokeMutation({ kind: 'pressKey', key: input.key }, context, 'deferred'); + }, + back: async (_input, context) => { + await invokeMutation({ kind: 'pressKey', key: 'back' }, context, 'deferred'); + }, + hideKeyboard: async (_input, context) => { + await invokeMutation({ kind: 'pressKey', key: 'dismiss' }, context, 'deferred'); + }, + waitForAnimationToEnd: async (input, context) => { + const visualStabilityReached = await waitForMaestroAnimationToEnd({ + timeoutMs: + input.timeoutMs ?? MAESTRO_COMPATIBILITY_PRESETS.command.waitForAnimationToEndTimeoutMs, + now: options.dependencies.now, + signal: context.signal, + capture: async (screenshotPath) => { + await invoke({ + kind: 'screenshot', + path: screenshotPath, + stabilize: false, + ...(options.platform === 'ios' ? { captureBackend: 'runner' as const } : {}), + }); + }, + }); + return { visualStabilityReached }; + }, + takeScreenshot: async (input) => ({ + artifactPaths: artifactPathsFromData(await invoke({ kind: 'screenshot', path: input.path })), + }), + runScript: async (input, context) => ({ + outputEnv: executeRunScriptFile({ + scriptPath: resolveScriptPath(input.file, context, options.sourcePath), + env: { + ...context.env, + ...(input.env ? stringifyEnvironment(input.env) : {}), + }, + }), + }), + }; + return { + operations, + snapshots, + readMetrics: () => ({ ...snapshots.readMetrics(), ...metrics }), + }; +} + +export function createDaemonMaestroRuntimePort( + options: CreateDaemonMaestroRuntimeOperationsOptions, +): MaestroRuntimePort { + const { operations, snapshots, readMetrics } = createDaemonMaestroRuntimeParts(options); + return createMaestroRuntimePort(operations, { + beforeExecute: async ({ context, requiresSettledPredecessor }) => { + if (requiresSettledPredecessor) { + await snapshots.settlePending(context); + } + }, + afterExecute: ({ context, visualStabilityReached }) => { + if (visualStabilityReached) { + snapshots.consumeStabilityFromVisualWait(context); + } + }, + bindObservation: snapshots.bindObservation, + readMetrics, + }); +} diff --git a/src/compat/maestro/daemon-runtime-public-operation.ts b/src/daemon/adapters/maestro/daemon-runtime-public-operation.ts similarity index 97% rename from src/compat/maestro/daemon-runtime-public-operation.ts rename to src/daemon/adapters/maestro/daemon-runtime-public-operation.ts index 778807e340..9b3ecfa972 100644 --- a/src/compat/maestro/daemon-runtime-public-operation.ts +++ b/src/daemon/adapters/maestro/daemon-runtime-public-operation.ts @@ -1,10 +1,10 @@ -import type { CommandFlags } from '../../core/dispatch.ts'; -import type { DaemonRequest } from '../../daemon/types.ts'; -import type { Point, Rect } from '@agent-device/kernel/snapshot'; import type { MaestroDispatchSelector, MaestroSinglePointerGestureInput, -} from './runtime-port-types.ts'; +} from '@agent-device/maestro'; +import type { CommandFlags } from '../../../core/dispatch.ts'; +import type { DaemonRequest } from '../../types.ts'; +import type { Point, Rect } from '@agent-device/kernel/snapshot'; export type MaestroClickOptions = Pick< CommandFlags, diff --git a/src/daemon/adapters/maestro/daemon-runtime-tap.ts b/src/daemon/adapters/maestro/daemon-runtime-tap.ts new file mode 100644 index 0000000000..e70c3aa44c --- /dev/null +++ b/src/daemon/adapters/maestro/daemon-runtime-tap.ts @@ -0,0 +1,266 @@ +import { AppError, asAppError } from '@agent-device/kernel/errors'; +import type { Rect } from '@agent-device/kernel/snapshot'; +import { + MAESTRO_COMPATIBILITY_PRESETS, + MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS, + type MaestroDispatchSelector, + type MaestroRuntimeOperationContext, + type MaestroRuntimeOperations, + type MaestroRuntimeReadContext, + type MaestroTargetMatch, + type MaestroTargetQuery, +} from '@agent-device/maestro'; +import { emitDiagnostic } from '../../../utils/diagnostics.ts'; +import { pointInsideRect } from '../../../utils/rect-center.ts'; +import { + withMaestroScreenshotBaseline, + type MaestroScreenshotBaseline, +} from './maestro-screenshot-comparison.ts'; +import { + MAESTRO_OBSERVATION_POLL_MS, + captureRetriableMaestroSnapshot, + maestroSnapshotSignature, + resolveTypedMaestroTarget, + sleepWithinDeadline, + waitForTypedSnapshotStability, + type MaestroSnapshotSource, +} from './daemon-runtime-port-observation.ts'; +import { + invokeMaestroPublicOperation, + type CreateDaemonMaestroRuntimeOperationsOptions, +} from './daemon-runtime-port-support.ts'; +import type { MaestroClickOptions } from './daemon-runtime-public-operation.ts'; + +export async function resolveDaemonMaestroTarget(params: { + input: MaestroTargetQuery & { timeoutMs: number }; + context: MaestroRuntimeReadContext; + snapshots: MaestroSnapshotSource; + options: CreateDaemonMaestroRuntimeOperationsOptions; + allowObservationReuse?: boolean; +}): Promise { + const { input, context, snapshots, options } = params; + const deadline = options.dependencies.now() + input.timeoutMs; + let currentSnapshot = + params.allowObservationReuse === false ? undefined : snapshots.reuseObservation(context); + while (true) { + const captureStartedAt = options.dependencies.now(); + const reusedObservation = currentSnapshot !== undefined; + currentSnapshot ??= await captureRetriableMaestroSnapshot( + { context, snapshot: snapshots.capture, dependencies: options.dependencies }, + deadline, + ); + const match = resolveTypedMaestroTarget({ + query: input, + context, + snapshot: currentSnapshot, + platform: options.platform, + }); + if (canUseResolvedTarget(match, reusedObservation)) return match; + currentSnapshot = undefined; + if (reusedObservation) continue; + if (captureStartedAt >= deadline) return match; + await sleepWithinDeadline( + options.dependencies, + deadline, + MAESTRO_OBSERVATION_POLL_MS, + context.signal, + ); + } +} + +export async function tapTargetAndSettle( + options: CreateDaemonMaestroRuntimeOperationsOptions, + snapshots: MaestroSnapshotSource, + metrics: { screenshotCaptures: number; tapRetries: number }, + target: Parameters[0]['target'], + context: MaestroRuntimeOperationContext, + policy: { click: MaestroClickOptions; retryIfNoChange: boolean }, +): Promise { + const dispatch = async () => + await dispatchTapTarget(options, snapshots, target, context, policy.click); + if (!policy.retryIfNoChange) { + try { + await dispatch(); + } finally { + snapshots.requireStability(context.generation); + } + return; + } + + if (options.platform === 'ios') { + await withMaestroScreenshotBaseline({ + signal: context.signal, + capture: async (path) => { + metrics.screenshotCaptures += 1; + await invokeMaestroPublicOperation(options, { + kind: 'screenshot', + path, + stabilize: false, + captureBackend: 'runner', + }); + }, + run: async (baseline) => + await tapTargetWithRetry( + options, + snapshots, + metrics, + target, + context, + policy.click, + baseline, + ), + }); + return; + } + + await tapTargetWithRetry(options, snapshots, metrics, target, context, policy.click); +} + +async function tapTargetWithRetry( + options: CreateDaemonMaestroRuntimeOperationsOptions, + snapshots: MaestroSnapshotSource, + metrics: { screenshotCaptures: number; tapRetries: number }, + target: Parameters[0]['target'], + context: MaestroRuntimeOperationContext, + flags: MaestroClickOptions, + screenshotBaseline?: MaestroScreenshotBaseline, +): Promise { + const dispatch = async () => await dispatchTapTarget(options, snapshots, target, context, flags); + const baselineSignature = + target.resolution?.surfaceSignature ?? + (screenshotBaseline ? undefined : maestroSnapshotSignature(await snapshots.capture(context))); + const settle = async () => + await waitForTypedSnapshotStability({ + timeoutMs: MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS, + context, + snapshot: snapshots.capture, + dependencies: options.dependencies, + }); + + try { + const observed = await executeTapRetryLoop({ + baselineSignature, + screenshotBaseline, + dispatch, + settle, + onRetry: () => { + metrics.tapRetries += 1; + }, + }); + snapshots.prime(context.generation, observed.snapshot); + } catch (error) { + snapshots.requireStability(context.generation); + throw error; + } +} + +async function executeTapRetryLoop(params: { + readonly baselineSignature?: string; + readonly screenshotBaseline?: MaestroScreenshotBaseline; + readonly dispatch: () => Promise; + readonly settle: () => ReturnType; + readonly onRetry: () => void; +}) { + await params.dispatch(); + let observed = await params.settle(); + let attempts = 1; + while ( + (params.baselineSignature === undefined || observed.signature === params.baselineSignature) && + attempts < MAESTRO_COMPATIBILITY_PRESETS.command.retryTapMaxAttempts + ) { + if (params.screenshotBaseline && (await params.screenshotBaseline.matchesCurrent()) !== true) { + break; + } + params.onRetry(); + await params.dispatch(); + attempts += 1; + observed = await params.settle(); + } + return observed; +} + +async function dispatchTapTarget( + options: CreateDaemonMaestroRuntimeOperationsOptions, + snapshots: MaestroSnapshotSource, + target: Parameters[0]['target'], + context: MaestroRuntimeOperationContext, + flags: MaestroClickOptions, +): Promise { + const resolution = target.resolution; + const dispatchSelector = resolution?.dispatchSelector; + if (dispatchSelector && target.point) { + snapshots.invalidate(context.generation); + try { + await clickSelector(options, dispatchSelector, target.point, flags); + return; + } catch (error) { + if (!isAtomicSelectorFallbackError(error)) throw error; + const refreshed = await resolveDaemonMaestroTarget({ + input: resolution.query, + context, + snapshots, + options, + allowObservationReuse: false, + }); + if (!isActionableTarget(refreshed)) throw error; + snapshots.invalidate(context.generation); + await clickMaestroTargetPoint(options, pointInsideRect(refreshed.rect), flags); + return; + } + } + snapshots.invalidate(context.generation); + await clickMaestroTargetPoint(options, target.point, flags); +} + +function isActionableTarget( + match: MaestroTargetMatch, +): match is MaestroTargetMatch & { rect: Rect } { + return match.matched && match.visible && match.rect !== undefined; +} + +function canUseResolvedTarget(match: MaestroTargetMatch, reusedObservation: boolean): boolean { + if (!isActionableTarget(match)) return false; + return !reusedObservation || match.dispatchSelector !== undefined; +} + +async function clickSelector( + options: CreateDaemonMaestroRuntimeOperationsOptions, + selector: MaestroDispatchSelector, + expectedPoint: { x: number; y: number }, + flags: MaestroClickOptions, +): Promise { + emitDiagnostic({ + level: 'debug', + phase: 'maestro_tap_dispatch', + data: { kind: 'selector', selectorKey: selector.key, expectedPoint }, + }); + await invokeMaestroPublicOperation(options, { + kind: 'clickSelector', + selector, + expectedPoint, + options: flags, + }); +} + +function isAtomicSelectorFallbackError(error: unknown): boolean { + const code = asAppError(error).code; + return code === 'AMBIGUOUS_MATCH' || code === 'ELEMENT_NOT_FOUND' || code === 'ELEMENT_OFFSCREEN'; +} + +export async function clickMaestroTargetPoint( + options: CreateDaemonMaestroRuntimeOperationsOptions, + point: { x: number; y: number } | undefined, + flags: MaestroClickOptions, +): Promise { + if (!point) throw new AppError('COMMAND_FAILED', 'Maestro target did not resolve to a point.'); + emitDiagnostic({ + level: 'debug', + phase: 'maestro_tap_dispatch', + data: { kind: 'point', point }, + }); + await invokeMaestroPublicOperation(options, { + kind: 'clickPoint', + point, + options: flags, + }); +} diff --git a/src/compat/maestro/maestro-screenshot-comparison.ts b/src/daemon/adapters/maestro/maestro-screenshot-comparison.ts similarity index 92% rename from src/compat/maestro/maestro-screenshot-comparison.ts rename to src/daemon/adapters/maestro/maestro-screenshot-comparison.ts index 7ab2f9321a..5cdbcd6720 100644 --- a/src/compat/maestro/maestro-screenshot-comparison.ts +++ b/src/daemon/adapters/maestro/maestro-screenshot-comparison.ts @@ -1,11 +1,11 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { createRequestCanceledError, isRequestCanceledError } from '../../request/cancel.ts'; -import { emitDiagnostic } from '../../utils/diagnostics.ts'; -import { computePngRgbDifferenceAsync } from '../../utils/png-worker-client.ts'; -import type { PngRgbDifferenceResult } from '../../utils/png-rgb-difference.ts'; -import { MAESTRO_COMPATIBILITY_PRESETS } from './compatibility-policy.ts'; +import { MAESTRO_COMPATIBILITY_PRESETS } from '@agent-device/maestro'; +import { createRequestCanceledError, isRequestCanceledError } from '../../../request/cancel.ts'; +import { emitDiagnostic } from '../../../utils/diagnostics.ts'; +import { computePngRgbDifferenceAsync } from '../../../utils/png-worker-client.ts'; +import type { PngRgbDifferenceResult } from '../../../utils/png-rgb-difference.ts'; export type MaestroScreenshotBaseline = { readonly matchesCurrent: () => Promise; diff --git a/src/compat/maestro/run-script-execution.ts b/src/daemon/adapters/maestro/run-script-execution.ts similarity index 98% rename from src/compat/maestro/run-script-execution.ts rename to src/daemon/adapters/maestro/run-script-execution.ts index a0ad979485..618bd21dde 100644 --- a/src/compat/maestro/run-script-execution.ts +++ b/src/daemon/adapters/maestro/run-script-execution.ts @@ -1,8 +1,8 @@ import fs from 'node:fs'; import vm from 'node:vm'; import { AppError, normalizeError } from '@agent-device/kernel/errors'; -import { runCmdSync } from '../../utils/exec.ts'; -import { stripUndefined } from '../../utils/parsing.ts'; +import { runCmdSync } from '../../../utils/exec.ts'; +import { stripUndefined } from '../../../utils/parsing.ts'; const RUN_SCRIPT_TIMEOUT_MS = 30_000; const RUN_SCRIPT_DIAGNOSTIC_PREVIEW_CHARS = 1_000; diff --git a/src/compat/maestro/wait-for-animation-to-end.ts b/src/daemon/adapters/maestro/wait-for-animation-to-end.ts similarity index 100% rename from src/compat/maestro/wait-for-animation-to-end.ts rename to src/daemon/adapters/maestro/wait-for-animation-to-end.ts diff --git a/src/daemon/handlers/__tests__/session-device-claims.test.ts b/src/daemon/handlers/__tests__/session-device-claims.test.ts index eeb206d12a..07ce78f1e3 100644 --- a/src/daemon/handlers/__tests__/session-device-claims.test.ts +++ b/src/daemon/handlers/__tests__/session-device-claims.test.ts @@ -21,6 +21,10 @@ vi.mock('../../../platforms/android/ime-lifecycle.ts', () => ({ activateAndroidTestIme: vi.fn(async () => {}), restoreAndroidTestIme: vi.fn(async () => ({ restored: false, reason: 'no-record' })), })); +vi.mock('../../../utils/host-process.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, readProcessStartTime: vi.fn(() => 'test-process-start') }; +}); import { dispatchCommand, resolveTargetDevice } from '../../../core/dispatch.ts'; import { ensureDeviceReady } from '../../device-ready.ts'; diff --git a/src/daemon/handlers/__tests__/session-replay-maestro-failure.test.ts b/src/daemon/handlers/__tests__/session-replay-maestro-failure.test.ts index fcd6274a33..9b1f2688de 100644 --- a/src/daemon/handlers/__tests__/session-replay-maestro-failure.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-maestro-failure.test.ts @@ -7,6 +7,14 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => { import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { stringify } from 'yaml'; +import { + executeMaestroFlow, + inspectMaestroFlow, + type MaestroFailedAction, + type MaestroRuntimeCommand, + type MaestroRuntimePort, +} from '@agent-device/maestro'; import { buildTypedMaestroFailureReportProjection, buildTypedMaestroFailureResponse, @@ -16,8 +24,6 @@ import { SessionStore } from '../../session-store.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { baseReplayRequest as baseReq } from './session-replay-runtime.fixtures.ts'; -import type { MaestroCommand } from '../../../compat/maestro/program-ir.ts'; -import type { MaestroReplayPlan } from '../../../compat/maestro/replay-plan-types.ts'; import type { SnapshotNode } from '@agent-device/kernel/snapshot'; const mockDispatchCommand = vi.mocked(dispatchCommand); @@ -27,23 +33,8 @@ beforeEach(() => { mockDispatchCommand.mockResolvedValue({}); }); -function makeMaestroPlan(): MaestroReplayPlan { - return { - kind: 'maestroReplayPlan', - platform: 'ios', - initialStaticEnv: {}, - steps: [], - total: 1, - digest: 'typed-maestro-test-plan', - compatibility: { - staticallyExecutedControls: 0, - staticallySkippedControls: 0, - }, - }; -} - async function buildFailureScenario( - command: MaestroCommand, + command: MaestroRuntimeCommand, nodes: SnapshotNode[], ): Promise<{ response: Extract>, { ok: false }>; @@ -55,19 +46,10 @@ async function buildFailureScenario( const sessionStore = new SessionStore(path.join(root, 'sessions')); sessionStore.set(sessionName, makeIosSession(sessionName)); mockDispatchCommand.mockResolvedValue({ nodes, truncated: false, backend: 'xctest' }); + const failure = await captureMaestroFailure(command, path.join(root, 'flow.yaml')); const response = await buildTypedMaestroFailureResponse({ error: { code: 'COMMAND_FAILED', message: 'typed Maestro action failed' }, - event: { - command, - source: command.source, - generation: 0, - stepIndex: 1, - stepTotal: 1, - durationMs: 12, - error: new Error('typed Maestro action failed'), - artifactPaths: [], - }, - plan: makeMaestroPlan(), + failure, replayPath: path.join(root, 'flow.yaml'), req: baseReq({ flags: { replayBackend: 'maestro', platform: 'ios' } }), sessionName, @@ -79,35 +61,24 @@ async function buildFailureScenario( } async function buildFailureResponse( - command: MaestroCommand, + command: MaestroRuntimeCommand, nodes: SnapshotNode[], ): Promise>, { ok: false }>> { return (await buildFailureScenario(command, nodes)).response; } -test('typed Maestro failure projection keeps the event command and source provenance', () => { +test('typed Maestro failure projection keeps action and source provenance', async () => { const command = { kind: 'tapOn' as const, source: { path: '/flows/login.yaml', line: 4 }, target: { space: 'target' as const, selector: { id: 'save' } }, }; const request = baseReq({ flags: { replayBackend: 'maestro' } }); - const projection = buildTypedMaestroFailureReportProjection( - { - command, - source: command.source, - generation: 0, - stepIndex: 1, - stepTotal: 1, - durationMs: 12, - error: new Error('tap failed'), - artifactPaths: [], - }, - request, - ); + const failure = await captureMaestroFailure(command, command.source.path); + const projection = buildTypedMaestroFailureReportProjection(failure, request); - expect(projection.command).toBe(command); - expect(projection.source).toBe(command.source); + expect(projection.command).toEqual({ kind: 'tapOn' }); + expect(projection.source).toEqual({ path: command.source.path, line: 3 }); expect(projection.progress).toEqual({ command: 'tapOn', value: 'save' }); expect(projection.action).toEqual({ command: 'click', @@ -117,6 +88,41 @@ test('typed Maestro failure projection keeps the event command and source proven expect(Object.keys(projection.action)).toEqual(['command', 'positionals', 'flags']); }); +async function captureMaestroFailure( + command: MaestroRuntimeCommand, + sourcePath: string, +): Promise { + const flow = inspectMaestroFlow(maestroFlowForCommand(command), sourcePath); + const error = new Error('typed Maestro action failed'); + const port: MaestroRuntimePort = { + execute: async () => { + throw error; + }, + observe: async () => { + throw error; + }, + }; + const outcome = await executeMaestroFlow(flow, port, { platform: 'ios' }); + if (outcome.ok || !outcome.failure) throw new Error('expected typed Maestro failure'); + return outcome.failure; +} + +function maestroFlowForCommand(command: MaestroRuntimeCommand): string { + const authored = + command.kind === 'inputText' + ? { inputText: command.text } + : command.kind === 'tapOn' && command.target.space === 'target' + ? { + tapOn: { + ...command.target.selector, + ...(command.childOf ? { childOf: command.childOf } : {}), + }, + } + : undefined; + if (!authored) throw new Error(`unsupported failure fixture: ${command.kind}`); + return `${stringify({ appId: 'com.example.app' })}---\n${stringify([authored])}`; +} + test('typed Maestro failure diagnostics render expanded selector values without extra flags', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-maestro-expanded-selector-')); const sessionStore = new SessionStore(path.join(root, 'sessions')); @@ -321,7 +327,7 @@ test('typed Maestro failure publishes exactly the refs exposed by its divergence kind: 'tapOn' as const, source: { path: '/flows/actions.yaml', line: 4 }, target: { space: 'target' as const, selector: { label: 'Missing' } }, - } satisfies Extract; + } satisfies Extract; const scenario = await buildFailureScenario(command, [ { ref: 'e1', @@ -357,7 +363,7 @@ test('typed Maestro suggestions rank visible childOf candidates and exclude out- source: { path: '/flows/actions.yaml', line: 4 }, target: { space: 'target' as const, selector: { text: 'save.*' } }, childOf: { id: 'actions' }, - } satisfies Extract; + } satisfies Extract; const response = await buildFailureResponse(command, [ { ref: 'e1', @@ -431,7 +437,7 @@ test('typed Maestro text matching an identifier reports id basis', async () => { kind: 'tapOn' as const, source: { path: '/flows/actions.yaml', line: 4 }, target: { space: 'target' as const, selector: { text: 'accessibility-save' } }, - } satisfies Extract; + } satisfies Extract; const response = await buildFailureResponse(command, [ { ref: 'e1', @@ -461,7 +467,7 @@ test('typed Maestro suggestions retain total count before the five-entry cap', a kind: 'tapOn' as const, source: { path: '/flows/actions.yaml', line: 4 }, target: { space: 'target' as const, selector: { label: 'Save' } }, - } satisfies Extract; + } satisfies Extract; const response = await buildFailureResponse(command, [ { ref: 'e1', diff --git a/src/daemon/handlers/__tests__/session-test-discovery.test.ts b/src/daemon/handlers/__tests__/session-test-discovery.test.ts index d58dc889ca..187b8b783d 100644 --- a/src/daemon/handlers/__tests__/session-test-discovery.test.ts +++ b/src/daemon/handlers/__tests__/session-test-discovery.test.ts @@ -6,7 +6,7 @@ import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; import { discoverReplayTestEntries } from '../session-test-discovery.ts'; -test('discoverReplayTestEntries expands directories in deterministic path order', () => { +test('discoverReplayTestEntries discovers nested .ad suites through native DFS traversal', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-discovery-')); const nested = path.join(root, 'nested'); fs.mkdirSync(nested, { recursive: true }); @@ -16,8 +16,8 @@ test('discoverReplayTestEntries expands directories in deterministic path order' const entries = discoverReplayTestEntries({ inputs: [root], cwd: root }); assert.deepEqual( - entries.map((entry) => entry.path), - [path.join(root, '01-first.ad'), path.join(nested, '02-second.ad')], + new Set(entries.map((entry) => entry.path)), + new Set([path.join(nested, '02-second.ad'), path.join(root, '01-first.ad')]), ); }); @@ -32,13 +32,12 @@ test('discoverReplayTestEntries skips untyped scripts when platform filter is se platformFilter: 'android', }); - assert.deepEqual( - entries.map((entry) => entry.kind), - ['skip', 'run'], - ); - assert.equal(entries[0]?.kind, 'skip'); - if (entries[0]?.kind === 'skip') { - assert.match(entries[0].message, /missing platform metadata for --platform android/); + const untyped = entries.find((entry) => path.basename(entry.path) === '01-untyped.ad'); + const android = entries.find((entry) => path.basename(entry.path) === '02-android.ad'); + assert.equal(untyped?.kind, 'skip'); + assert.equal(android?.kind, 'run'); + if (untyped?.kind === 'skip') { + assert.match(untyped.message, /missing platform metadata for --platform android/); } }); @@ -75,10 +74,9 @@ test('discoverReplayTestEntries includes Maestro yaml flows for Maestro test sui new Set(entries.map((entry) => path.basename(entry.path))), new Set(['01-flow.yaml', '02-flow.yml', '03-flow.ad']), ); - assert.deepEqual( - entries.map((entry) => entry.kind), - ['run', 'run', 'run'], - ); + assert.equal(entries.find((entry) => path.basename(entry.path) === '01-flow.yaml')?.kind, 'run'); + assert.equal(entries.find((entry) => path.basename(entry.path) === '02-flow.yml')?.kind, 'run'); + assert.equal(entries.find((entry) => path.basename(entry.path) === '03-flow.ad')?.kind, 'skip'); const namedFlow = entries.find((entry) => path.basename(entry.path) === '01-flow.yaml'); assert.equal(namedFlow?.kind, 'run'); if (namedFlow?.kind === 'run') { diff --git a/src/daemon/handlers/__tests__/session-test-runner.test.ts b/src/daemon/handlers/__tests__/session-test-runner.test.ts index a7f671311f..0ce55813e6 100644 --- a/src/daemon/handlers/__tests__/session-test-runner.test.ts +++ b/src/daemon/handlers/__tests__/session-test-runner.test.ts @@ -112,9 +112,11 @@ test('test filters replay scripts by context platform and skips untyped files', expect(response.data?.skipped).toBe(1); const tests = response.data?.tests as Array> | undefined; expect(tests?.length).toBe(2); - expect(tests?.[0]?.status).toBe('passed'); - expect(tests?.[1]?.status).toBe('skipped'); - expect(tests?.[1]?.reason).toBe('skipped-by-filter'); + const android = tests?.find((entry) => String(entry.file).endsWith('01-android.ad')); + const untyped = tests?.find((entry) => String(entry.file).endsWith('03-untyped.ad')); + expect(android?.status).toBe('passed'); + expect(untyped?.status).toBe('skipped'); + expect(untyped?.reason).toBe('skipped-by-filter'); } }); diff --git a/src/daemon/handlers/__tests__/session-test-suite.test.ts b/src/daemon/handlers/__tests__/session-test-suite.test.ts index 7816798b06..3714be5631 100644 --- a/src/daemon/handlers/__tests__/session-test-suite.test.ts +++ b/src/daemon/handlers/__tests__/session-test-suite.test.ts @@ -247,8 +247,10 @@ test('test emits progress when attempts retry and pass', async () => { test('test emits skip progress without synthetic duration', async () => { const sessionStore = makeSessionStore(); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-suite-skip-progress-')); - fs.writeFileSync(path.join(root, '01-missing-platform.ad'), 'open "Demo"\n'); - fs.writeFileSync(path.join(root, '02-android.ad'), 'context platform=android\nopen "Demo"\n'); + const missingPlatformPath = path.join(root, '01-missing-platform.ad'); + const androidPath = path.join(root, '02-android.ad'); + fs.writeFileSync(missingPlatformPath, 'open "Demo"\n'); + fs.writeFileSync(androidPath, 'context platform=android\nopen "Demo"\n'); const events: RequestProgressEvent[] = []; const response = await withRequestProgressSink( @@ -259,7 +261,7 @@ test('test emits skip progress without synthetic duration', async () => { token: 't', session: 'default', command: 'test', - positionals: [root], + positionals: [missingPlatformPath, androidPath], meta: { cwd: root, requestId: 'suite-skip-progress' }, flags: { platform: 'android' }, }, @@ -484,8 +486,10 @@ test('test stops the suite when the parent request is canceled during an active test('test --shard-all runs each runnable entry on each selected device', async () => { const sessionStore = makeSessionStore(); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-suite-shard-all-')); - fs.writeFileSync(path.join(root, '01-login.ad'), 'context platform=android\nopen "Demo"\n'); - fs.writeFileSync(path.join(root, '02-pay.ad'), 'context platform=android\nopen "Demo"\n'); + const loginPath = path.join(root, '01-login.ad'); + const payPath = path.join(root, '02-pay.ad'); + fs.writeFileSync(loginPath, 'context platform=android\nopen "Demo"\n'); + fs.writeFileSync(payPath, 'context platform=android\nopen "Demo"\n'); const invoked: DaemonRequest[] = []; const response = await withDeviceInventoryProvider( @@ -496,7 +500,7 @@ test('test --shard-all runs each runnable entry on each selected device', async token: 't', session: 'default', command: 'test', - positionals: [root], + positionals: [loginPath, payPath], meta: { cwd: root, requestId: 'suite-shard-all' }, flags: { platform: 'android', @@ -553,10 +557,14 @@ test('test --shard-all runs each runnable entry on each selected device', async test('test --shard-split distributes runnable entries by modulo and keeps skips once', async () => { const sessionStore = makeSessionStore(); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-suite-shard-split-')); - fs.writeFileSync(path.join(root, '01-missing-platform.ad'), 'open "Demo"\n'); - fs.writeFileSync(path.join(root, '02-a.ad'), 'context platform=android\nopen "Demo"\n'); - fs.writeFileSync(path.join(root, '03-b.ad'), 'context platform=android\nopen "Demo"\n'); - fs.writeFileSync(path.join(root, '04-c.ad'), 'context platform=android\nopen "Demo"\n'); + const missingPlatformPath = path.join(root, '01-missing-platform.ad'); + const aPath = path.join(root, '02-a.ad'); + const bPath = path.join(root, '03-b.ad'); + const cPath = path.join(root, '04-c.ad'); + fs.writeFileSync(missingPlatformPath, 'open "Demo"\n'); + fs.writeFileSync(aPath, 'context platform=android\nopen "Demo"\n'); + fs.writeFileSync(bPath, 'context platform=android\nopen "Demo"\n'); + fs.writeFileSync(cPath, 'context platform=android\nopen "Demo"\n'); const invoked: DaemonRequest[] = []; const response = await withDeviceInventoryProvider( @@ -567,7 +575,7 @@ test('test --shard-split distributes runnable entries by modulo and keeps skips token: 't', session: 'default', command: 'test', - positionals: [root], + positionals: [missingPlatformPath, aPath, bPath, cPath], meta: { cwd: root, requestId: 'suite-shard-split' }, flags: { platform: 'android', shardSplit: 2 }, }, diff --git a/src/daemon/handlers/session-replay-maestro-failure.ts b/src/daemon/handlers/session-replay-maestro-failure.ts index d44dec6dc0..a80cdb42e1 100644 --- a/src/daemon/handlers/session-replay-maestro-failure.ts +++ b/src/daemon/handlers/session-replay-maestro-failure.ts @@ -1,24 +1,15 @@ -import { - isMaestroControlCommandDescriptor, - type MaestroEngineEvent, -} from '../../compat/maestro/engine-types.ts'; -import { formatMaestroCommandProgress } from '../../compat/maestro/progress.ts'; -import type { MaestroCommand, MaestroSelector } from '../../compat/maestro/program-ir.ts'; -import { evaluateMaestroReplayResume } from '../../compat/maestro/replay-plan.ts'; -import type { MaestroReplayPlan } from '../../compat/maestro/replay-plan-types.ts'; -import { matchesMaestroTypedSelector } from '../../compat/maestro/runtime-target-policy.ts'; -import { rankMaestroCandidates } from '../../compat/maestro/runtime-target-ranking.ts'; -import type { DaemonError } from '@agent-device/kernel/errors'; +import { rankMaestroFailureCandidates, type MaestroFailedAction } from '@agent-device/maestro'; import type { SnapshotNode } from '@agent-device/kernel/snapshot'; +import type { DaemonError } from '@agent-device/kernel/errors'; +import type { SnapshotDiagnosticsSummary } from '@agent-device/contracts/capture'; import { REPLAY_DIVERGENCE_SUGGESTION_LIMIT, createReplayDivergenceSanitizer, type ReplayDivergence, - type ReplayDivergenceSuggestionBasis, type ReplayVarScrubEntry, } from '../../replay/divergence.ts'; import { formatScriptArg } from '../../replay/script-utils.ts'; -import type { SnapshotDiagnosticsSummary } from '@agent-device/contracts/capture'; +import { getRequestSignal } from '../../request/cancel.ts'; import { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import type { ReplayReportAction } from './session-replay-report-action.ts'; @@ -36,13 +27,6 @@ import { buildReplayDivergenceFailureResponseFromDescriptor, hoistReplayFailureCauseDiagnosticMeta, } from './session-replay-runtime-failure-response.ts'; -import { getRequestSignal } from '../../request/cancel.ts'; - -export type MaestroFailedEngineEvent = MaestroEngineEvent & { - readonly durationMs: number; - readonly error: unknown; - readonly artifactPaths: readonly string[]; -}; export type MaestroFailureReportAction = Pick< ReplayReportAction, @@ -50,25 +34,27 @@ export type MaestroFailureReportAction = Pick< >; export type MaestroFailureReportProjection = { - /** Failure command; runtime-command failures are resolved, while earlier failures stay authored. */ - readonly command: MaestroEngineEvent['command']; - readonly source: MaestroEngineEvent['source']; - readonly progress: ReturnType; + readonly command: { readonly kind: string }; + readonly source: MaestroFailedAction['source']; + readonly progress: { readonly command: string; readonly value?: string }; readonly action: MaestroFailureReportAction; }; export function buildTypedMaestroFailureReportProjection( - event: MaestroFailedEngineEvent, + failure: MaestroFailedAction, req: DaemonRequest, ): MaestroFailureReportProjection { - const progress = formatMaestroCommandProgress(event.command); + const progress = { + command: failure.action, + ...(failure.value ? { value: failure.value } : {}), + }; return { - command: event.command, - source: event.source, + command: { kind: failure.action }, + source: failure.source, progress, action: { - command: reportCommandForCapture(event.command.kind), - positionals: safeProgressPositionals(event.command.kind, progress.value), + command: reportCommandForCapture(failure.action), + positionals: safeProgressPositionals(failure.action, failure.value), flags: req.flags ?? {}, }, }; @@ -76,8 +62,7 @@ export function buildTypedMaestroFailureReportProjection( export async function buildTypedMaestroFailureResponse(params: { readonly error: DaemonError; - readonly event: MaestroFailedEngineEvent; - readonly plan: MaestroReplayPlan; + readonly failure: MaestroFailedAction; readonly replayPath: string; readonly req: DaemonRequest; readonly sessionName: string; @@ -85,11 +70,11 @@ export async function buildTypedMaestroFailureResponse(params: { readonly logPath: string; readonly snapshotDiagnostics?: SnapshotDiagnosticsSummary; }): Promise { - const { event, plan, replayPath, req, sessionName, sessionStore, logPath } = params; + const { failure, replayPath, req, sessionName, sessionStore, logPath } = params; const requestSignal = getRequestSignal(req.meta?.requestId); - const report = buildTypedMaestroFailureReportProjection(event, req); + const report = buildTypedMaestroFailureReportProjection(failure, req); const cause = hoistReplayFailureCauseDiagnosticMeta(params.error); - const scrubVars = collectMaestroTextScrubVars(report.command); + const scrubVars: ReplayVarScrubEntry[] = [...failure.redactions]; const sanitize = createReplayDivergenceSanitizer(scrubVars); const safeCause = { ...cause, @@ -111,33 +96,26 @@ export async function buildTypedMaestroFailureResponse(params: { hint: 'The session closed before a post-failure screen could be captured.', }; const suggestions = - session && - observation.state === 'available' && - !isMaestroControlCommandDescriptor(report.command) + session && observation.state === 'available' && !failure.isControl ? collectTypedMaestroSuggestions({ - command: report.command, - platform: plan.platform, + failure, action: report.action, session, nodes: observation.nodes, sanitize, }) : []; - const resume = evaluateMaestroReplayResume(plan, { - from: event.stepIndex, - planDigest: plan.digest, - }); - const actionLabel = [report.command.kind, formatMaestroActionValue(report.progress.value)] + const actionLabel = [failure.action, formatMaestroActionValue(failure.value)] .filter(Boolean) .join(' '); const divergence: ReplayDivergence = { version: 1, kind: 'action-failure', step: { - index: event.stepIndex, + index: failure.stepIndex, source: { - path: sanitize(report.source.path ?? replayPath), - line: report.source.line, + path: sanitize(failure.source.path ?? replayPath), + line: failure.source.line, }, }, action: sanitize(actionLabel), @@ -149,14 +127,7 @@ export async function buildTypedMaestroFailureResponse(params: { screen: buildDivergenceScreen(observation, sanitize), suggestions: suggestions.slice(0, REPLAY_DIVERGENCE_SUGGESTION_LIMIT), suggestionCount: suggestions.length, - resume: resume.allowed - ? { allowed: true, from: event.stepIndex, planDigest: plan.digest } - : { - allowed: false, - from: event.stepIndex, - planDigest: plan.digest, - reason: resume.reason, - }, + resume: failure.resume, repairHint: computeReplayRepairHint({ kind: 'action-failure', targetEvidence: undefined, @@ -174,39 +145,30 @@ export async function buildTypedMaestroFailureResponse(params: { return buildReplayDivergenceFailureResponseFromDescriptor({ error: safeCause, actionLabel, - action: report.command.kind, + action: failure.action, positionals: [...report.action.positionals], - step: event.stepIndex, + step: failure.stepIndex, replayPath, - artifactPaths: [...event.artifactPaths], + artifactPaths: [...failure.artifactPaths], snapshotDiagnostics: params.snapshotDiagnostics, divergence: bounded, scrubVars, }); } -function formatMaestroActionValue(value: string | undefined): string { - if (!value || value === '') return value ?? ''; - return formatScriptArg(value); -} - function collectTypedMaestroSuggestions(params: { - command: MaestroCommand; - platform: MaestroReplayPlan['platform']; + failure: MaestroFailedAction; action: MaestroFailureReportAction; session: SessionState; nodes: SnapshotNode[]; sanitize: DivergenceFieldSanitizer; }) { - const query = typedSuggestionQuery(params.command); - if (!query || (params.platform !== 'android' && params.platform !== 'ios')) return []; const snapshot = { createdAt: Date.now(), nodes: params.nodes }; - const candidates = collectTypedMaestroCandidates(snapshot, query, params.platform); return rankAndDedupeReplaySuggestions( - candidates.map((node) => ({ + rankMaestroFailureCandidates(params.failure, snapshot).map(({ node, basis }) => ({ node, nodeIndex: node.index, - basis: suggestionBasis(query.selector, node), + basis, })), ).map(({ node, basis }) => buildReplayDivergenceSuggestionForNode({ @@ -220,99 +182,9 @@ function collectTypedMaestroSuggestions(params: { ); } -type TypedSuggestionBasis = Extract; - -function collectTypedMaestroCandidates( - snapshot: { nodes: SnapshotNode[]; createdAt: number }, - query: TypedSuggestionQuery, - platform: Extract, -): SnapshotNode[] { - return rankMaestroCandidates(snapshot, query.selector, platform, query.childOf).ranked; -} - -type TypedSuggestionQuery = { - selector: MaestroSelector; - index?: number | string; - childOf?: MaestroSelector; -}; - -function typedSuggestionQuery(command: MaestroCommand): TypedSuggestionQuery | undefined { - if (isTargetInteraction(command)) return targetInteractionSuggestion(command); - if (isObservationCommand(command)) return observationSuggestion(command); - if (command.kind === 'swipe' && command.gesture.kind === 'target') { - return { selector: command.gesture.from }; - } - return undefined; -} - -type TargetInteractionCommand = Extract< - MaestroCommand, - { kind: 'tapOn' | 'doubleTapOn' | 'longPressOn' } ->; - -function isTargetInteraction(command: MaestroCommand): command is TargetInteractionCommand { - return ( - command.kind === 'tapOn' || command.kind === 'doubleTapOn' || command.kind === 'longPressOn' - ); -} - -function targetInteractionSuggestion( - command: TargetInteractionCommand, -): TypedSuggestionQuery | undefined { - if (command.target.space !== 'target') return undefined; - if (command.kind === 'tapOn') { - return { - selector: command.target.selector, - index: command.index, - childOf: command.childOf, - }; - } - return { selector: command.target.selector }; -} - -type ObservationCommand = Extract< - MaestroCommand, - { kind: 'assertVisible' | 'assertNotVisible' | 'extendedWaitUntil' | 'scrollUntilVisible' } ->; - -function isObservationCommand(command: MaestroCommand): command is ObservationCommand { - return ( - command.kind === 'assertVisible' || - command.kind === 'assertNotVisible' || - command.kind === 'extendedWaitUntil' || - command.kind === 'scrollUntilVisible' - ); -} - -function observationSuggestion(command: ObservationCommand): TypedSuggestionQuery | undefined { - switch (command.kind) { - case 'assertVisible': - case 'assertNotVisible': - return { selector: command.target }; - case 'extendedWaitUntil': - return command.visible - ? { selector: command.visible } - : command.notVisible - ? { selector: command.notVisible } - : undefined; - case 'scrollUntilVisible': - return { selector: command.element }; - } -} - -function suggestionBasis(selector: MaestroSelector, node: SnapshotNode): TypedSuggestionBasis { - if (selector.id !== undefined) return 'id'; - if ( - selector.text !== undefined && - matchesMaestroTypedSelector( - { ...node, label: undefined, value: undefined }, - { text: selector.text }, - ) - ) { - return 'id'; - } - if (selector.text !== undefined || selector.label !== undefined) return 'label'; - return 'other'; +function formatMaestroActionValue(value: string | undefined): string { + if (!value || value === '') return value ?? ''; + return formatScriptArg(value); } function reportCommandForCapture(command: string): string { @@ -333,12 +205,3 @@ function safeProgressPositionals(command: string, value: string | undefined): st if (!value || command === 'inputText') return []; return [value]; } - -function collectMaestroTextScrubVars( - command: MaestroEngineEvent['command'], -): ReplayVarScrubEntry[] { - if (command.kind !== 'inputText' || command.text.length === 0) { - return []; - } - return [{ name: `${command.kind}.text`, value: command.text }]; -} diff --git a/src/daemon/handlers/session-replay-maestro-observer.ts b/src/daemon/handlers/session-replay-maestro-observer.ts index 08074c6abf..df4a5a92ee 100644 --- a/src/daemon/handlers/session-replay-maestro-observer.ts +++ b/src/daemon/handlers/session-replay-maestro-observer.ts @@ -1,37 +1,34 @@ -import { formatMaestroCommandProgress } from '../../compat/maestro/progress.ts'; import type { - MaestroEngineEvent, - MaestroEngineObserver, - MaestroRuntimeMetrics, -} from '../../compat/maestro/engine-types.ts'; + MaestroActionEvent, + MaestroCompletedActionEvent, + MaestroExecutionObserver, + MaestroFailedAction, +} from '@agent-device/maestro'; import { AppError } from '@agent-device/kernel/errors'; import { emitRequestProgress, readReplayTestActionProgress } from '../../request/progress.ts'; import { stripUndefined } from '../../utils/parsing.ts'; -import type { MaestroFailedEngineEvent } from './session-replay-maestro-failure.ts'; import { appendReplayTraceEvent } from './session-replay-trace.ts'; export function createMaestroReplayObserver(params: { filePath: string; tracePath: string | undefined; - onFailure: (event: MaestroFailedEngineEvent) => void; -}): MaestroEngineObserver { - const { filePath, tracePath, onFailure } = params; - const traceStarts = new Map(); +}): MaestroExecutionObserver { + const { filePath, tracePath } = params; + const traceStarts = new Map(); return { - commandStarted: (event) => { + actionStarted: (event) => { traceStarts.set(event.stepIndex, event); runTelemetrySink(() => emitMaestroProgress(filePath, event)); runTelemetrySink(() => appendMaestroTraceStart(tracePath, filePath, event)); }, - commandCompleted: (event) => { + actionCompleted: (event) => { const traceEvent = takeTraceStart(traceStarts, event.stepIndex); runTelemetrySink(() => appendMaestroTraceStop(tracePath, filePath, traceStopEvent(traceEvent, event), true), ); }, - commandFailed: (event) => { + actionFailed: (event) => { const traceEvent = takeTraceStart(traceStarts, event.stepIndex); - runTelemetrySink(() => onFailure(event)); runTelemetrySink(() => appendMaestroTraceStop(tracePath, filePath, traceStopEvent(traceEvent, event), false), ); @@ -46,41 +43,30 @@ function runTelemetrySink(callback: () => void): void { } function takeTraceStart( - traceStarts: Map, + traceStarts: Map, stepIndex: number, -): MaestroEngineEvent | undefined { +): MaestroActionEvent | undefined { const event = traceStarts.get(stepIndex); traceStarts.delete(stepIndex); return event; } function traceStopEvent( - start: MaestroEngineEvent | undefined, - event: MaestroEngineEvent & { - durationMs: number; - runtimeMetrics?: MaestroRuntimeMetrics; - data?: Record; - error?: unknown; - }, -): MaestroEngineEvent & { - durationMs: number; - runtimeMetrics?: MaestroRuntimeMetrics; - data?: Record; - error?: unknown; -} { + start: MaestroActionEvent | undefined, + event: MaestroCompletedActionEvent | MaestroFailedAction, +): MaestroCompletedActionEvent | MaestroFailedAction { return stripUndefined({ ...(start ?? event), durationMs: event.durationMs, runtimeMetrics: event.runtimeMetrics, - data: event.data, - error: event.error, + data: 'data' in event ? event.data : undefined, + error: 'error' in event ? event.error : undefined, }); } -function emitMaestroProgress(file: string, event: MaestroEngineEvent): void { +function emitMaestroProgress(file: string, event: MaestroActionEvent): void { const progress = readReplayTestActionProgress(); if (!progress) return; - const formatted = formatMaestroCommandProgress(event.command); emitRequestProgress({ type: 'replay-test', ...progress, @@ -88,17 +74,16 @@ function emitMaestroProgress(file: string, event: MaestroEngineEvent): void { status: 'progress', stepIndex: event.stepIndex, stepTotal: event.stepTotal, - stepCommand: formatted.command, - ...(formatted.value ? { stepValue: formatted.value } : {}), + stepCommand: event.action, + ...(event.value ? { stepValue: event.value } : {}), }); } function appendMaestroTraceStart( tracePath: string | undefined, replayPath: string, - event: MaestroEngineEvent, + event: MaestroActionEvent, ): void { - const formatted = formatMaestroCommandProgress(event.command); appendReplayTraceEvent(tracePath, { type: 'replay_action_start', ts: new Date().toISOString(), @@ -108,20 +93,15 @@ function appendMaestroTraceStart( : {}), line: event.source.line, step: event.stepIndex, - command: formatted.command, - positionals: formatted.value ? [formatted.value] : [], + command: event.action, + positionals: event.value ? [event.value] : [], }); } function appendMaestroTraceStop( tracePath: string | undefined, replayPath: string, - event: MaestroEngineEvent & { - durationMs: number; - runtimeMetrics?: MaestroRuntimeMetrics; - data?: Record; - error?: unknown; - }, + event: MaestroCompletedActionEvent | MaestroFailedAction, ok: boolean, ): void { const resultTiming = maestroResultTiming(event); @@ -134,21 +114,21 @@ function appendMaestroTraceStop( : {}), line: event.source.line, step: event.stepIndex, - command: formatMaestroCommandProgress(event.command).command, + command: event.action, ok, durationMs: event.durationMs, ...(Object.keys(resultTiming).length > 0 ? { resultTiming } : {}), - ...(!ok && event.error instanceof AppError ? { errorCode: event.error.code } : {}), + ...(!ok && 'error' in event && event.error instanceof AppError + ? { errorCode: event.error.code } + : {}), }); } function maestroResultTiming( - event: MaestroEngineEvent & { - runtimeMetrics?: MaestroRuntimeMetrics; - data?: Record; - }, + event: MaestroCompletedActionEvent | MaestroFailedAction, ): Record { - const timing = isPlainRecord(event.data?.timing) ? event.data.timing : undefined; + const data = 'data' in event ? event.data : undefined; + const timing = isPlainRecord(data?.timing) ? data.timing : undefined; return stripUndefined({ ...(event.runtimeMetrics ?? {}), ...(typeof timing?.executionProfile === 'string' diff --git a/src/daemon/handlers/session-replay-maestro-response.ts b/src/daemon/handlers/session-replay-maestro-response.ts index 16a21738dc..7f22130a16 100644 --- a/src/daemon/handlers/session-replay-maestro-response.ts +++ b/src/daemon/handlers/session-replay-maestro-response.ts @@ -1,27 +1,22 @@ import type { ReplayCommandResult } from '@agent-device/contracts/replay'; -import type { MaestroReplayPlan } from '../../compat/maestro/replay-plan-types.ts'; +import type { MaestroExecutionOutcome } from '@agent-device/maestro'; import { normalizeError } from '@agent-device/kernel/errors'; import { summarizeSnapshotTimingSamples } from '@agent-device/contracts/capture'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; import { SessionStore } from '../session-store.ts'; -import { - buildTypedMaestroFailureResponse, - type MaestroFailedEngineEvent, -} from './session-replay-maestro-failure.ts'; +import { buildTypedMaestroFailureResponse } from './session-replay-maestro-failure.ts'; import { errorResponse } from './response.ts'; export function buildTypedMaestroSuccessResponse(params: { - result: { artifactPaths: string[]; warnings?: string[] }; - plan: MaestroReplayPlan; - startIndex: number; + outcome: Extract; startedAt: number; sessionName: string; sessionStore: SessionStore; snapshotStart: number; }): DaemonResponse { - const { result, plan, startIndex, startedAt, sessionName, sessionStore, snapshotStart } = params; + const { outcome, startedAt, sessionName, sessionStore, snapshotStart } = params; const snapshotDiagnostics = readSnapshotDiagnostics(sessionStore, sessionName, snapshotStart); - const replayed = plan.total - startIndex; + const replayed = outcome.replayed; return { ok: true, data: { @@ -29,8 +24,8 @@ export function buildTypedMaestroSuccessResponse(params: { healed: 0, session: sessionName, sessionActive: sessionStore.get(sessionName) !== undefined, - artifactPaths: result.artifactPaths, - ...(result.warnings ? { warnings: result.warnings } : {}), + artifactPaths: outcome.artifactPaths, + ...(outcome.warnings ? { warnings: outcome.warnings } : {}), ...(snapshotDiagnostics ? { snapshotDiagnostics } : {}), message: replaySuccessMessage(replayed, Date.now() - startedAt), } satisfies ReplayCommandResult, @@ -41,22 +36,19 @@ export async function buildTypedMaestroReplayErrorResponse(params: { req: DaemonRequest; requestedPath: string; state: { - failedEvent?: MaestroFailedEngineEvent; - plan?: MaestroReplayPlan; snapshotStart: number; }; - error: unknown; + outcome: Extract; sessionName: string; sessionStore: SessionStore; logPath: string; }): Promise { - const { failedEvent, plan } = params.state; - const normalizedError = normalizeError(failedEvent?.error ?? params.error); - if (failedEvent && plan) { + const { failure } = params.outcome; + const normalizedError = normalizeError(params.outcome.error); + if (failure) { return await buildTypedMaestroFailureResponse({ error: normalizedError, - event: failedEvent, - plan, + failure, replayPath: SessionStore.expandHome(params.requestedPath, params.req.meta?.cwd), req: params.req, sessionName: params.sessionName, @@ -71,7 +63,7 @@ export async function buildTypedMaestroReplayErrorResponse(params: { } return errorResponse(normalizedError.code, normalizedError.message, { ...(normalizedError.details ?? {}), - ...buildErrorDetails(failedEvent), + ...buildErrorDetails(failure), }); } @@ -86,13 +78,13 @@ function readSnapshotDiagnostics( } function buildErrorDetails( - failedEvent: MaestroFailedEngineEvent | undefined, + failure: Extract['failure'], ): Record { - if (!failedEvent) return {}; + if (!failure) return {}; return { - replaySource: failedEvent.source, - replayStep: failedEvent.stepIndex, - replayStepTotal: failedEvent.stepTotal, + replaySource: failure.source, + replayStep: failure.stepIndex, + replayStepTotal: failure.stepTotal, }; } diff --git a/src/daemon/handlers/session-replay-maestro-runtime.ts b/src/daemon/handlers/session-replay-maestro-runtime.ts index 30360597cd..3f8b99576f 100644 --- a/src/daemon/handlers/session-replay-maestro-runtime.ts +++ b/src/daemon/handlers/session-replay-maestro-runtime.ts @@ -1,6 +1,11 @@ import fs from 'node:fs'; -import path from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; +import { + executeMaestroFlow, + inspectMaestroFlow, + type MaestroFlow, + type MaestroPlatform, +} from '@agent-device/maestro'; import { AppError } from '@agent-device/kernel/errors'; import { dispatchGestureViewport, @@ -15,23 +20,13 @@ import { readReplayCliEnvEntries, readReplayShellEnvSource, } from '../../replay/vars.ts'; -import { createDaemonMaestroRuntimePort } from '../../compat/maestro/daemon-runtime-port.ts'; -import { executeMaestroPlan } from '../../compat/maestro/engine.ts'; -import { parseMaestroProgram } from '../../compat/maestro/program-ir-parser.ts'; -import { createMaestroProgramLoader } from '../../compat/maestro/program-loader.ts'; -import { - compileMaestroReplayPlan, - resolveMaestroReplayStartIndex, -} from '../../compat/maestro/replay-plan.ts'; -import type { MaestroPlatform, MaestroProgram } from '../../compat/maestro/program-ir.ts'; -import type { MaestroReplayPlan } from '../../compat/maestro/replay-plan-types.ts'; +import { createDaemonMaestroRuntimePort } from '../adapters/maestro/daemon-runtime-port.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../types.ts'; import { assertSessionSelectorMatches } from '../session-selector.ts'; import { SessionStore } from '../session-store.ts'; import { errorResponse } from './response.ts'; import { buildReplayBuiltinVars } from './session-replay-vars.ts'; -import type { MaestroFailedEngineEvent } from './session-replay-maestro-failure.ts'; import { createMaestroReplayObserver } from './session-replay-maestro-observer.ts'; import { buildTypedMaestroReplayErrorResponse, @@ -51,14 +46,12 @@ type TypedMaestroReplayParams = { }; type TypedMaestroReplayState = { - failedEvent?: MaestroFailedEngineEvent; - plan?: MaestroReplayPlan; snapshotStart: number; }; type TypedMaestroReplayContext = { filePath: string; - program: MaestroProgram; + flow: MaestroFlow; device?: DeviceInfo; platform: Extract; target: string; @@ -66,7 +59,6 @@ type TypedMaestroReplayContext = { defaults: Record; env: Record; signal: AbortSignal | undefined; - loadProgram: ReturnType; }; type MaestroReplayBinding = Pick< @@ -100,7 +92,7 @@ export async function runTypedMaestroReplayFile( ...params, requestedPath, state, - error, + outcome: { ok: false, error }, }); } } @@ -114,20 +106,6 @@ async function executeTypedMaestroReplay( ): Promise { const { req, sessionName, sessionStore, tracePath, invoke, state } = params; const context = await prepareTypedMaestroReplay(params); - const plan = await compileMaestroReplayPlan(context.program, { - defaults: context.defaults, - env: context.env, - platform: context.platform, - target: context.target, - runtimeHints: context.runtimeHints, - loadProgram: context.loadProgram, - signal: context.signal, - }); - state.plan = plan; - const startIndex = resolveMaestroReplayStartIndex(plan, { - from: req.flags?.replayFrom, - planDigest: req.flags?.replayPlanDigest, - }); const port = createMaestroReplayPort({ req, invoke, @@ -140,26 +118,30 @@ async function executeTypedMaestroReplay( sourcePath: context.filePath, }); state.snapshotStart = sessionStore.get(sessionName)?.snapshotDiagnostics?.samples.length ?? 0; - const result = await executeMaestroPlan(plan, port, { + const outcome = await executeMaestroFlow(context.flow, port, { defaults: context.defaults, env: context.env, platform: context.platform, target: context.target, - loadProgram: context.loadProgram, + runtimeHints: context.runtimeHints, signal: context.signal, - startIndex, + from: req.flags?.replayFrom, + planDigest: req.flags?.replayPlanDigest, observer: createMaestroReplayObserver({ filePath: context.filePath, tracePath, - onFailure: (event) => { - state.failedEvent = event; - }, }), }); + if (!outcome.ok) { + return await buildTypedMaestroReplayErrorResponse({ + ...params, + requestedPath: params.requestedPath, + state, + outcome, + }); + } return buildTypedMaestroSuccessResponse({ - result, - plan, - startIndex, + outcome, startedAt: params.startedAt, sessionName, sessionStore, @@ -172,9 +154,7 @@ async function prepareTypedMaestroReplay( ): Promise { const { req, requestedPath, sessionName, sessionStore } = params; const filePath = SessionStore.expandHome(requestedPath, req.meta?.cwd); - const program = parseMaestroProgram(fs.readFileSync(filePath, 'utf8'), { - sourcePath: filePath, - }); + const flow = inspectMaestroFlow(fs.readFileSync(filePath, 'utf8'), filePath); const session = sessionStore.get(sessionName); if (session) assertSessionSelectorMatches(session, req.flags); const binding = await resolveMaestroReplayBinding({ @@ -182,11 +162,11 @@ async function prepareTypedMaestroReplay( sessionStore, sessionName, session, - program, + flow, }); return { filePath, - program, + flow, ...binding, defaults: buildTypedMaestroDefaults({ req, @@ -197,7 +177,6 @@ async function prepareTypedMaestroReplay( }), env: buildTypedMaestroEnv(req), signal: getRequestSignal(req.meta?.requestId), - loadProgram: createMaestroProgramLoader(path.dirname(filePath)), }; } @@ -206,9 +185,9 @@ async function resolveMaestroReplayBinding(params: { sessionStore: SessionStore; sessionName: string; session: ReturnType; - program: MaestroProgram; + flow: MaestroFlow; }): Promise { - const { req, sessionStore, sessionName, session, program } = params; + const { req, sessionStore, sessionName, session, flow } = params; const requestedPlatform = req.flags?.platform; const device = session?.device ?? @@ -216,7 +195,7 @@ async function resolveMaestroReplayBinding(params: { ? undefined : await resolveTargetDevice( req.flags ?? {}, - buildMaestroReplayTargetDeviceResolutionOptions(program, requestedPlatform), + buildMaestroReplayTargetDeviceResolutionOptions(flow.appTarget, requestedPlatform), )); const platform = resolveMaestroPlatform(req, device); const runtimeHints = resolveEffectiveOpenRuntimeHints({ @@ -234,7 +213,7 @@ async function resolveMaestroReplayBinding(params: { platform, target: resolveMaestroTarget(req, device), runtimeHints, - program, + flow, }); } @@ -243,13 +222,13 @@ async function completeMaestroRuntimeBinding( req: DaemonRequest; sessionStore: SessionStore; sessionName: string; - program: MaestroProgram; + flow: MaestroFlow; } & MaestroReplayBinding, ): Promise { if (params.device || !requiresDeviceRuntimeDefaults(params.runtimeHints)) return params; const device = await resolveTargetDevice( params.req.flags ?? {}, - buildMaestroReplayTargetDeviceResolutionOptions(params.program, params.platform), + buildMaestroReplayTargetDeviceResolutionOptions(params.flow.appTarget, params.platform), ); return { device, @@ -415,13 +394,6 @@ function unresolvedIosMaestroFlags( return flags; } -export function isTypedMaestroReplay(req: DaemonRequest, filePath: string): boolean { - return ( - req.flags?.replayBackend === 'maestro' && - (path.extname(filePath) === '.yaml' || path.extname(filePath) === '.yml') - ); -} - function resolveMaestroPlatform( req: DaemonRequest, sessionDevice: DeviceInfo | undefined, diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index 37877cfc0c..408d1a06f7 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -39,6 +39,7 @@ import { } from '@agent-device/contracts/capture'; import type { ReplayCommandResult } from '@agent-device/contracts/replay'; import type { ReplayDivergenceResume } from '../../replay/divergence.ts'; +import { resolveReplayFormat } from '../../replay/format.ts'; import { isRecord } from '../../utils/parsing.ts'; import { collectReplayActionArtifactPaths } from './session-replay-runtime-artifacts.ts'; import { withReplayFailureDiagnostics } from './session-replay-runtime-failure.ts'; @@ -56,10 +57,7 @@ import { type ReplayVerifiedTargetGuard, } from './session-replay-target-verification.ts'; import { buildReplayBuiltinVars } from './session-replay-vars.ts'; -import { - isTypedMaestroReplay, - runTypedMaestroReplayFile, -} from './session-replay-maestro-runtime.ts'; +import { runTypedMaestroReplayFile } from './session-replay-maestro-runtime.ts'; import { getRequestSignal } from '../../request/cancel.ts'; /** Per-run invariants for a single replay step (ADR 0012 step 4 verify + dispatch + guard). */ @@ -211,8 +209,15 @@ export async function runReplayScriptFile(params: { const artifactPaths = new Set(); try { resolved = SessionStore.expandHome(filePath, req.meta?.cwd); - const typedResponse = await runTypedReplayIfNeeded({ ...params, resolved }); - if (typedResponse) return typedResponse; + if (resolveReplayFormat(resolved, req.flags?.replayBackend) === 'maestro') { + if (sessionStore.get(sessionName)?.saveScriptBoundary !== undefined) { + return errorResponse( + 'INVALID_ARGS', + 'This session has an active .ad --save-script repair run; finish it with replay --from or close before running Maestro YAML.', + ); + } + return await runTypedMaestroReplayFile(params); + } const planPreparation = prepareReplayPlan({ req, sessionName, @@ -510,25 +515,6 @@ type PreparedReplayPlan = { type ParsedReplayInput = ReturnType; -async function runTypedReplayIfNeeded(params: { - req: DaemonRequest; - sessionName: string; - logPath: string; - sessionStore: SessionStore; - tracePath?: string; - invoke: DaemonInvokeFn; - resolved: string; -}): Promise { - if (!isTypedMaestroReplay(params.req, params.resolved)) return undefined; - if (params.sessionStore.get(params.sessionName)?.saveScriptBoundary !== undefined) { - return errorResponse( - 'INVALID_ARGS', - 'This session has an active .ad --save-script repair run; finish it with replay --from or close before running Maestro YAML.', - ); - } - return await runTypedMaestroReplayFile(params); -} - function prepareReplayPlan(params: { req: DaemonRequest; sessionName: string; diff --git a/src/daemon/handlers/session-test-discovery.ts b/src/daemon/handlers/session-test-discovery.ts index 110a63c1a9..d8950247e1 100644 --- a/src/daemon/handlers/session-test-discovery.ts +++ b/src/daemon/handlers/session-test-discovery.ts @@ -2,12 +2,11 @@ import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; import { isApplePlatform, type PlatformSelector } from '@agent-device/kernel/device'; +import { inspectMaestroFlow } from '@agent-device/maestro'; import { resolveRequestTrackingId } from '../../request/cancel.ts'; -import { SessionStore } from '../session-store.ts'; +import { resolveReplayFormat } from '../../replay/format.ts'; import { readReplayScriptMetadata, type ReplayScriptMetadata } from '../../replay/script.ts'; -import { parseMaestroProgram } from '../../compat/maestro/program-ir-parser.ts'; - -const GLOB_PATTERN_CHARS = /[*?[\]{}]/; +import { discoverReplaySourcePaths } from '../replay-source-discovery.ts'; const MAX_REPLAY_TEST_RETRIES = 3; @@ -27,8 +26,6 @@ export type ReplayTestDiscoveryEntry = export type ReplayTestRunEntry = Extract; -type ReplayTestInputSource = 'directory' | 'file' | 'glob'; - export function discoverReplayTestEntries(params: { inputs: string[]; cwd?: string; @@ -36,9 +33,12 @@ export function discoverReplayTestEntries(params: { replayBackend?: string; }): ReplayTestDiscoveryEntry[] { const { inputs, cwd, platformFilter, replayBackend } = params; - const extensions = replayTestExtensions(replayBackend); const resolvedCwd = cwd ?? process.cwd(); - const filePaths = discoverReplayTestFilePaths(inputs, resolvedCwd, extensions, replayBackend); + const filePaths = discoverReplaySourcePaths({ + inputs, + cwd: resolvedCwd, + replayBackend, + }); const entries: ReplayTestDiscoveryEntry[] = []; for (const filePath of filePaths) { @@ -50,7 +50,7 @@ export function discoverReplayTestEntries(params: { continue; } if (!metadata.platform) { - if (isMaestroReplayBackend(replayBackend)) { + if (resolveReplayFormat(filePath, replayBackend) === 'maestro') { entries.push({ kind: 'run', path: filePath, title, metadata }); } else { entries.push({ @@ -134,179 +134,19 @@ export function resolveReplayTestRetries( return Math.max(0, Math.min(MAX_REPLAY_TEST_RETRIES, resolved)); } -function discoverReplayTestFilePaths( - inputs: string[], - cwd: string, - extensions: Set, - replayBackend: string | undefined, -): string[] { - if (!isMaestroReplayBackend(replayBackend)) { - return [ - ...new Set(inputs.flatMap((input) => expandReplayTestInput(input, cwd, extensions).paths)), - ] - .map((entry) => path.normalize(entry)) - .sort((left, right) => left.localeCompare(right)); - } - - const files: string[] = []; - const expandedGroups: string[][] = []; - for (const input of inputs) { - const expanded = expandMaestroReplayTestInput(input, cwd, extensions); - if (expanded.source === 'file') { - files.push(...expanded.paths); - } else { - expandedGroups.push( - expanded.source === 'directory' - ? uniqueNormalizedPaths(expanded.paths) - : sortMaestroExpandedReplayTestPaths(expanded.paths), - ); - } - } - - return uniqueNormalizedPaths([...files, ...expandedGroups.flat()]); -} - -function expandMaestroReplayTestInput( - input: string, - cwd: string, - extensions: Set, -): { paths: string[]; source: ReplayTestInputSource } { - const expandedInput = SessionStore.expandHome(input, cwd); - if (fs.existsSync(expandedInput) && fs.statSync(expandedInput).isDirectory()) { - return { - paths: readMaestroDirectoryReplayTestPaths(expandedInput, extensions), - source: 'directory', - }; - } - - return expandReplayTestInput(input, cwd, extensions); -} - -function readMaestroDirectoryReplayTestPaths( - directoryPath: string, - extensions: Set, -): string[] { - const paths: string[] = []; - // Maestro's Java Files.walk follows native directory iteration order. Keep - // this unsorted so folder suites, and sharding derived from them, match - // Maestro on the same machine even though order can differ across hosts. - const directory = fs.opendirSync(directoryPath); - try { - let entry: fs.Dirent | null; - while ((entry = directory.readSync()) !== null) { - const filePath = path.join(directoryPath, entry.name); - if (entry.isDirectory()) { - paths.push(...readMaestroDirectoryReplayTestPaths(filePath, extensions)); - } else if (entry.isFile() && extensions.has(path.extname(entry.name))) { - paths.push(filePath); - } - } - } finally { - directory.closeSync(); - } - return paths; -} - -function expandReplayTestInput( - input: string, - cwd: string, - extensions: Set, -): { paths: string[]; source: ReplayTestInputSource } { - const expandedInput = SessionStore.expandHome(input, cwd); - if (fs.existsSync(expandedInput)) { - const stat = fs.statSync(expandedInput); - if (stat.isDirectory()) { - const paths = replayTestGlobPatterns(extensions).flatMap((pattern) => - fs - .globSync(pattern, { cwd: expandedInput }) - .map((match) => path.join(expandedInput, match)), - ); - return { paths, source: 'directory' }; - } - if (stat.isFile()) { - if (!extensions.has(path.extname(expandedInput))) { - throw new AppError('INVALID_ARGS', `test does not support this file type: ${input}`); - } - return { paths: [expandedInput], source: 'file' }; - } - return { paths: [], source: 'file' }; - } - - if (!looksLikeGlob(input) && !looksLikeGlob(expandedInput)) { - throw new AppError('INVALID_ARGS', `test input not found: ${input}`); - } - - const pattern = path.isAbsolute(expandedInput) ? expandedInput : input; - const matches = fs.globSync(pattern, { - cwd: path.isAbsolute(expandedInput) ? undefined : cwd, - }); - - const paths = matches - .map((match) => (path.isAbsolute(match) ? match : path.resolve(cwd, match))) - .filter((match) => extensions.has(path.extname(match)) && isExistingFile(match)); - return { paths, source: 'glob' }; -} - -function replayTestExtensions(replayBackend: string | undefined): Set { - return isMaestroReplayBackend(replayBackend) - ? new Set(['.yaml', '.yml', '.ad']) - : new Set(['.ad']); -} - -function replayTestGlobPatterns(extensions: Set): string[] { - return [...extensions].map((extension) => `**/*${extension}`); -} - -function sortMaestroExpandedReplayTestPaths(paths: string[]): string[] { - return paths.map((entry) => path.normalize(entry)).sort(compareMaestroReplayTestPath); -} - -function compareMaestroReplayTestPath(left: string, right: string): number { - const leftRank = maestroReplayTestExtensionRank(left); - const rightRank = maestroReplayTestExtensionRank(right); - if (leftRank !== rightRank) { - return leftRank - rightRank; - } - return left.localeCompare(right); -} - -function maestroReplayTestExtensionRank(filePath: string): number { - return path.extname(filePath) === '.ad' ? 1 : 0; -} - -function uniqueNormalizedPaths(paths: string[]): string[] { - return [...new Set(paths.map((entry) => path.normalize(entry)))]; -} - -function isMaestroReplayBackend(replayBackend: string | undefined): boolean { - return replayBackend === 'maestro'; -} - function readReplayTestTitle( script: string, filePath: string, replayBackend: string | undefined, ): string | undefined { - return isMaestroReplayBackend(replayBackend) && path.extname(filePath) !== '.ad' - ? parseMaestroProgram(script, { sourcePath: filePath }).config.name + return resolveReplayFormat(filePath, replayBackend) === 'maestro' + ? inspectMaestroFlow(script, filePath).name : undefined; } -function looksLikeGlob(value: string): boolean { - return GLOB_PATTERN_CHARS.test(value); -} - function matchesPlatformFilter(filter: PlatformSelector, candidate: PlatformSelector): boolean { if (filter === 'apple') { return isApplePlatform(candidate); } return candidate === filter; } - -function isExistingFile(filePath: string): boolean { - try { - return fs.statSync(filePath).isFile(); - } catch { - return false; - } -} diff --git a/src/daemon/replay-device-selection.ts b/src/daemon/replay-device-selection.ts index b2f49f66f0..c0330f28a2 100644 --- a/src/daemon/replay-device-selection.ts +++ b/src/daemon/replay-device-selection.ts @@ -1,9 +1,9 @@ import fs from 'node:fs'; +import { inspectMaestroFlow } from '@agent-device/maestro'; import { parseReplayInput } from '../compat/replay-input.ts'; -import { parseMaestroProgram } from '../compat/maestro/program-ir-parser.ts'; import type { ResolveTargetDeviceOptions } from '../core/dispatch-resolve.ts'; -import type { MaestroProgram } from '../compat/maestro/program-ir.ts'; import { isDeepLinkTarget } from '@agent-device/contracts/command'; +import { resolveReplayFormat } from '../replay/format.ts'; import { appleSimulatorAppTargetForOpenTarget } from './open-device-selection.ts'; import { SessionStore } from './session-store.ts'; import type { CommandFlags } from '../core/dispatch.ts'; @@ -28,11 +28,12 @@ export function buildReplayTargetDeviceResolution( try { const resolved = SessionStore.expandHome(filePath, req.meta?.cwd); const source = fs.readFileSync(resolved, 'utf8'); - if (isMaestroReplay(req, resolved)) { + if (resolveReplayFormat(resolved, req.flags?.replayBackend) === 'maestro') { + const flow = inspectMaestroFlow(source, resolved); return { flags: req.flags ?? {}, options: buildMaestroReplayTargetDeviceResolutionOptions( - parseMaestroProgram(source, { sourcePath: resolved }), + flow.appTarget, req.flags?.platform, ), }; @@ -55,21 +56,13 @@ export function buildReplayTargetDeviceResolution( } export function buildMaestroReplayTargetDeviceResolutionOptions( - program: MaestroProgram, + appTarget: string | undefined, platform: CommandFlags['platform'] | undefined, ): ResolveTargetDeviceOptions { if (platform !== 'ios') return {}; - const appTarget = readMaestroReplayAppTarget(program); return appTargetResolutionOptions(appTarget) ?? {}; } -function isMaestroReplay(req: DaemonRequest, filePath: string): boolean { - return ( - req.flags?.replayBackend === 'maestro' && - (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) - ); -} - function readScriptReplaySelection(actions: SessionAction[]): { appTarget: string | undefined; platform: CommandFlags['platform'] | undefined; @@ -101,15 +94,6 @@ export function buildReplayScriptPlatformFlags( return { ...flags, platform: selection.platform }; } -function readMaestroReplayAppTarget(program: MaestroProgram): string | undefined { - const configured = program.config.appId; - if (isStaticAppTarget(configured)) return configured; - const launch = [...(program.config.onFlowStart ?? []), ...program.commands].find( - (command) => command.kind === 'launchApp' && isStaticAppTarget(command.appId), - ); - return launch?.kind === 'launchApp' ? launch.appId : undefined; -} - function isStaticAppTarget(value: string | undefined): value is string { return Boolean(value && value.trim() && !value.includes('$') && !isDeepLinkTarget(value)); } diff --git a/src/daemon/replay-source-discovery.ts b/src/daemon/replay-source-discovery.ts new file mode 100644 index 0000000000..36c29f0670 --- /dev/null +++ b/src/daemon/replay-source-discovery.ts @@ -0,0 +1,126 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; +import { SessionStore } from './session-store.ts'; + +const GLOB_PATTERN_CHARS = /[*?[\]{}]/; +type ReplayInputSource = 'directory' | 'file' | 'glob'; + +/** + * One traversal and ordering policy for both replay engines. It intentionally + * matches Maestro: explicit files lead, directories use native DFS order, and + * glob groups are extension-ranked then lexicographic. + */ +export function discoverReplaySourcePaths(params: { + inputs: string[]; + cwd: string; + replayBackend?: string; +}): string[] { + const extensions = + params.replayBackend === 'maestro' ? new Set(['.yaml', '.yml', '.ad']) : new Set(['.ad']); + const explicitFiles: string[] = []; + const expandedGroups: string[][] = []; + + for (const input of params.inputs) { + const expanded = expandReplayInput(input, params.cwd, extensions); + if (expanded.source === 'file') { + explicitFiles.push(...expanded.paths); + continue; + } + expandedGroups.push( + expanded.source === 'directory' + ? uniqueNormalizedPaths(expanded.paths) + : sortExpandedPaths(expanded.paths), + ); + } + return uniqueNormalizedPaths([...explicitFiles, ...expandedGroups.flat()]); +} + +function expandReplayInput( + input: string, + cwd: string, + extensions: ReadonlySet, +): { paths: string[]; source: ReplayInputSource } { + const expandedInput = SessionStore.expandHome(input, cwd); + if (fs.existsSync(expandedInput)) { + const stat = fs.statSync(expandedInput); + if (stat.isDirectory()) { + return { + paths: readDirectoryPaths(expandedInput, extensions), + source: 'directory', + }; + } + if (stat.isFile()) { + if (!extensions.has(path.extname(expandedInput).toLowerCase())) { + throw new AppError('INVALID_ARGS', `test does not support this file type: ${input}`); + } + return { paths: [expandedInput], source: 'file' }; + } + return { paths: [], source: 'file' }; + } + + if (!looksLikeGlob(input) && !looksLikeGlob(expandedInput)) { + throw new AppError('INVALID_ARGS', `test input not found: ${input}`); + } + + const pattern = path.isAbsolute(expandedInput) ? expandedInput : input; + const matches = fs.globSync(pattern, { + cwd: path.isAbsolute(expandedInput) ? undefined : cwd, + }); + return { + source: 'glob', + paths: matches + .map((match) => (path.isAbsolute(match) ? match : path.resolve(cwd, match))) + .filter( + (match) => extensions.has(path.extname(match).toLowerCase()) && isExistingFile(match), + ), + }; +} + +function readDirectoryPaths(directoryPath: string, extensions: ReadonlySet): string[] { + const paths: string[] = []; + const directory = fs.opendirSync(directoryPath); + try { + let entry: fs.Dirent | null; + while ((entry = directory.readSync()) !== null) { + const filePath = path.join(directoryPath, entry.name); + if (entry.isDirectory()) { + paths.push(...readDirectoryPaths(filePath, extensions)); + } else if (entry.isFile() && extensions.has(path.extname(entry.name).toLowerCase())) { + paths.push(filePath); + } + } + } finally { + directory.closeSync(); + } + return paths; +} + +function sortExpandedPaths(paths: string[]): string[] { + return paths + .map((entry) => path.normalize(entry)) + .sort((left, right) => { + const rank = extensionRank(left) - extensionRank(right); + return rank === 0 ? left.localeCompare(right) : rank; + }); +} + +function extensionRank(filePath: string): number { + return path.extname(filePath).toLowerCase() === '.ad' ? 1 : 0; +} + +function uniqueNormalizedPaths(paths: string[]): string[] { + return [...new Set(paths.map((entry) => path.normalize(entry)))]; +} + +function looksLikeGlob(value: string): boolean { + return GLOB_PATTERN_CHARS.test(value); +} + +function isExistingFile(filePath: string): boolean { + try { + return fs.statSync(filePath).isFile(); + } catch { + return false; + } +} diff --git a/src/replay/__tests__/format.test.ts b/src/replay/__tests__/format.test.ts new file mode 100644 index 0000000000..369c68eb3d --- /dev/null +++ b/src/replay/__tests__/format.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from 'vitest'; +import { resolveReplayFormat } from '../format.ts'; + +describe('resolveReplayFormat', () => { + test('keeps .ad on the native engine even when Maestro is requested', () => { + expect(resolveReplayFormat('/flows/login.ad', 'maestro')).toBe('ad'); + }); + + test.each(['/flows/login.yaml', '/flows/login.yml'])( + 'routes %s to Maestro only when explicitly requested', + (sourcePath) => { + expect(resolveReplayFormat(sourcePath, 'maestro')).toBe('maestro'); + expect(resolveReplayFormat(sourcePath, undefined)).toBe('ad'); + }, + ); +}); diff --git a/src/replay/format.ts b/src/replay/format.ts new file mode 100644 index 0000000000..6845e89e80 --- /dev/null +++ b/src/replay/format.ts @@ -0,0 +1,17 @@ +import path from 'node:path'; + +export type ReplayFormat = 'ad' | 'maestro'; + +/** + * Selects an engine from the authored path and explicit backend request. + * Content is never probed and engines never fall back to one another. + */ +export function resolveReplayFormat( + sourcePath: string, + replayBackend: string | undefined, +): ReplayFormat { + const extension = path.extname(sourcePath).toLowerCase(); + return replayBackend === 'maestro' && (extension === '.yaml' || extension === '.yml') + ? 'maestro' + : 'ad'; +} diff --git a/src/snapshot/snapshot-processing.ts b/src/snapshot/snapshot-processing.ts index 0bd26b30a8..9e9af0cc24 100644 --- a/src/snapshot/snapshot-processing.ts +++ b/src/snapshot/snapshot-processing.ts @@ -132,19 +132,6 @@ export function findSnapshotAncestor( return null; } -export function isDescendantOfSnapshotNode( - nodes: SnapshotState['nodes'], - node: SnapshotNode, - ancestor: SnapshotNode, - nodeByIndex: ReadonlyMap, -): boolean { - return Boolean( - findSnapshotAncestor(nodes, node, nodeByIndex, (candidate) => - candidate === ancestor || candidate.index === ancestor.index ? candidate : null, - ), - ); -} - /** * Returns the nearest ancestor matching `predicate`; false means keep walking. */ diff --git a/test/integration/provider-scenarios/android-test-suite.test.ts b/test/integration/provider-scenarios/android-test-suite.test.ts index 347a9a9da4..18fd2238d2 100644 --- a/test/integration/provider-scenarios/android-test-suite.test.ts +++ b/test/integration/provider-scenarios/android-test-suite.test.ts @@ -5,351 +5,382 @@ import { test } from 'vitest'; import { createAndroidSettingsWorld } from './android-world.ts'; import { withProviderScenarioResource } from './harness.ts'; import { ANDROID_TEST_SUITE_CONTRACT_EVIDENCE } from './android-test-suite.coverage.ts'; +import { PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS } from './test-timeouts.ts'; -test(ANDROID_TEST_SUITE_CONTRACT_EVIDENCE.testName, async () => { - await withProviderScenarioResource(createAndroidSettingsWorld, async (world) => { - const client = world.daemon.client(); - const suiteRoot = path.join(world.tempRoot, 'suite-flags'); - fs.mkdirSync(suiteRoot, { recursive: true }); - - const passingScript = path.join(suiteRoot, '01-pass.ad'); - fs.writeFileSync( - passingScript, - [ - 'context platform=android', - 'open settings', - 'snapshot -i', - 'is visible label=Apps', - '', - ].join('\n'), - ); - const passingSuite = await client.replay.test({ - paths: [passingScript], - retries: 1, - artifactsDir: path.join(suiteRoot, 'passing-artifacts'), - ...world.selection, - }); - assert.equal(passingSuite.total, 1, JSON.stringify(passingSuite)); - assert.equal(passingSuite.passed, 1, JSON.stringify(passingSuite)); - assert.equal(passingSuite.failed, 0, JSON.stringify(passingSuite)); - const passingTests = passingSuite.tests as Array>; - assert.equal(passingTests[0]?.attempts, 1, JSON.stringify(passingSuite)); - - const failFastRoot = path.join(suiteRoot, 'fail-fast'); - fs.mkdirSync(failFastRoot, { recursive: true }); - fs.writeFileSync( - path.join(failFastRoot, '01-fail.ad'), - [ - 'context platform=android', - 'open settings', - 'snapshot -i', - 'is visible label=Missing', - '', - ].join('\n'), - ); - fs.writeFileSync( - path.join(failFastRoot, '02-not-run.ad'), - [ - 'context platform=android', - 'open settings', - 'snapshot -i', - 'is visible label=Apps', - '', - ].join('\n'), - ); - const failFastSuite = await client.replay.test({ - paths: [failFastRoot], - failFast: true, - artifactsDir: path.join(suiteRoot, 'fail-fast-artifacts'), - ...world.selection, - }); - assert.equal(failFastSuite.total, 2, JSON.stringify(failFastSuite)); - assert.equal(failFastSuite.executed, 1, JSON.stringify(failFastSuite)); - assert.equal(failFastSuite.failed, 1, JSON.stringify(failFastSuite)); - assert.equal(failFastSuite.notRun, 1, JSON.stringify(failFastSuite)); - }); -}); - -test('Provider-backed integration Android Maestro refreshes action geometry and preserves authored swipe points', async () => { - let snapshots = 0; - await withProviderScenarioResource( - async () => - await createAndroidSettingsWorld({ - snapshotXml: () => { - snapshots += 1; - return androidMaestroReplayXml( - snapshots === 1 ? '[16,24][374,80]' : '[100,300][260,360]', - ); - }, - }), - async (world) => { +test( + ANDROID_TEST_SUITE_CONTRACT_EVIDENCE.testName, + async () => { + await withProviderScenarioResource(createAndroidSettingsWorld, async (world) => { const client = world.daemon.client(); - const suiteRoot = path.join(world.tempRoot, 'suite-maestro'); + const suiteRoot = path.join(world.tempRoot, 'suite-flags'); fs.mkdirSync(suiteRoot, { recursive: true }); - const flowPath = path.join(suiteRoot, 'maestro-flow.yaml'); + + const passingScript = path.join(suiteRoot, '01-pass.ad'); fs.writeFileSync( - flowPath, + passingScript, [ - 'appId: com.android.settings', - '---', - '- launchApp', - '- assertVisible:', - ' id: android:id/title', - '- tapOn: Search', - '- swipe:', - ' start: 90%, 50%', - ' end: 10%, 50%', - ' duration: 300', + 'context platform=android', + 'open settings', + 'snapshot -i', + 'is visible label=Apps', '', ].join('\n'), ); - - const suite = await client.replay.test({ - paths: [flowPath], - backend: 'maestro', - artifactsDir: path.join(suiteRoot, 'artifacts'), - timeoutMs: 30000, + const passingSuite = await client.replay.test({ + paths: [passingScript], + retries: 1, + artifactsDir: path.join(suiteRoot, 'passing-artifacts'), ...world.selection, }); + assert.equal(passingSuite.total, 1, JSON.stringify(passingSuite)); + assert.equal(passingSuite.passed, 1, JSON.stringify(passingSuite)); + assert.equal(passingSuite.failed, 0, JSON.stringify(passingSuite)); + const passingTests = passingSuite.tests as Array>; + assert.equal(passingTests[0]?.attempts, 1, JSON.stringify(passingSuite)); - assert.equal(suite.total, 1, JSON.stringify(suite)); - assert.equal(suite.passed, 1, JSON.stringify(suite)); - assert.equal(suite.failed, 0, JSON.stringify(suite)); - assert.deepEqual( - world.adbCalls.find((call) => call.slice(0, 3).join(' ') === 'shell input tap'), - ['shell', 'input', 'tap', '180', '330'], - ); - assert.equal(world.touchInjectionCalls.length, 1); - const swipePlan = world.touchInjectionCalls[0]!; - assert.equal(swipePlan.intent, 'pan'); - assert.equal(swipePlan.durationMs, 300); - assert.deepEqual(swipePlan.pointers[0]?.samples[0]?.point, { x: 351, y: 300 }); - assert.deepEqual(swipePlan.pointers[0]?.samples.at(-1)?.point, { x: 39, y: 300 }); - assert.equal(world.gestureViewportCalls, 1); - // Assertion, launch/tap stability comparisons, and fresh tap geometry share retained baselines. - assert.equal(snapshots, 4); - }, - ); -}); - -test('Provider-backed integration Android Maestro replay test suite discovers YAML flows in directories', async () => { - let snapshots = 0; - await withProviderScenarioResource( - async () => - await createAndroidSettingsWorld({ - snapshotXml: () => { - snapshots += 1; - return androidMaestroReplayXml('[100,300][260,360]'); - }, - }), - async (world) => { - const client = world.daemon.client(); - const suiteRoot = path.join(world.tempRoot, 'suite-maestro-directory'); - fs.mkdirSync(suiteRoot, { recursive: true }); + const failFastRoot = path.join(suiteRoot, 'fail-fast'); + fs.mkdirSync(failFastRoot, { recursive: true }); + const failingScript = path.join(failFastRoot, '01-fail.ad'); + const notRunScript = path.join(failFastRoot, '02-not-run.ad'); fs.writeFileSync( - path.join(suiteRoot, '01-visible.yaml'), - ['appId: com.android.settings', '---', '- launchApp', '- assertVisible: Apps', ''].join( - '\n', - ), - ); - fs.writeFileSync( - path.join(suiteRoot, '02-tap.yml'), - ['appId: com.android.settings', '---', '- launchApp', '- tapOn: Search', ''].join('\n'), - ); - - const suite = await client.replay.test({ - paths: [suiteRoot], - backend: 'maestro', - artifactsDir: path.join(suiteRoot, 'artifacts'), - timeoutMs: 30000, - ...world.selection, - }); - - assert.equal(suite.total, 2, JSON.stringify(suite)); - assert.equal(suite.executed, 2, JSON.stringify(suite)); - assert.equal(suite.passed, 2, JSON.stringify(suite)); - assert.equal(suite.failed, 0, JSON.stringify(suite)); - assert.deepEqual( - world.adbCalls.find((call) => call.slice(0, 3).join(' ') === 'shell input tap'), - ['shell', 'input', 'tap', '180', '330'], - ); - assert.equal( - world.adbCalls.filter((call) => call.slice(0, 3).join(' ') === 'shell am force-stop') - .length, - 2, - ); - assertSnapshotCountInRange(snapshots, 2, 3); - }, - ); -}); - -test('Provider-backed integration Android Maestro types after tapOn inputText without trailing Enter', async () => { - await withProviderScenarioResource( - async () => await createAndroidSettingsWorld({ nativeTextInjection: true }), - async (world) => { - const client = world.daemon.client(); - const suiteRoot = path.join(world.tempRoot, 'suite-maestro-input'); - fs.mkdirSync(suiteRoot, { recursive: true }); - const flowPath = path.join(suiteRoot, 'input-only.yaml'); - fs.writeFileSync( - flowPath, + failingScript, [ - 'appId: com.android.settings', - '---', - '- launchApp', - '- tapOn: Search', - '- inputText: "Łódź café"', + 'context platform=android', + 'open settings', + 'snapshot -i', + 'is visible label=Missing', '', ].join('\n'), ); - - const suite = await client.replay.test({ - paths: [flowPath], - backend: 'maestro', - artifactsDir: path.join(suiteRoot, 'artifacts'), - timeoutMs: 30000, - ...world.selection, - }); - - assert.equal(suite.total, 1, JSON.stringify(suite)); - assert.equal(suite.passed, 1, JSON.stringify(suite)); - assert.equal(suite.failed, 0, JSON.stringify(suite)); - assert.deepEqual(world.textInjectionCalls, [ - { - action: 'type', - text: 'Łódź café', - delayMs: 0, - }, - ]); - assert.deepEqual( - world.adbCalls.find((call) => call.slice(0, 3).join(' ') === 'shell input tap'), - ['shell', 'input', 'tap', '195', '52'], - ); - assert.equal( - world.adbCalls.some( - (call) => call[0] === 'shell' && call[1] === 'input' && call[2] === 'text', - ), - false, - JSON.stringify(world.adbCalls), - ); - assert.equal( - world.adbCalls.some((call) => call.slice(0, 4).join(' ') === 'shell input keyevent ENTER'), - false, - JSON.stringify(world.adbCalls), - ); - world.assertNoHostAdbCalls(); - }, - ); -}); - -test('Provider-backed integration Android Maestro preserves authored inputText then pressKey Enter', async () => { - await withProviderScenarioResource( - async () => await createAndroidSettingsWorld({ nativeTextInjection: true }), - async (world) => { - const client = world.daemon.client(); - const suiteRoot = path.join(world.tempRoot, 'suite-maestro-input-submit'); - fs.mkdirSync(suiteRoot, { recursive: true }); - const flowPath = path.join(suiteRoot, 'input-submit.yaml'); fs.writeFileSync( - flowPath, + notRunScript, [ - 'appId: com.android.settings', - '---', - '- launchApp', - '- tapOn: Search', - '- inputText: "Łódź café"', - '- pressKey: Enter', + 'context platform=android', + 'open settings', + 'snapshot -i', + 'is visible label=Apps', '', ].join('\n'), ); - - const suite = await client.replay.test({ - paths: [flowPath], - backend: 'maestro', - artifactsDir: path.join(suiteRoot, 'artifacts'), - timeoutMs: 30000, + const failFastSuite = await client.replay.test({ + paths: [failingScript, notRunScript], + failFast: true, + artifactsDir: path.join(suiteRoot, 'fail-fast-artifacts'), ...world.selection, }); + assert.equal(failFastSuite.total, 2, JSON.stringify(failFastSuite)); + assert.equal(failFastSuite.executed, 1, JSON.stringify(failFastSuite)); + assert.equal(failFastSuite.failed, 1, JSON.stringify(failFastSuite)); + assert.equal(failFastSuite.notRun, 1, JSON.stringify(failFastSuite)); + }); + }, + PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS, +); - assert.equal(suite.total, 1, JSON.stringify(suite)); - assert.equal(suite.passed, 1, JSON.stringify(suite)); - assert.equal(suite.failed, 0, JSON.stringify(suite)); - assert.deepEqual(world.textInjectionCalls, [ - { - action: 'type', - text: 'Łódź café', - delayMs: 0, - }, - ]); - assert.equal( - world.adbCalls.some( - (call) => call[0] === 'shell' && call[1] === 'input' && call[2] === 'text', - ), - false, - JSON.stringify(world.adbCalls), - ); - assert.deepEqual( - world.adbCalls.find((call) => call.slice(0, 4).join(' ') === 'shell input keyevent ENTER'), - ['shell', 'input', 'keyevent', 'ENTER'], - ); - world.assertNoHostAdbCalls(); - }, - ); -}); +test( + 'Provider-backed integration Android Maestro refreshes action geometry and preserves authored swipe points', + async () => { + let snapshots = 0; + await withProviderScenarioResource( + async () => + await createAndroidSettingsWorld({ + snapshotXml: () => { + snapshots += 1; + return androidMaestroReplayXml( + snapshots === 1 ? '[16,24][374,80]' : '[100,300][260,360]', + ); + }, + }), + async (world) => { + const client = world.daemon.client(); + const suiteRoot = path.join(world.tempRoot, 'suite-maestro'); + fs.mkdirSync(suiteRoot, { recursive: true }); + const flowPath = path.join(suiteRoot, 'maestro-flow.yaml'); + fs.writeFileSync( + flowPath, + [ + 'appId: com.android.settings', + '---', + '- launchApp', + '- assertVisible:', + ' id: android:id/title', + '- tapOn: Search', + '- swipe:', + ' start: 90%, 50%', + ' end: 10%, 50%', + ' duration: 300', + '', + ].join('\n'), + ); -test('Provider-backed integration Android Maestro executes runFlow conditions and retry batches at runtime', async () => { - let snapshots = 0; - await withProviderScenarioResource( - async () => - await createAndroidSettingsWorld({ - snapshotXml: () => { - snapshots += 1; - return androidMaestroReplayXml('[100,300][260,360]'); - }, - }), - async (world) => { - const client = world.daemon.client(); - const suiteRoot = path.join(world.tempRoot, 'suite-maestro-runtime-flow'); - fs.mkdirSync(suiteRoot, { recursive: true }); - const flowPath = path.join(suiteRoot, 'runtime-flow.yaml'); - fs.writeFileSync( - flowPath, - [ - 'appId: com.android.settings', - '---', - '- launchApp', - '- runFlow:', - ' when:', - ' visible: Apps', - ' commands:', - ' - tapOn: Search', - '- retry:', - ' maxRetries: 1', - ' commands:', - ' - assertVisible: Apps', - '', - ].join('\n'), - ); + const suite = await client.replay.test({ + paths: [flowPath], + backend: 'maestro', + artifactsDir: path.join(suiteRoot, 'artifacts'), + timeoutMs: 30000, + ...world.selection, + }); - const suite = await client.replay.test({ - paths: [flowPath], - backend: 'maestro', - artifactsDir: path.join(suiteRoot, 'artifacts'), - timeoutMs: 30000, - ...world.selection, - }); + assert.equal(suite.total, 1, JSON.stringify(suite)); + assert.equal(suite.passed, 1, JSON.stringify(suite)); + assert.equal(suite.failed, 0, JSON.stringify(suite)); + assert.deepEqual( + world.adbCalls.find((call) => call.slice(0, 3).join(' ') === 'shell input tap'), + ['shell', 'input', 'tap', '180', '330'], + ); + assert.equal(world.touchInjectionCalls.length, 1); + const swipePlan = world.touchInjectionCalls[0]!; + assert.equal(swipePlan.intent, 'pan'); + assert.equal(swipePlan.durationMs, 300); + assert.deepEqual(swipePlan.pointers[0]?.samples[0]?.point, { x: 351, y: 300 }); + assert.deepEqual(swipePlan.pointers[0]?.samples.at(-1)?.point, { x: 39, y: 300 }); + assert.equal(world.gestureViewportCalls, 1); + // Assertion, launch/tap stability comparisons, and fresh tap geometry share retained baselines. + assert.equal(snapshots, 4); + }, + ); + }, + PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS, +); - assert.equal(suite.total, 1, JSON.stringify(suite)); - assert.equal(suite.passed, 1, JSON.stringify(suite)); - assert.equal(suite.failed, 0, JSON.stringify(suite)); - assert.deepEqual( - world.adbCalls.filter((call) => call.slice(0, 3).join(' ') === 'shell input tap'), - [['shell', 'input', 'tap', '180', '330']], - ); - assertSnapshotCountInRange(snapshots, 3, 4); - }, - ); -}); +test( + 'Provider-backed integration Android Maestro replay test suite discovers YAML flows in directories', + async () => { + let snapshots = 0; + await withProviderScenarioResource( + async () => + await createAndroidSettingsWorld({ + snapshotXml: () => { + snapshots += 1; + return androidMaestroReplayXml('[100,300][260,360]'); + }, + }), + async (world) => { + const client = world.daemon.client(); + const suiteRoot = path.join(world.tempRoot, 'suite-maestro-directory'); + fs.mkdirSync(suiteRoot, { recursive: true }); + fs.writeFileSync( + path.join(suiteRoot, '01-visible.yaml'), + ['appId: com.android.settings', '---', '- launchApp', '- assertVisible: Apps', ''].join( + '\n', + ), + ); + fs.writeFileSync( + path.join(suiteRoot, '02-tap.yml'), + ['appId: com.android.settings', '---', '- launchApp', '- tapOn: Search', ''].join('\n'), + ); + + const suite = await client.replay.test({ + paths: [suiteRoot], + backend: 'maestro', + artifactsDir: path.join(suiteRoot, 'artifacts'), + timeoutMs: 30000, + ...world.selection, + }); + + assert.equal(suite.total, 2, JSON.stringify(suite)); + assert.equal(suite.executed, 2, JSON.stringify(suite)); + assert.equal(suite.passed, 2, JSON.stringify(suite)); + assert.equal(suite.failed, 0, JSON.stringify(suite)); + assert.deepEqual( + world.adbCalls.find((call) => call.slice(0, 3).join(' ') === 'shell input tap'), + ['shell', 'input', 'tap', '180', '330'], + ); + assert.equal( + world.adbCalls.filter((call) => call.slice(0, 3).join(' ') === 'shell am force-stop') + .length, + 2, + ); + assertSnapshotCountInRange(snapshots, 2, 3); + }, + ); + }, + PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS, +); + +test( + 'Provider-backed integration Android Maestro types after tapOn inputText without trailing Enter', + async () => { + await withProviderScenarioResource( + async () => await createAndroidSettingsWorld({ nativeTextInjection: true }), + async (world) => { + const client = world.daemon.client(); + const suiteRoot = path.join(world.tempRoot, 'suite-maestro-input'); + fs.mkdirSync(suiteRoot, { recursive: true }); + const flowPath = path.join(suiteRoot, 'input-only.yaml'); + fs.writeFileSync( + flowPath, + [ + 'appId: com.android.settings', + '---', + '- launchApp', + '- tapOn: Search', + '- inputText: "Łódź café"', + '', + ].join('\n'), + ); + + const suite = await client.replay.test({ + paths: [flowPath], + backend: 'maestro', + artifactsDir: path.join(suiteRoot, 'artifacts'), + timeoutMs: 30000, + ...world.selection, + }); + + assert.equal(suite.total, 1, JSON.stringify(suite)); + assert.equal(suite.passed, 1, JSON.stringify(suite)); + assert.equal(suite.failed, 0, JSON.stringify(suite)); + assert.deepEqual(world.textInjectionCalls, [ + { + action: 'type', + text: 'Łódź café', + delayMs: 0, + }, + ]); + assert.deepEqual( + world.adbCalls.find((call) => call.slice(0, 3).join(' ') === 'shell input tap'), + ['shell', 'input', 'tap', '195', '52'], + ); + assert.equal( + world.adbCalls.some( + (call) => call[0] === 'shell' && call[1] === 'input' && call[2] === 'text', + ), + false, + JSON.stringify(world.adbCalls), + ); + assert.equal( + world.adbCalls.some( + (call) => call.slice(0, 4).join(' ') === 'shell input keyevent ENTER', + ), + false, + JSON.stringify(world.adbCalls), + ); + world.assertNoHostAdbCalls(); + }, + ); + }, + PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS, +); + +test( + 'Provider-backed integration Android Maestro preserves authored inputText then pressKey Enter', + async () => { + await withProviderScenarioResource( + async () => await createAndroidSettingsWorld({ nativeTextInjection: true }), + async (world) => { + const client = world.daemon.client(); + const suiteRoot = path.join(world.tempRoot, 'suite-maestro-input-submit'); + fs.mkdirSync(suiteRoot, { recursive: true }); + const flowPath = path.join(suiteRoot, 'input-submit.yaml'); + fs.writeFileSync( + flowPath, + [ + 'appId: com.android.settings', + '---', + '- launchApp', + '- tapOn: Search', + '- inputText: "Łódź café"', + '- pressKey: Enter', + '', + ].join('\n'), + ); + + const suite = await client.replay.test({ + paths: [flowPath], + backend: 'maestro', + artifactsDir: path.join(suiteRoot, 'artifacts'), + timeoutMs: 30000, + ...world.selection, + }); + + assert.equal(suite.total, 1, JSON.stringify(suite)); + assert.equal(suite.passed, 1, JSON.stringify(suite)); + assert.equal(suite.failed, 0, JSON.stringify(suite)); + assert.deepEqual(world.textInjectionCalls, [ + { + action: 'type', + text: 'Łódź café', + delayMs: 0, + }, + ]); + assert.equal( + world.adbCalls.some( + (call) => call[0] === 'shell' && call[1] === 'input' && call[2] === 'text', + ), + false, + JSON.stringify(world.adbCalls), + ); + assert.deepEqual( + world.adbCalls.find( + (call) => call.slice(0, 4).join(' ') === 'shell input keyevent ENTER', + ), + ['shell', 'input', 'keyevent', 'ENTER'], + ); + world.assertNoHostAdbCalls(); + }, + ); + }, + PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS, +); + +test( + 'Provider-backed integration Android Maestro executes runFlow conditions and retry batches at runtime', + async () => { + let snapshots = 0; + await withProviderScenarioResource( + async () => + await createAndroidSettingsWorld({ + snapshotXml: () => { + snapshots += 1; + return androidMaestroReplayXml('[100,300][260,360]'); + }, + }), + async (world) => { + const client = world.daemon.client(); + const suiteRoot = path.join(world.tempRoot, 'suite-maestro-runtime-flow'); + fs.mkdirSync(suiteRoot, { recursive: true }); + const flowPath = path.join(suiteRoot, 'runtime-flow.yaml'); + fs.writeFileSync( + flowPath, + [ + 'appId: com.android.settings', + '---', + '- launchApp', + '- runFlow:', + ' when:', + ' visible: Apps', + ' commands:', + ' - tapOn: Search', + '- retry:', + ' maxRetries: 1', + ' commands:', + ' - assertVisible: Apps', + '', + ].join('\n'), + ); + + const suite = await client.replay.test({ + paths: [flowPath], + backend: 'maestro', + artifactsDir: path.join(suiteRoot, 'artifacts'), + timeoutMs: 30000, + ...world.selection, + }); + + assert.equal(suite.total, 1, JSON.stringify(suite)); + assert.equal(suite.passed, 1, JSON.stringify(suite)); + assert.equal(suite.failed, 0, JSON.stringify(suite)); + assert.deepEqual( + world.adbCalls.filter((call) => call.slice(0, 3).join(' ') === 'shell input tap'), + [['shell', 'input', 'tap', '180', '330']], + ); + assertSnapshotCountInRange(snapshots, 3, 4); + }, + ); + }, + PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS, +); test('Provider-backed integration Android Maestro optional tap misses without touching the device', async () => { await withProviderScenarioResource(createAndroidSettingsWorld, async (world) => { @@ -388,7 +419,7 @@ test('Provider-backed integration Android Maestro optional tap misses without to JSON.stringify(world.adbCalls), ); }); -}, 10_000); +}, 15_000); function androidMaestroReplayXml(searchBounds: string): string { return [ From 901b7aa80b5681e9587cc98901b3430aa142f2ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 30 Jul 2026 19:40:59 +0200 Subject: [PATCH 2/2] perf: deepen maestro facade boundaries --- .fallowrc.json | 12 +- package.json | 12 +- packages/maestro/package.json | 1 + packages/maestro/src/index.test.ts | 29 +++ packages/maestro/src/index.ts | 209 +++--------------- .../maestro/src/internal/facade-execution.ts | 18 +- .../maestro/src/internal/facade-export.ts | 6 + .../src/internal/facade-runtime-port.ts | 134 +++++++++++ .../maestro/src/internal/facade-support.ts | 7 + .../src/internal/program-ir-command-parser.ts | 2 +- .../flows/optional-scroll-and-wait.yaml | 0 .../flows/optional-warned-not-failed.yaml | 0 .../differential/flows/percent-swipe.yaml | 0 .../differential/flows/settle-after-tap.yaml | 0 .../flows/tap-retry-if-no-change.yaml | 0 .../flows/wait-animation-between-taps.yaml | 0 .../differential/invariants.test.ts | 2 +- .../conformance}/differential/invariants.ts | 0 .../conformance}/differential/run.test.ts | 0 .../test/conformance}/differential/run.ts | 2 +- .../conformance}/differential/scenarios.ts | 4 +- .../test/conformance}/expected-divergence.ts | 0 .../maestro/test/conformance/fixture-seal.ts | 17 ++ packages/maestro/test/conformance/harness.ts | 47 ++++ .../maestro/test/conformance}/verify.test.ts | 14 +- .../maestro/test/conformance}/verify.ts | 18 +- pnpm-lock.yaml | 6 +- scripts/maestro-conformance/README.md | 15 +- scripts/maestro-conformance/fixture-seal.mjs | 29 --- scripts/maestro-conformance/regenerate.mjs | 6 +- .../daemon-runtime-port-observation.ts | 7 +- .../daemon-runtime-port-snapshot-source.ts | 4 +- .../adapters/maestro/daemon-runtime-port.ts | 19 +- .../adapters/maestro/daemon-runtime-tap.ts | 7 +- .../adapters/maestro/failure-snapshot.ts | 10 + .../maestro/maestro-screenshot-comparison.ts | 6 +- .../__tests__/session-replay-runtime.test.ts | 25 +++ .../__tests__/session-test-discovery.test.ts | 3 +- .../session-replay-maestro-failure.ts | 5 +- src/daemon/handlers/session-replay-runtime.ts | 9 +- src/daemon/handlers/session-test-artifacts.ts | 8 +- src/daemon/replay-source-discovery.ts | 17 +- src/replay/__tests__/format.test.ts | 17 +- src/replay/format.ts | 17 +- 44 files changed, 440 insertions(+), 304 deletions(-) create mode 100644 packages/maestro/src/index.test.ts create mode 100644 packages/maestro/src/internal/facade-export.ts create mode 100644 packages/maestro/src/internal/facade-runtime-port.ts create mode 100644 packages/maestro/src/internal/facade-support.ts rename {scripts/maestro-conformance => packages/maestro/test/conformance}/differential/flows/optional-scroll-and-wait.yaml (100%) rename {scripts/maestro-conformance => packages/maestro/test/conformance}/differential/flows/optional-warned-not-failed.yaml (100%) rename {scripts/maestro-conformance => packages/maestro/test/conformance}/differential/flows/percent-swipe.yaml (100%) rename {scripts/maestro-conformance => packages/maestro/test/conformance}/differential/flows/settle-after-tap.yaml (100%) rename {scripts/maestro-conformance => packages/maestro/test/conformance}/differential/flows/tap-retry-if-no-change.yaml (100%) rename {scripts/maestro-conformance => packages/maestro/test/conformance}/differential/flows/wait-animation-between-taps.yaml (100%) rename {scripts/maestro-conformance => packages/maestro/test/conformance}/differential/invariants.test.ts (99%) rename {scripts/maestro-conformance => packages/maestro/test/conformance}/differential/invariants.ts (100%) rename {scripts/maestro-conformance => packages/maestro/test/conformance}/differential/run.test.ts (100%) rename {scripts/maestro-conformance => packages/maestro/test/conformance}/differential/run.ts (99%) rename {scripts/maestro-conformance => packages/maestro/test/conformance}/differential/scenarios.ts (98%) rename {scripts/maestro-conformance => packages/maestro/test/conformance}/expected-divergence.ts (100%) create mode 100644 packages/maestro/test/conformance/fixture-seal.ts create mode 100644 packages/maestro/test/conformance/harness.ts rename {scripts/maestro-conformance => packages/maestro/test/conformance}/verify.test.ts (94%) rename {scripts/maestro-conformance => packages/maestro/test/conformance}/verify.ts (94%) delete mode 100644 scripts/maestro-conformance/fixture-seal.mjs create mode 100644 src/daemon/adapters/maestro/failure-snapshot.ts diff --git a/.fallowrc.json b/.fallowrc.json index 0faeb7e8af..bfe11da29d 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -43,13 +43,13 @@ "scripts/write-xcuitest-cache-metadata.mjs", "scripts/help-conformance-sample-outputs.d.mts" ], - "ignoreDependencies": ["@theme"], + "ignoreDependencies": [ + "@theme", + // @agent-device/maestro owns the source import, while the published root + // build externalizes yaml and retains runtime imports in packed chunks. + "yaml" + ], "ignoreExports": [ - { - "comment": "Authoritative supported-command list; sole consumer is the conformance oracle (scripts/maestro-conformance, an ignored tooling tree).", - "file": "src/compat/maestro/program-ir-command-parser.ts", - "exports": ["SUPPORTED_MAESTRO_COMMAND_NAMES"] - }, { "comment": "Daemon route handlers are reached only through the dynamic `import()` table in request-handler-chain.ts, which --production analysis cannot follow to a consumer.", "file": "src/daemon/handlers/{lease,session,snapshot,react-native,record-trace,find,interaction}.ts", diff --git a/package.json b/package.json index 53ee9812ad..7d98f33473 100644 --- a/package.json +++ b/package.json @@ -106,9 +106,9 @@ "package:npm": "pnpm build && pnpm build:xcuitest:ios && pnpm build:xcuitest:macos && pnpm build:xcuitest:tvos && pnpm build:xcuitest:visionos && pnpm build:macos-helper:clean && pnpm package:apple-runner:npm && pnpm build:android", "ad": "node bin/agent-device.mjs", "bench:help-conformance": "node scripts/help-conformance-bench.mjs", - "maestro:conformance": "node --experimental-strip-types --test scripts/maestro-conformance/verify.test.ts scripts/maestro-conformance/differential/run.test.ts scripts/maestro-conformance/differential/invariants.test.ts", - "maestro:conformance:regenerate": "node scripts/maestro-conformance/regenerate.mjs", - "maestro:conformance:differential": "node --experimental-strip-types scripts/maestro-conformance/differential/run.ts", + "maestro:conformance": "node --experimental-strip-types --test packages/maestro/test/conformance/verify.test.ts packages/maestro/test/conformance/differential/run.test.ts packages/maestro/test/conformance/differential/invariants.test.ts", + "maestro:conformance:regenerate": "node --experimental-strip-types scripts/maestro-conformance/regenerate.mjs", + "maestro:conformance:differential": "node --experimental-strip-types packages/maestro/test/conformance/differential/run.ts", "prototype:program-boundary": "node --experimental-strip-types scripts/prototypes/daemon-boundaries/program-boundary.ts", "prototype:session-boundary": "node --experimental-strip-types scripts/prototypes/daemon-boundaries/session-boundary.ts", "size": "node scripts/size-report.mjs", @@ -241,7 +241,8 @@ ], "dependencies": { "@limrun/api": "^0.24.5", - "pngjs": "^7.0.0" + "pngjs": "^7.0.0", + "yaml": "^2.9.0" }, "devDependencies": { "@agent-device/contracts": "workspace:*", @@ -261,7 +262,6 @@ "tsdown": "^0.22.4", "typescript": "^7.0.2", "vite": "^8.0.16", - "vitest": "^4.1.8", - "yaml": "^2.9.0" + "vitest": "^4.1.8" } } diff --git a/packages/maestro/package.json b/packages/maestro/package.json index 2c62de2cb0..5ac0cffea2 100644 --- a/packages/maestro/package.json +++ b/packages/maestro/package.json @@ -2,6 +2,7 @@ "name": "@agent-device/maestro", "version": "0.0.0", "private": true, + "sideEffects": false, "type": "module", "description": "Private Maestro compatibility engine for agent-device.", "dependencies": { diff --git a/packages/maestro/src/index.test.ts b/packages/maestro/src/index.test.ts new file mode 100644 index 0000000000..a315158d79 --- /dev/null +++ b/packages/maestro/src/index.test.ts @@ -0,0 +1,29 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, test } from 'vitest'; +import * as maestro from './index.ts'; + +describe('@agent-device/maestro facade', () => { + test('the package root contains declarations and re-exports only', () => { + const source = fs.readFileSync(path.join(import.meta.dirname, 'index.ts'), 'utf8'); + expect(source).not.toMatch(/^(?:import|const|let|var|function|class)\b/m); + }); + + test('keeps conformance and ranking machinery private', () => { + const exports = Object.keys(maestro); + expect(exports).not.toContain('parseMaestroConformanceSource'); + expect(exports).not.toContain('canonicalizeUpstreamMaestroFlow'); + expect(exports).not.toContain('SUPPORTED_MAESTRO_COMMAND_NAMES'); + expect(exports).not.toContain('MAESTRO_CONFORMANCE_CONSTANTS'); + expect(exports).not.toContain('MAESTRO_COMPATIBILITY_PRESETS'); + expect(exports).not.toContain('rankMaestroFailureCandidates'); + }); + + test('publishes no implementation subpaths and declares pure modules', () => { + const manifest = JSON.parse( + fs.readFileSync(path.join(import.meta.dirname, '../package.json'), 'utf8'), + ) as { exports: Record; sideEffects?: boolean }; + expect(Object.keys(manifest.exports)).toEqual(['.']); + expect(manifest.sideEffects).toBe(false); + }); +}); diff --git a/packages/maestro/src/index.ts b/packages/maestro/src/index.ts index 58b711ef6f..f01c718e29 100644 --- a/packages/maestro/src/index.ts +++ b/packages/maestro/src/index.ts @@ -1,195 +1,56 @@ -import { - canonicalizeUpstreamFlow, - canonicalizeAgentCommands, - type CanonicalCommand, -} from './internal/conformance-normalize.ts'; -import { maestroTestFailure } from './internal/compatibility-errors.ts'; -import { - MAESTRO_COMPATIBILITY_PRESETS, - MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS, -} from './internal/compatibility-policy.ts'; -import { - type MaestroObservation, - type MaestroObservationCondition, - type MaestroObservationIdentity, - type MaestroRuntimeMetrics, - type MaestroRuntimePort, - type MaestroRuntimeCommand, -} from './internal/engine-types.ts'; -import { - exportReplayActionsToMaestro, - type MaestroExportOptions, - type MaestroExportResult, - type MaestroExportWarning, -} from './internal/export-flow.ts'; -import { parseMaestroProgram } from './internal/program-ir-parser.ts'; -import { SUPPORTED_MAESTRO_COMMAND_NAMES } from './internal/program-ir-command-parser.ts'; -import type { MaestroPlatform, MaestroSelector } from './internal/program-ir.ts'; -import { - executeMaestroRuntimeCommand, - maestroCommandRequiresSettledPredecessor, -} from './internal/runtime-port-commands.ts'; -import { operationContext } from './internal/runtime-port-context.ts'; -import { - maestroObservationMatches, - observeMaestroCondition, -} from './internal/runtime-port-observation.ts'; -import type { - MaestroDispatchSelector, - MaestroRuntimeOperationContext, - MaestroRuntimeOperationResult, - MaestroRuntimeReadContext, - MaestroRuntimeOperations, - MaestroSinglePointerGestureInput, - MaestroTargetMatch, - MaestroTargetQuery, -} from './internal/runtime-port-types.ts'; -import { resolveMaestroScrollableGesture } from './internal/runtime-port-geometry.ts'; -import { literalFromMaestroRegex } from './internal/selector-regex.ts'; -import { resolveMaestroTargetFromSnapshot } from './internal/runtime-targets.ts'; -import type { - MaestroTargetQuery as MaestroSnapshotTargetQuery, - MaestroTargetResolution, -} from './internal/runtime-targets.ts'; -import { - formatMaestroCompatibilityReference, - MAESTRO_COMPAT_LIMITATIONS, - MAESTRO_COMPAT_SUPPORTED_CAPABILITIES, - MAESTRO_COMPATIBILITY_ADR_URL, - MAESTRO_COMPATIBILITY_ISSUE_URL, -} from './internal/support-matrix.ts'; - -export type MaestroRuntimePortLifecycle = { - beforeExecute?(input: { - context: MaestroRuntimeOperationContext; - requiresSettledPredecessor: boolean; - visualStabilityBarrier: boolean; - }): Promise; - afterExecute?(input: { - context: MaestroRuntimeOperationContext; - visualStabilityReached: boolean; - }): void; - bindObservation?( - observation: Awaited>, - ): Awaited>; - readMetrics?(): NonNullable>>; -}; - -export type { - CanonicalCommand as MaestroCanonicalCommand, - MaestroDispatchSelector, - MaestroExportOptions, - MaestroExportResult, - MaestroExportWarning, - MaestroObservation, - MaestroObservationCondition, - MaestroObservationIdentity, - MaestroPlatform, - MaestroRuntimeMetrics, - MaestroRuntimeCommand, - MaestroRuntimeOperationContext, - MaestroRuntimeOperationResult, - MaestroRuntimeOperations, - MaestroRuntimePort, - MaestroRuntimeReadContext, - MaestroSelector, - MaestroSinglePointerGestureInput, - MaestroSnapshotTargetQuery, - MaestroTargetMatch, - MaestroTargetQuery, - MaestroTargetResolution, -}; - export { + collectMaestroFailureSuggestions, executeMaestroFlow, inspectMaestroFlow, - rankMaestroFailureCandidates, type MaestroActionEvent, type MaestroCompletedActionEvent, type MaestroExecutionObserver, type MaestroExecutionOptions, type MaestroExecutionOutcome, type MaestroFailedAction, - type MaestroFailureCandidate, type MaestroFlow, } from './internal/facade-execution.ts'; export { exportReplayActionsToMaestro, + type MaestroExportOptions, + type MaestroExportResult, + type MaestroExportWarning, +} from './internal/facade-export.ts'; + +export { formatMaestroCompatibilityReference, - literalFromMaestroRegex, - maestroObservationMatches, - maestroTestFailure, - MAESTRO_COMPATIBILITY_PRESETS, MAESTRO_COMPATIBILITY_ADR_URL, MAESTRO_COMPATIBILITY_ISSUE_URL, MAESTRO_COMPAT_LIMITATIONS, MAESTRO_COMPAT_SUPPORTED_CAPABILITIES, - MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS, +} from './internal/facade-support.ts'; + +export { + createMaestroRuntimePort, + literalFromMaestroRegex, + maestroObservationMatches, + maestroTestFailure, + MAESTRO_RUNTIME_ADAPTER_POLICY, resolveMaestroScrollableGesture, resolveMaestroTargetFromSnapshot, - SUPPORTED_MAESTRO_COMMAND_NAMES, -}; - -export const MAESTRO_CONFORMANCE_CONSTANTS = { - retryMaxRetries: MAESTRO_COMPATIBILITY_PRESETS.control.retryMaxRetries, - animationWaitThreshold: - MAESTRO_COMPATIBILITY_PRESETS.command.waitForAnimationToEndDifferencePercent, - animationWaitTimeoutMs: MAESTRO_COMPATIBILITY_PRESETS.command.waitForAnimationToEndTimeoutMs, - maxEraseCharacters: MAESTRO_COMPATIBILITY_PRESETS.command.eraseTextMaxCharacters, - swipeDurationMs: MAESTRO_COMPATIBILITY_PRESETS.command.swipeDurationMs, -} as const; - -export function parseMaestroConformanceSource( - source: string, - sourcePath: string, -): { commands: CanonicalCommand[]; kinds: Set } { - const program = parseMaestroProgram(source, { sourcePath }); - const kinds = new Set(); - collectCommandKinds(program.commands, kinds); - return { commands: canonicalizeAgentCommands(program), kinds }; -} - -export { canonicalizeUpstreamFlow as canonicalizeUpstreamMaestroFlow }; - -export function createMaestroRuntimePort( - operations: MaestroRuntimeOperations, - lifecycle: MaestroRuntimePortLifecycle = {}, -): MaestroRuntimePort { - return { - execute: async (request) => { - const context = operationContext(request, request.command); - const visualStabilityBarrier = request.command.kind === 'waitForAnimationToEnd'; - await lifecycle.beforeExecute?.({ - context, - requiresSettledPredecessor: - maestroCommandRequiresSettledPredecessor(request.command) && !visualStabilityBarrier, - visualStabilityBarrier, - }); - const result = await executeMaestroRuntimeCommand(request, operations); - lifecycle.afterExecute?.({ - context, - visualStabilityReached: visualStabilityBarrier && result.visualStabilityReached === true, - }); - delete result.visualStabilityReached; - return result; - }, - observe: async (request) => { - const observation = await observeMaestroCondition(request, operations); - return lifecycle.bindObservation?.(observation) ?? observation; - }, - ...(lifecycle.readMetrics ? { readMetrics: lifecycle.readMetrics } : {}), - }; -} - -function collectCommandKinds( - commands: Array<{ kind: string; commands?: unknown }>, - into: Set, -): void { - for (const command of commands) { - into.add(command.kind); - if (Array.isArray(command.commands)) { - collectCommandKinds(command.commands as Array<{ kind: string; commands?: unknown }>, into); - } - } -} + type MaestroDispatchSelector, + type MaestroObservation, + type MaestroObservationCondition, + type MaestroObservationIdentity, + type MaestroPlatform, + type MaestroRuntimeMetrics, + type MaestroRuntimeCommand, + type MaestroRuntimeOperationContext, + type MaestroRuntimeOperationResult, + type MaestroRuntimeOperations, + type MaestroRuntimePort, + type MaestroRuntimePortLifecycle, + type MaestroRuntimeReadContext, + type MaestroSelector, + type MaestroSinglePointerGestureInput, + type MaestroSnapshotTargetQuery, + type MaestroTargetMatch, + type MaestroTargetQuery, + type MaestroTargetResolution, +} from './internal/facade-runtime-port.ts'; diff --git a/packages/maestro/src/internal/facade-execution.ts b/packages/maestro/src/internal/facade-execution.ts index 791cff2c46..ea7ebd9140 100644 --- a/packages/maestro/src/internal/facade-execution.ts +++ b/packages/maestro/src/internal/facade-execution.ts @@ -109,11 +109,6 @@ export type MaestroExecutionOutcome = readonly failure?: MaestroFailedAction; }; -export type MaestroFailureCandidate = { - readonly node: SnapshotNode; - readonly basis: 'id' | 'label' | 'other'; -}; - export function inspectMaestroFlow(source: string, sourcePath: string): MaestroFlow { const program = parseMaestroProgram(source, { sourcePath }); return { @@ -170,10 +165,17 @@ export async function executeMaestroFlow( } } -export function rankMaestroFailureCandidates( +/** + * Converts a failed execution into report-ready values while retaining + * selector interpretation and ranking policy inside the Maestro package. + */ +export function collectMaestroFailureSuggestions( failure: MaestroFailedAction, snapshot: SnapshotState, -): MaestroFailureCandidate[] { +): ReadonlyArray<{ + readonly node: SnapshotNode; + readonly basis: 'id' | 'label' | 'other'; +}> { const query = suggestionQuery(failure[failureCommand]); const platform = failure[failurePlan].platform; if (!query || (platform !== 'android' && platform !== 'ios')) return []; @@ -289,7 +291,7 @@ function suggestionQuery(command: MaestroEngineEvent['command']): SuggestionQuer function suggestionBasis( selector: SuggestionQuery['selector'], node: SnapshotNode, -): MaestroFailureCandidate['basis'] { +): 'id' | 'label' | 'other' { if (selector.id !== undefined) return 'id'; if ( selector.text !== undefined && diff --git a/packages/maestro/src/internal/facade-export.ts b/packages/maestro/src/internal/facade-export.ts new file mode 100644 index 0000000000..a22b51ab5e --- /dev/null +++ b/packages/maestro/src/internal/facade-export.ts @@ -0,0 +1,6 @@ +export { + exportReplayActionsToMaestro, + type MaestroExportOptions, + type MaestroExportResult, + type MaestroExportWarning, +} from './export-flow.ts'; diff --git a/packages/maestro/src/internal/facade-runtime-port.ts b/packages/maestro/src/internal/facade-runtime-port.ts new file mode 100644 index 0000000000..6b8b4cca67 --- /dev/null +++ b/packages/maestro/src/internal/facade-runtime-port.ts @@ -0,0 +1,134 @@ +import { maestroTestFailure } from './compatibility-errors.ts'; +import { + MAESTRO_COMPATIBILITY_PRESETS, + MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS, +} from './compatibility-policy.ts'; +import type { + MaestroObservation, + MaestroObservationCondition, + MaestroObservationIdentity, + MaestroRuntimeMetrics, + MaestroRuntimePort, + MaestroRuntimeCommand, +} from './engine-types.ts'; +import type { MaestroPlatform, MaestroSelector } from './program-ir.ts'; +import { + executeMaestroRuntimeCommand, + maestroCommandRequiresSettledPredecessor, +} from './runtime-port-commands.ts'; +import { operationContext } from './runtime-port-context.ts'; +import { resolveMaestroScrollableGesture } from './runtime-port-geometry.ts'; +import { maestroObservationMatches, observeMaestroCondition } from './runtime-port-observation.ts'; +import type { + MaestroDispatchSelector, + MaestroRuntimeOperationContext, + MaestroRuntimeOperationResult, + MaestroRuntimeReadContext, + MaestroRuntimeOperations, + MaestroSinglePointerGestureInput, + MaestroTargetMatch, + MaestroTargetQuery, +} from './runtime-port-types.ts'; +import { literalFromMaestroRegex } from './selector-regex.ts'; +import { + resolveMaestroTargetFromSnapshot, + type MaestroTargetQuery as MaestroSnapshotTargetQuery, + type MaestroTargetResolution, +} from './runtime-targets.ts'; + +/** + * This is the complete policy vocabulary the daemon adapter must coordinate + * with the engine. Keep the compatibility-policy tree private: adding a value + * here requires a real adapter consumer and a decision-site explanation. + */ +export const MAESTRO_RUNTIME_ADAPTER_POLICY = { + settleTimeoutMs: MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS, + retryTapMaxAttempts: MAESTRO_COMPATIBILITY_PRESETS.command.retryTapMaxAttempts, + longPressDurationMs: MAESTRO_COMPATIBILITY_PRESETS.command.longPressDurationMs, + eraseTextMaxCharacters: MAESTRO_COMPATIBILITY_PRESETS.command.eraseTextMaxCharacters, + scrollUntilVisiblePercentage: MAESTRO_COMPATIBILITY_PRESETS.command.scrollUntilVisiblePercentage, + animationWaitTimeoutMs: MAESTRO_COMPATIBILITY_PRESETS.command.waitForAnimationToEndTimeoutMs, + animationDifferencePercent: + MAESTRO_COMPATIBILITY_PRESETS.command.waitForAnimationToEndDifferencePercent, + observationPollMs: MAESTRO_COMPATIBILITY_PRESETS.observation.pollIntervalMs, +} as const; + +export type MaestroRuntimePortLifecycle = { + beforeExecute?(input: { + context: MaestroRuntimeOperationContext; + requiresSettledPredecessor: boolean; + visualStabilityBarrier: boolean; + }): Promise; + afterExecute?(input: { + context: MaestroRuntimeOperationContext; + visualStabilityReached: boolean; + }): void; + bindObservation?( + observation: Awaited>, + ): Awaited>; + readMetrics?(): NonNullable>>; +}; + +/** + * The daemon is the sole runtime adapter. These types describe only the port + * values it must exchange with the engine; parser, plan, expression, and + * ranking state deliberately remain absent from the package interface. + */ +export type { + MaestroDispatchSelector, + MaestroObservation, + MaestroObservationCondition, + MaestroObservationIdentity, + MaestroPlatform, + MaestroRuntimeMetrics, + MaestroRuntimeCommand, + MaestroRuntimeOperationContext, + MaestroRuntimeOperationResult, + MaestroRuntimeOperations, + MaestroRuntimePort, + MaestroRuntimeReadContext, + MaestroSelector, + MaestroSinglePointerGestureInput, + MaestroSnapshotTargetQuery, + MaestroTargetMatch, + MaestroTargetQuery, + MaestroTargetResolution, +}; + +export { + literalFromMaestroRegex, + maestroObservationMatches, + maestroTestFailure, + resolveMaestroScrollableGesture, + resolveMaestroTargetFromSnapshot, +}; + +export function createMaestroRuntimePort( + operations: MaestroRuntimeOperations, + lifecycle: MaestroRuntimePortLifecycle = {}, +): MaestroRuntimePort { + return { + execute: async (request) => { + const context = operationContext(request, request.command); + const visualStabilityBarrier = request.command.kind === 'waitForAnimationToEnd'; + await lifecycle.beforeExecute?.({ + context, + requiresSettledPredecessor: + maestroCommandRequiresSettledPredecessor(request.command) && !visualStabilityBarrier, + visualStabilityBarrier, + }); + const result = await executeMaestroRuntimeCommand(request, operations); + lifecycle.afterExecute?.({ + context, + visualStabilityReached: visualStabilityBarrier && result.visualStabilityReached === true, + }); + delete result.visualStabilityReached; + return result; + }, + observe: async (request) => { + const observation = await observeMaestroCondition(request, operations); + return lifecycle.bindObservation?.(observation) ?? observation; + }, + ...(lifecycle.readMetrics ? { readMetrics: lifecycle.readMetrics } : {}), + }; +} diff --git a/packages/maestro/src/internal/facade-support.ts b/packages/maestro/src/internal/facade-support.ts new file mode 100644 index 0000000000..b89db5495e --- /dev/null +++ b/packages/maestro/src/internal/facade-support.ts @@ -0,0 +1,7 @@ +export { + formatMaestroCompatibilityReference, + MAESTRO_COMPATIBILITY_ADR_URL, + MAESTRO_COMPATIBILITY_ISSUE_URL, + MAESTRO_COMPAT_LIMITATIONS, + MAESTRO_COMPAT_SUPPORTED_CAPABILITIES, +} from './support-matrix.ts'; diff --git a/packages/maestro/src/internal/program-ir-command-parser.ts b/packages/maestro/src/internal/program-ir-command-parser.ts index a6b4c88aac..4dba98144b 100644 --- a/packages/maestro/src/internal/program-ir-command-parser.ts +++ b/packages/maestro/src/internal/program-ir-command-parser.ts @@ -131,7 +131,7 @@ const COMMAND_VALUE_PARSERS: Readonly> = { * The exact set of Maestro command names our engine accepts. This is the * authoritative supported surface — any name outside it is rejected by * `parseCommandValue`. The conformance oracle - * (`scripts/maestro-conformance/verify.ts`) asserts every entry is either + * (`packages/maestro/test/conformance/verify.ts`) asserts every entry is either * corpus-covered or explicitly listed as unverified. */ export const SUPPORTED_MAESTRO_COMMAND_NAMES: readonly string[] = diff --git a/scripts/maestro-conformance/differential/flows/optional-scroll-and-wait.yaml b/packages/maestro/test/conformance/differential/flows/optional-scroll-and-wait.yaml similarity index 100% rename from scripts/maestro-conformance/differential/flows/optional-scroll-and-wait.yaml rename to packages/maestro/test/conformance/differential/flows/optional-scroll-and-wait.yaml diff --git a/scripts/maestro-conformance/differential/flows/optional-warned-not-failed.yaml b/packages/maestro/test/conformance/differential/flows/optional-warned-not-failed.yaml similarity index 100% rename from scripts/maestro-conformance/differential/flows/optional-warned-not-failed.yaml rename to packages/maestro/test/conformance/differential/flows/optional-warned-not-failed.yaml diff --git a/scripts/maestro-conformance/differential/flows/percent-swipe.yaml b/packages/maestro/test/conformance/differential/flows/percent-swipe.yaml similarity index 100% rename from scripts/maestro-conformance/differential/flows/percent-swipe.yaml rename to packages/maestro/test/conformance/differential/flows/percent-swipe.yaml diff --git a/scripts/maestro-conformance/differential/flows/settle-after-tap.yaml b/packages/maestro/test/conformance/differential/flows/settle-after-tap.yaml similarity index 100% rename from scripts/maestro-conformance/differential/flows/settle-after-tap.yaml rename to packages/maestro/test/conformance/differential/flows/settle-after-tap.yaml diff --git a/scripts/maestro-conformance/differential/flows/tap-retry-if-no-change.yaml b/packages/maestro/test/conformance/differential/flows/tap-retry-if-no-change.yaml similarity index 100% rename from scripts/maestro-conformance/differential/flows/tap-retry-if-no-change.yaml rename to packages/maestro/test/conformance/differential/flows/tap-retry-if-no-change.yaml diff --git a/scripts/maestro-conformance/differential/flows/wait-animation-between-taps.yaml b/packages/maestro/test/conformance/differential/flows/wait-animation-between-taps.yaml similarity index 100% rename from scripts/maestro-conformance/differential/flows/wait-animation-between-taps.yaml rename to packages/maestro/test/conformance/differential/flows/wait-animation-between-taps.yaml diff --git a/scripts/maestro-conformance/differential/invariants.test.ts b/packages/maestro/test/conformance/differential/invariants.test.ts similarity index 99% rename from scripts/maestro-conformance/differential/invariants.test.ts rename to packages/maestro/test/conformance/differential/invariants.test.ts index 6b44b4261f..15615aca95 100644 --- a/scripts/maestro-conformance/differential/invariants.test.ts +++ b/packages/maestro/test/conformance/differential/invariants.test.ts @@ -6,7 +6,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; -import { MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS } from '@agent-device/maestro'; +import { MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS } from '../harness.ts'; import { DIFFERENTIAL_SCENARIOS } from './scenarios.ts'; import { type Invariant, evaluateInvariant, readTrace } from './invariants.ts'; diff --git a/scripts/maestro-conformance/differential/invariants.ts b/packages/maestro/test/conformance/differential/invariants.ts similarity index 100% rename from scripts/maestro-conformance/differential/invariants.ts rename to packages/maestro/test/conformance/differential/invariants.ts diff --git a/scripts/maestro-conformance/differential/run.test.ts b/packages/maestro/test/conformance/differential/run.test.ts similarity index 100% rename from scripts/maestro-conformance/differential/run.test.ts rename to packages/maestro/test/conformance/differential/run.test.ts diff --git a/scripts/maestro-conformance/differential/run.ts b/packages/maestro/test/conformance/differential/run.ts similarity index 99% rename from scripts/maestro-conformance/differential/run.ts rename to packages/maestro/test/conformance/differential/run.ts index 4677236161..47766448a8 100644 --- a/scripts/maestro-conformance/differential/run.ts +++ b/packages/maestro/test/conformance/differential/run.ts @@ -4,7 +4,7 @@ // the `maestro` CLI on PATH, and an installed target app, so it runs only from // the scheduled `conformance-differential` workflow or by hand. // -// node --experimental-strip-types scripts/maestro-conformance/differential/run.ts \ +// node --experimental-strip-types packages/maestro/test/conformance/differential/run.ts \ // --platform ios --out-dir .tmp/conformance-differential // // `--dry-run` validates the scenario registry without a device (exercised by diff --git a/scripts/maestro-conformance/differential/scenarios.ts b/packages/maestro/test/conformance/differential/scenarios.ts similarity index 98% rename from scripts/maestro-conformance/differential/scenarios.ts rename to packages/maestro/test/conformance/differential/scenarios.ts index 64c0315d94..95d34bb3f6 100644 --- a/scripts/maestro-conformance/differential/scenarios.ts +++ b/packages/maestro/test/conformance/differential/scenarios.ts @@ -17,7 +17,7 @@ // we do it engine-side via `engineInvariants` over agent-device's own replay // timing trace. Scenarios without invariants prove outcome parity ONLY; do not // read more into them than that. -import { MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS } from '@agent-device/maestro'; +import { MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS } from '../harness.ts'; import type { Invariant } from './invariants.ts'; /** Bundle id of the fixture app the workflow installs before running scenarios. */ @@ -69,7 +69,7 @@ export type DifferentialScenario = { id: string; /** The #1217 bug class this scenario guards, when applicable. */ bugClass?: 1 | 2 | 3 | 4; - /** Corpus flow, relative to scripts/maestro-conformance/. */ + /** Differential flow, relative to this package's conformance test directory. */ flow: string; /** Exactly what running both engines and comparing outcomes establishes. */ comparesAcrossEngines: string; diff --git a/scripts/maestro-conformance/expected-divergence.ts b/packages/maestro/test/conformance/expected-divergence.ts similarity index 100% rename from scripts/maestro-conformance/expected-divergence.ts rename to packages/maestro/test/conformance/expected-divergence.ts diff --git a/packages/maestro/test/conformance/fixture-seal.ts b/packages/maestro/test/conformance/fixture-seal.ts new file mode 100644 index 0000000000..453fe2c764 --- /dev/null +++ b/packages/maestro/test/conformance/fixture-seal.ts @@ -0,0 +1,17 @@ +import { createHash } from 'node:crypto'; + +// Normal CI cannot re-derive fixtures without Java, so verification recomputes +// this tamper-evident seal over generated content. The scheduled regeneration +// job closes the remaining gap by re-running the JVM harness against the pinned +// artifacts and comparing the generated bytes. +export function fixtureContentHash(fixtureWithoutSeal: unknown): string { + return createHash('sha256').update(JSON.stringify(fixtureWithoutSeal)).digest('hex'); +} + +export function checkFixtureSeal(parsedFixture: Record): { + expected: string; + actual: unknown; +} { + const { contentHash, ...content } = parsedFixture; + return { expected: fixtureContentHash(content), actual: contentHash }; +} diff --git a/packages/maestro/test/conformance/harness.ts b/packages/maestro/test/conformance/harness.ts new file mode 100644 index 0000000000..1112521009 --- /dev/null +++ b/packages/maestro/test/conformance/harness.ts @@ -0,0 +1,47 @@ +import { + canonicalizeAgentCommands, + canonicalizeUpstreamFlow, + type CanonicalCommand, +} from '../../src/internal/conformance-normalize.ts'; +import { + MAESTRO_COMPATIBILITY_PRESETS, + MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS, +} from '../../src/internal/compatibility-policy.ts'; +import { SUPPORTED_MAESTRO_COMMAND_NAMES } from '../../src/internal/program-ir-command-parser.ts'; +import { parseMaestroProgram } from '../../src/internal/program-ir-parser.ts'; + +export type { CanonicalCommand }; +export { canonicalizeUpstreamFlow, SUPPORTED_MAESTRO_COMMAND_NAMES }; + +export const MAESTRO_CONFORMANCE_CONSTANTS = { + retryMaxRetries: MAESTRO_COMPATIBILITY_PRESETS.control.retryMaxRetries, + animationWaitThreshold: + MAESTRO_COMPATIBILITY_PRESETS.command.waitForAnimationToEndDifferencePercent, + animationWaitTimeoutMs: MAESTRO_COMPATIBILITY_PRESETS.command.waitForAnimationToEndTimeoutMs, + maxEraseCharacters: MAESTRO_COMPATIBILITY_PRESETS.command.eraseTextMaxCharacters, + swipeDurationMs: MAESTRO_COMPATIBILITY_PRESETS.command.swipeDurationMs, +} as const; + +export { MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS }; + +export function parseMaestroConformanceSource( + source: string, + sourcePath: string, +): { commands: CanonicalCommand[]; kinds: Set } { + const program = parseMaestroProgram(source, { sourcePath }); + const kinds = new Set(); + collectCommandKinds(program.commands, kinds); + return { commands: canonicalizeAgentCommands(program), kinds }; +} + +function collectCommandKinds( + commands: Array<{ kind: string; commands?: unknown }>, + into: Set, +): void { + for (const command of commands) { + into.add(command.kind); + if (Array.isArray(command.commands)) { + collectCommandKinds(command.commands as Array<{ kind: string; commands?: unknown }>, into); + } + } +} diff --git a/scripts/maestro-conformance/verify.test.ts b/packages/maestro/test/conformance/verify.test.ts similarity index 94% rename from scripts/maestro-conformance/verify.test.ts rename to packages/maestro/test/conformance/verify.test.ts index 93bfc80d8f..48b13d8295 100644 --- a/scripts/maestro-conformance/verify.test.ts +++ b/packages/maestro/test/conformance/verify.test.ts @@ -4,10 +4,10 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { test } from 'node:test'; import { type FlowResult, + CONFORMANCE_DATA_DIR, CORPUS_DIR, checkCoverage, checkFixtureSeals, @@ -16,17 +16,17 @@ import { loadLayer1, loadLayer2, } from './verify.ts'; -import { parseMaestroConformanceSource } from '@agent-device/maestro'; +import { parseMaestroConformanceSource } from './harness.ts'; import { DOCUMENTED_DEVIATIONS, FLOW_DIVERGENCES, LAYER2_REFERENCE_ONLY, } from './expected-divergence.ts'; -// @ts-expect-error -- .mjs helper shared with regenerate.mjs; no type declarations. -import { checkFixtureSeal } from './fixture-seal.mjs'; +import { checkFixtureSeal } from './fixture-seal.ts'; -const HERE = path.dirname(fileURLToPath(import.meta.url)); -const PIN = JSON.parse(fs.readFileSync(path.join(HERE, 'pinned-upstream.json'), 'utf8')); +const PIN = JSON.parse( + fs.readFileSync(path.join(CONFORMANCE_DATA_DIR, 'pinned-upstream.json'), 'utf8'), +); function flowsById(): Map { return new Map(classifyAllFlows().map((flow) => [flow.id, flow])); @@ -47,7 +47,7 @@ test('fixture content is sealed against hand editing', () => { // rather than trusting that a hash comparison must work. test('the seal rejects an edited capture (proof the check has teeth)', () => { const original = JSON.parse( - fs.readFileSync(path.join(HERE, 'fixtures', 'layer2-semantics.json'), 'utf8'), + fs.readFileSync(path.join(CONFORMANCE_DATA_DIR, 'fixtures', 'layer2-semantics.json'), 'utf8'), ); // Forge the retry cap the way a hand-transcribed fixture would have drifted. const tampered = structuredClone(original); diff --git a/scripts/maestro-conformance/verify.ts b/packages/maestro/test/conformance/verify.ts similarity index 94% rename from scripts/maestro-conformance/verify.ts rename to packages/maestro/test/conformance/verify.ts index 24560d773e..2eaa33d8df 100644 --- a/scripts/maestro-conformance/verify.ts +++ b/packages/maestro/test/conformance/verify.ts @@ -15,19 +15,19 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { AppError } from '@agent-device/kernel/errors'; import { - canonicalizeUpstreamMaestroFlow, + canonicalizeUpstreamFlow, MAESTRO_CONFORMANCE_CONSTANTS, parseMaestroConformanceSource, SUPPORTED_MAESTRO_COMMAND_NAMES, - type MaestroCanonicalCommand, -} from '@agent-device/maestro'; + type CanonicalCommand, +} from './harness.ts'; import { LAYER2_REFERENCE_ONLY, UNVERIFIED_COMMANDS } from './expected-divergence.ts'; -// @ts-expect-error -- .mjs helper shared with regenerate.mjs; no type declarations. -import { checkFixtureSeal } from './fixture-seal.mjs'; +import { checkFixtureSeal } from './fixture-seal.ts'; const HERE = path.dirname(fileURLToPath(import.meta.url)); -export const CORPUS_DIR = path.join(HERE, 'corpus'); -const FIXTURES_DIR = path.join(HERE, 'fixtures'); +export const CONFORMANCE_DATA_DIR = path.resolve(HERE, '../../../../scripts/maestro-conformance'); +export const CORPUS_DIR = path.join(CONFORMANCE_DATA_DIR, 'corpus'); +const FIXTURES_DIR = path.join(CONFORMANCE_DATA_DIR, 'fixtures'); export type Classification = | 'identical' @@ -112,7 +112,7 @@ function agentParseProgram(file: string): ReturnType number; readonly sleep: (milliseconds: number, signal?: AbortSignal) => Promise; @@ -199,8 +199,7 @@ export async function scrollUntilTypedMaestroTarget(params: { mode: 'observe', }); if ( - lastMatch.visiblePercentage === - MAESTRO_COMPATIBILITY_PRESETS.command.scrollUntilVisiblePercentage + lastMatch.visiblePercentage === MAESTRO_RUNTIME_ADAPTER_POLICY.scrollUntilVisiblePercentage ) { return lastMatch; } diff --git a/src/daemon/adapters/maestro/daemon-runtime-port-snapshot-source.ts b/src/daemon/adapters/maestro/daemon-runtime-port-snapshot-source.ts index 878b367927..77c025ec29 100644 --- a/src/daemon/adapters/maestro/daemon-runtime-port-snapshot-source.ts +++ b/src/daemon/adapters/maestro/daemon-runtime-port-snapshot-source.ts @@ -1,7 +1,7 @@ import { AppError } from '@agent-device/kernel/errors'; import type { SnapshotState } from '@agent-device/kernel/snapshot'; import { - MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS, + MAESTRO_RUNTIME_ADAPTER_POLICY, type MaestroObservationIdentity, } from '@agent-device/maestro'; import { @@ -110,7 +110,7 @@ export function createDaemonMaestroSnapshotSource( const initialSnapshot = cached?.generation === context.generation ? cached.snapshot : stabilityBaseline?.snapshot; const stable = await waitForTypedSnapshotStability({ - timeoutMs: MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS, + timeoutMs: MAESTRO_RUNTIME_ADAPTER_POLICY.settleTimeoutMs, context, snapshot: captureFresh, dependencies: options.dependencies, diff --git a/src/daemon/adapters/maestro/daemon-runtime-port.ts b/src/daemon/adapters/maestro/daemon-runtime-port.ts index b883752f13..35f7ca236f 100644 --- a/src/daemon/adapters/maestro/daemon-runtime-port.ts +++ b/src/daemon/adapters/maestro/daemon-runtime-port.ts @@ -2,8 +2,7 @@ import { AppError } from '@agent-device/kernel/errors'; import { createMaestroRuntimePort, maestroTestFailure, - MAESTRO_COMPATIBILITY_PRESETS, - MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS, + MAESTRO_RUNTIME_ADAPTER_POLICY, resolveMaestroScrollableGesture, type MaestroRuntimeMetrics, type MaestroRuntimeOperationContext, @@ -75,7 +74,7 @@ function createDaemonMaestroRuntimeParts(options: CreateDaemonMaestroRuntimeOper registerDiagnosticSensitiveValue(text); await invokeMutation({ kind: 'typeText', text }, context); const stable = await waitForTypedSnapshotStability({ - timeoutMs: MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS, + timeoutMs: MAESTRO_RUNTIME_ADAPTER_POLICY.settleTimeoutMs, context, snapshot: snapshots.capture, dependencies: options.dependencies, @@ -168,7 +167,7 @@ function createDaemonMaestroRuntimeParts(options: CreateDaemonMaestroRuntimeOper await withMutation( () => clickMaestroTargetPoint(options, input.target.point, { - holdMs: MAESTRO_COMPATIBILITY_PRESETS.command.longPressDurationMs, + holdMs: MAESTRO_RUNTIME_ADAPTER_POLICY.longPressDurationMs, }), context, 'deferred', @@ -190,7 +189,7 @@ function createDaemonMaestroRuntimeParts(options: CreateDaemonMaestroRuntimeOper eraseText: async (input, context) => await typeTextAndSettle( '\b'.repeat( - input.charactersToErase ?? MAESTRO_COMPATIBILITY_PRESETS.command.eraseTextMaxCharacters, + input.charactersToErase ?? MAESTRO_RUNTIME_ADAPTER_POLICY.eraseTextMaxCharacters, ), context, ), @@ -226,7 +225,7 @@ function createDaemonMaestroRuntimeParts(options: CreateDaemonMaestroRuntimeOper ); return ( await waitForTypedSnapshotStability({ - timeoutMs: Math.min(MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS, remainingMs), + timeoutMs: Math.min(MAESTRO_RUNTIME_ADAPTER_POLICY.settleTimeoutMs, remainingMs), context, snapshot: snapshots.capture, dependencies: options.dependencies, @@ -234,10 +233,7 @@ function createDaemonMaestroRuntimeParts(options: CreateDaemonMaestroRuntimeOper ).snapshot; }, }); - if ( - match.visiblePercentage !== - MAESTRO_COMPATIBILITY_PRESETS.command.scrollUntilVisiblePercentage - ) { + if (match.visiblePercentage !== MAESTRO_RUNTIME_ADAPTER_POLICY.scrollUntilVisiblePercentage) { throw maestroTestFailure('Maestro scrollUntilVisible target did not become visible.', { selector: input.selector, timeoutMs: input.timeoutMs, @@ -258,8 +254,7 @@ function createDaemonMaestroRuntimeParts(options: CreateDaemonMaestroRuntimeOper }, waitForAnimationToEnd: async (input, context) => { const visualStabilityReached = await waitForMaestroAnimationToEnd({ - timeoutMs: - input.timeoutMs ?? MAESTRO_COMPATIBILITY_PRESETS.command.waitForAnimationToEndTimeoutMs, + timeoutMs: input.timeoutMs ?? MAESTRO_RUNTIME_ADAPTER_POLICY.animationWaitTimeoutMs, now: options.dependencies.now, signal: context.signal, capture: async (screenshotPath) => { diff --git a/src/daemon/adapters/maestro/daemon-runtime-tap.ts b/src/daemon/adapters/maestro/daemon-runtime-tap.ts index e70c3aa44c..8c4cda4a41 100644 --- a/src/daemon/adapters/maestro/daemon-runtime-tap.ts +++ b/src/daemon/adapters/maestro/daemon-runtime-tap.ts @@ -1,8 +1,7 @@ import { AppError, asAppError } from '@agent-device/kernel/errors'; import type { Rect } from '@agent-device/kernel/snapshot'; import { - MAESTRO_COMPATIBILITY_PRESETS, - MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS, + MAESTRO_RUNTIME_ADAPTER_POLICY, type MaestroDispatchSelector, type MaestroRuntimeOperationContext, type MaestroRuntimeOperations, @@ -131,7 +130,7 @@ async function tapTargetWithRetry( (screenshotBaseline ? undefined : maestroSnapshotSignature(await snapshots.capture(context))); const settle = async () => await waitForTypedSnapshotStability({ - timeoutMs: MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS, + timeoutMs: MAESTRO_RUNTIME_ADAPTER_POLICY.settleTimeoutMs, context, snapshot: snapshots.capture, dependencies: options.dependencies, @@ -166,7 +165,7 @@ async function executeTapRetryLoop(params: { let attempts = 1; while ( (params.baselineSignature === undefined || observed.signature === params.baselineSignature) && - attempts < MAESTRO_COMPATIBILITY_PRESETS.command.retryTapMaxAttempts + attempts < MAESTRO_RUNTIME_ADAPTER_POLICY.retryTapMaxAttempts ) { if (params.screenshotBaseline && (await params.screenshotBaseline.matchesCurrent()) !== true) { break; diff --git a/src/daemon/adapters/maestro/failure-snapshot.ts b/src/daemon/adapters/maestro/failure-snapshot.ts new file mode 100644 index 0000000000..c6e4d700c7 --- /dev/null +++ b/src/daemon/adapters/maestro/failure-snapshot.ts @@ -0,0 +1,10 @@ +import { collectMaestroFailureSuggestions, type MaestroFailedAction } from '@agent-device/maestro'; +import type { SnapshotState } from '@agent-device/kernel/snapshot'; + +/** + * Adapts package-owned target semantics to daemon divergence suggestions. + * Ranking remains inside the Maestro module; the daemon sees only ranked values. + */ +export function adaptMaestroFailureSnapshot(failure: MaestroFailedAction, snapshot: SnapshotState) { + return collectMaestroFailureSuggestions(failure, snapshot); +} diff --git a/src/daemon/adapters/maestro/maestro-screenshot-comparison.ts b/src/daemon/adapters/maestro/maestro-screenshot-comparison.ts index 5cdbcd6720..fe10079780 100644 --- a/src/daemon/adapters/maestro/maestro-screenshot-comparison.ts +++ b/src/daemon/adapters/maestro/maestro-screenshot-comparison.ts @@ -1,7 +1,7 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { MAESTRO_COMPATIBILITY_PRESETS } from '@agent-device/maestro'; +import { MAESTRO_RUNTIME_ADAPTER_POLICY } from '@agent-device/maestro'; import { createRequestCanceledError, isRequestCanceledError } from '../../../request/cancel.ts'; import { emitDiagnostic } from '../../../utils/diagnostics.ts'; import { computePngRgbDifferenceAsync } from '../../../utils/png-worker-client.ts'; @@ -109,9 +109,7 @@ function screenshotsMatch( return false; } const differencePercent = comparison.differencePercent; - const matches = - differencePercent <= - MAESTRO_COMPATIBILITY_PRESETS.command.waitForAnimationToEndDifferencePercent; + const matches = differencePercent <= MAESTRO_RUNTIME_ADAPTER_POLICY.animationDifferencePercent; emitComparison(purpose, matches ? 'stable' : 'changed', comparison); return matches; } diff --git a/src/daemon/handlers/__tests__/session-replay-runtime.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime.test.ts index b404c21e11..7026d76bb6 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime.test.ts @@ -149,6 +149,31 @@ test('Maestro YAML uses the typed engine while .ad remains generic', async () => expect(commands).toEqual(['open']); }); +test('bare Maestro YAML requires explicit --maestro routing', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-maestro-explicit-route-')); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const sessionName = 'default'; + sessionStore.set(sessionName, makeIosSession(sessionName)); + const yamlPath = path.join(root, 'flow.yaml'); + fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- launchApp\n'); + + const response = await runReplayScriptFile({ + req: baseReq({ positionals: [yamlPath] }), + sessionName, + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: vi.fn(), + }); + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'INVALID_ARGS', + message: `Maestro YAML requires explicit --maestro routing: replay ${yamlPath} --maestro`, + }, + }); +}); + test('ADR 0016 / #1384: a Maestro replay reports sessionActive: true (real producer, no fake HTTP)', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-maestro-session-active-')); const sessionStore = new SessionStore(path.join(root, 'sessions')); diff --git a/src/daemon/handlers/__tests__/session-test-discovery.test.ts b/src/daemon/handlers/__tests__/session-test-discovery.test.ts index 187b8b783d..f6057a6be1 100644 --- a/src/daemon/handlers/__tests__/session-test-discovery.test.ts +++ b/src/daemon/handlers/__tests__/session-test-discovery.test.ts @@ -265,6 +265,7 @@ test('discoverReplayTestEntries rejects YAML without explicit Maestro routing', (error: unknown) => error instanceof AppError && error.code === 'INVALID_ARGS' && - error.message === `test does not support this file type: ${flowPath}`, + error.message === + `Maestro YAML requires explicit --maestro routing: test ${flowPath} --maestro`, ); }); diff --git a/src/daemon/handlers/session-replay-maestro-failure.ts b/src/daemon/handlers/session-replay-maestro-failure.ts index a80cdb42e1..1e3ddf6c16 100644 --- a/src/daemon/handlers/session-replay-maestro-failure.ts +++ b/src/daemon/handlers/session-replay-maestro-failure.ts @@ -1,4 +1,4 @@ -import { rankMaestroFailureCandidates, type MaestroFailedAction } from '@agent-device/maestro'; +import type { MaestroFailedAction } from '@agent-device/maestro'; import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import type { DaemonError } from '@agent-device/kernel/errors'; import type { SnapshotDiagnosticsSummary } from '@agent-device/contracts/capture'; @@ -27,6 +27,7 @@ import { buildReplayDivergenceFailureResponseFromDescriptor, hoistReplayFailureCauseDiagnosticMeta, } from './session-replay-runtime-failure-response.ts'; +import { adaptMaestroFailureSnapshot } from '../adapters/maestro/failure-snapshot.ts'; export type MaestroFailureReportAction = Pick< ReplayReportAction, @@ -165,7 +166,7 @@ function collectTypedMaestroSuggestions(params: { }) { const snapshot = { createdAt: Date.now(), nodes: params.nodes }; return rankAndDedupeReplaySuggestions( - rankMaestroFailureCandidates(params.failure, snapshot).map(({ node, basis }) => ({ + adaptMaestroFailureSnapshot(params.failure, snapshot).map(({ node, basis }) => ({ node, nodeIndex: node.index, basis, diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index 408d1a06f7..4dc46aab62 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -39,7 +39,11 @@ import { } from '@agent-device/contracts/capture'; import type { ReplayCommandResult } from '@agent-device/contracts/replay'; import type { ReplayDivergenceResume } from '../../replay/divergence.ts'; -import { resolveReplayFormat } from '../../replay/format.ts'; +import { + isMaestroYamlPath, + maestroBackendRequiredMessage, + resolveReplayFormat, +} from '../../replay/format.ts'; import { isRecord } from '../../utils/parsing.ts'; import { collectReplayActionArtifactPaths } from './session-replay-runtime-artifacts.ts'; import { withReplayFailureDiagnostics } from './session-replay-runtime-failure.ts'; @@ -209,6 +213,9 @@ export async function runReplayScriptFile(params: { const artifactPaths = new Set(); try { resolved = SessionStore.expandHome(filePath, req.meta?.cwd); + if (isMaestroYamlPath(resolved) && req.flags?.replayBackend !== 'maestro') { + return errorResponse('INVALID_ARGS', maestroBackendRequiredMessage('replay', filePath)); + } if (resolveReplayFormat(resolved, req.flags?.replayBackend) === 'maestro') { if (sessionStore.get(sessionName)?.saveScriptBoundary !== undefined) { return errorResponse( diff --git a/src/daemon/handlers/session-test-artifacts.ts b/src/daemon/handlers/session-test-artifacts.ts index 1291c8fd72..5239adf605 100644 --- a/src/daemon/handlers/session-test-artifacts.ts +++ b/src/daemon/handlers/session-test-artifacts.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { isMaestroYamlPath } from '../../replay/format.ts'; import type { DaemonResponse } from '../types.ts'; import { SessionStore } from '../session-store.ts'; @@ -116,7 +117,7 @@ function copyReplaySourceFile(filePath: string, attemptArtifactsDir: string): vo const genericReplayPath = path.join(attemptArtifactsDir, 'replay.ad'); fs.copyFileSync(filePath, genericReplayPath); - if (!isMaestroFlowPath(filePath)) return; + if (!isMaestroYamlPath(filePath)) return; // Keep replay.ad for existing artifact consumers, and preserve the original // Maestro filename so CI artifacts point back to the source flow. const originalReplayPath = path.join(attemptArtifactsDir, path.basename(filePath)); @@ -125,11 +126,6 @@ function copyReplaySourceFile(filePath: string, attemptArtifactsDir: string): vo } } -function isMaestroFlowPath(filePath: string): boolean { - const extension = path.extname(filePath).toLowerCase(); - return extension === '.yml' || extension === '.yaml'; -} - function buildUniqueArtifactFileName(fileName: string, usedNames: Map): string { const extension = path.extname(fileName); const stem = extension ? fileName.slice(0, -extension.length) : fileName; diff --git a/src/daemon/replay-source-discovery.ts b/src/daemon/replay-source-discovery.ts index 36c29f0670..87cf986b27 100644 --- a/src/daemon/replay-source-discovery.ts +++ b/src/daemon/replay-source-discovery.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; +import { isMaestroYamlPath, maestroBackendRequiredMessage } from '../replay/format.ts'; import { SessionStore } from './session-store.ts'; const GLOB_PATTERN_CHARS = /[*?[\]{}]/; @@ -51,9 +52,7 @@ function expandReplayInput( }; } if (stat.isFile()) { - if (!extensions.has(path.extname(expandedInput).toLowerCase())) { - throw new AppError('INVALID_ARGS', `test does not support this file type: ${input}`); - } + assertSupportedReplayFile(input, expandedInput, extensions); return { paths: [expandedInput], source: 'file' }; } return { paths: [], source: 'file' }; @@ -77,6 +76,18 @@ function expandReplayInput( }; } +function assertSupportedReplayFile( + input: string, + expandedInput: string, + extensions: ReadonlySet, +): void { + if (extensions.has(path.extname(expandedInput).toLowerCase())) return; + if (isMaestroYamlPath(expandedInput) && !extensions.has('.yaml')) { + throw new AppError('INVALID_ARGS', maestroBackendRequiredMessage('test', input)); + } + throw new AppError('INVALID_ARGS', `test does not support this file type: ${input}`); +} + function readDirectoryPaths(directoryPath: string, extensions: ReadonlySet): string[] { const paths: string[] = []; const directory = fs.opendirSync(directoryPath); diff --git a/src/replay/__tests__/format.test.ts b/src/replay/__tests__/format.test.ts index 369c68eb3d..cfe11ef4ad 100644 --- a/src/replay/__tests__/format.test.ts +++ b/src/replay/__tests__/format.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from 'vitest'; -import { resolveReplayFormat } from '../format.ts'; +import { + isMaestroYamlPath, + maestroBackendRequiredMessage, + resolveReplayFormat, +} from '../format.ts'; describe('resolveReplayFormat', () => { test('keeps .ad on the native engine even when Maestro is requested', () => { @@ -13,4 +17,15 @@ describe('resolveReplayFormat', () => { expect(resolveReplayFormat(sourcePath, undefined)).toBe('ad'); }, ); + + test('identifies Maestro YAML by extension without reading contents', () => { + expect(isMaestroYamlPath('/flows/login.YML')).toBe(true); + expect(isMaestroYamlPath('/flows/login.ad')).toBe(false); + }); + + test('formats the explicit routing diagnostic', () => { + expect(maestroBackendRequiredMessage('replay', '/flows/login.yaml')).toBe( + 'Maestro YAML requires explicit --maestro routing: replay /flows/login.yaml --maestro', + ); + }); }); diff --git a/src/replay/format.ts b/src/replay/format.ts index 6845e89e80..3b6b2cdd02 100644 --- a/src/replay/format.ts +++ b/src/replay/format.ts @@ -2,6 +2,18 @@ import path from 'node:path'; export type ReplayFormat = 'ad' | 'maestro'; +export function isMaestroYamlPath(sourcePath: string): boolean { + const extension = path.extname(sourcePath).toLowerCase(); + return extension === '.yaml' || extension === '.yml'; +} + +export function maestroBackendRequiredMessage( + command: 'replay' | 'test', + sourcePath: string, +): string { + return `Maestro YAML requires explicit --maestro routing: ${command} ${sourcePath} --maestro`; +} + /** * Selects an engine from the authored path and explicit backend request. * Content is never probed and engines never fall back to one another. @@ -10,8 +22,5 @@ export function resolveReplayFormat( sourcePath: string, replayBackend: string | undefined, ): ReplayFormat { - const extension = path.extname(sourcePath).toLowerCase(); - return replayBackend === 'maestro' && (extension === '.yaml' || extension === '.yml') - ? 'maestro' - : 'ad'; + return replayBackend === 'maestro' && isMaestroYamlPath(sourcePath) ? 'maestro' : 'ad'; }