Skip to content

Commit 7f5df7f

Browse files
committed
test(maestro): prove settle metric execution
1 parent fbfcb08 commit 7f5df7f

14 files changed

Lines changed: 146 additions & 36 deletions

‎packages/maestro/src/internal/engine-types.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export type MaestroRuntimeMetrics = {
105105
hierarchyCaptures: number;
106106
screenshotCaptures: number;
107107
tapRetries: number;
108+
settleLatches: number;
108109
settleTimeouts: number;
109110
};
110111

‎packages/maestro/src/internal/facade-execution.ts‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
isMaestroControlCommandDescriptor,
88
type MaestroEngineEvent,
99
type MaestroEngineObserver,
10+
type MaestroRuntimeMetrics,
1011
type MaestroRuntimePort,
1112
} from './engine-types.ts';
1213
import { parseMaestroProgram } from './program-ir-parser.ts';
@@ -49,12 +50,7 @@ export type MaestroActionEvent = {
4950

5051
export type MaestroCompletedActionEvent = MaestroActionEvent & {
5152
readonly durationMs: number;
52-
readonly runtimeMetrics?: {
53-
hierarchyCaptures: number;
54-
screenshotCaptures: number;
55-
tapRetries: number;
56-
settleTimeouts: number;
57-
};
53+
readonly runtimeMetrics?: MaestroRuntimeMetrics;
5854
readonly data?: Record<string, unknown>;
5955
};
6056

‎packages/maestro/src/internal/replay-plan-execution.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ function runtimeMetricsDelta(
112112
hierarchyCaptures: after.hierarchyCaptures - before.hierarchyCaptures,
113113
screenshotCaptures: after.screenshotCaptures - before.screenshotCaptures,
114114
tapRetries: after.tapRetries - before.tapRetries,
115+
settleLatches: after.settleLatches - before.settleLatches,
115116
settleTimeouts: after.settleTimeouts - before.settleTimeouts,
116117
},
117118
};

‎packages/maestro/test/conformance/differential/invariants.test.ts‎

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import os from 'node:os';
77
import path from 'node:path';
88
import { test } from 'node:test';
99
import { MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS, parseMaestroConformanceSource } from '../harness.ts';
10+
import { parseMaestroProgram } from '../../../src/internal/program-ir-parser.ts';
1011
import { DIFFERENTIAL_SCENARIOS } from './scenarios.ts';
1112
import { type Invariant, evaluateInvariant, readTrace } from './invariants.ts';
1213

@@ -33,29 +34,59 @@ const SETTLE_TIMEOUT_INVARIANT: Invariant = {
3334
because: 'test',
3435
};
3536

36-
const settleStep = (command: string, durationMs: number, settleTimeouts: number, step = 1) => ({
37+
const SETTLE_LATCH_INVARIANT: Invariant = {
38+
kind: 'metricAtLeast',
39+
command: 'tapOn',
40+
metric: 'settleLatches',
41+
min: 1,
42+
because: 'test',
43+
};
44+
45+
const settleStep = (
46+
command: string,
47+
durationMs: number,
48+
metrics: { settleLatches: number; settleTimeouts: number },
49+
step = 1,
50+
) => ({
3751
type: 'replay_action_stop',
3852
step,
3953
command,
4054
ok: true,
4155
durationMs,
42-
resultTiming: { hierarchyCaptures: 1, screenshotCaptures: 0, tapRetries: 0, settleTimeouts },
56+
resultTiming: { hierarchyCaptures: 1, screenshotCaptures: 0, tapRetries: 0, ...metrics },
4357
});
4458

4559
test('a stability loop that latched holds the settle invariant however slow the step', () => {
46-
const result = evaluateInvariant([settleStep('tapOn', 3344, 0)], SETTLE_TIMEOUT_INVARIANT);
60+
const result = evaluateInvariant(
61+
[settleStep('tapOn', 3344, { settleLatches: 1, settleTimeouts: 0 })],
62+
SETTLE_TIMEOUT_INVARIANT,
63+
);
4764
assert.equal(result.status, 'held');
4865
});
4966

5067
test('a stability loop that never latched violates it however fast the step', () => {
51-
const result = evaluateInvariant([settleStep('tapOn', 120, 1)], SETTLE_TIMEOUT_INVARIANT);
68+
const result = evaluateInvariant(
69+
[settleStep('tapOn', 120, { settleLatches: 0, settleTimeouts: 1 })],
70+
SETTLE_TIMEOUT_INVARIANT,
71+
);
5272
assert.equal(result.status, 'violated');
5373
assert.match(result.detail, /settleTimeouts was 1/);
5474
});
5575

76+
test('a tap that did not run the loop violates the settle proof-of-life invariant', () => {
77+
const result = evaluateInvariant(
78+
[settleStep('tapOn', 120, { settleLatches: 0, settleTimeouts: 0 })],
79+
SETTLE_LATCH_INVARIANT,
80+
);
81+
assert.equal(result.status, 'violated');
82+
});
83+
5684
test('another command running out of settle budget does not implicate the tap', () => {
5785
const result = evaluateInvariant(
58-
[settleStep('scroll', 900, 2), settleStep('tapOn', 3344, 0, 2)],
86+
[
87+
settleStep('scroll', 900, { settleLatches: 0, settleTimeouts: 2 }),
88+
settleStep('tapOn', 3344, { settleLatches: 1, settleTimeouts: 0 }, 2),
89+
],
5990
SETTLE_TIMEOUT_INVARIANT,
6091
);
6192
assert.equal(result.status, 'held');
@@ -122,21 +153,28 @@ test('readTrace on a missing file returns no events', () => {
122153

123154
test('bug class 4 has a machine-checkable invariant, not just outcome parity', () => {
124155
const settle = DIFFERENTIAL_SCENARIOS.find((scenario) => scenario.bugClass === 4);
125-
const invariant = settle?.engineInvariants?.[0];
126-
assert.ok(invariant, 'settle scenario must carry an engine-side invariant');
127-
assert.equal(invariant?.kind, 'metricAtMost');
156+
const invariants = settle?.engineInvariants;
157+
assert.ok(invariants, 'settle scenario must carry engine-side invariants');
128158
assert.deepEqual(
129-
invariant?.kind === 'metricAtMost'
130-
? { metric: invariant.metric, max: invariant.max }
131-
: undefined,
132-
{ metric: 'settleTimeouts', max: 0 },
159+
invariants.map((invariant) =>
160+
invariant.kind === 'metricAtLeast'
161+
? { kind: invariant.kind, metric: invariant.metric, min: invariant.min }
162+
: invariant.kind === 'metricAtMost'
163+
? { kind: invariant.kind, metric: invariant.metric, max: invariant.max }
164+
: { kind: invariant.kind },
165+
),
166+
[
167+
{ kind: 'metricAtLeast', metric: 'settleLatches', min: 1 },
168+
{ kind: 'metricAtMost', metric: 'settleTimeouts', max: 0 },
169+
],
133170
);
134171
});
135172

136173
const SETTLE_FLOW_PATH = path.join(import.meta.dirname, 'flows/settle-after-tap.yaml');
137174

138175
function assertSettleFlowSemantics(source: string): void {
139176
const parsed = parseMaestroConformanceSource(source, SETTLE_FLOW_PATH);
177+
const program = parseMaestroProgram(source, { sourcePath: SETTLE_FLOW_PATH });
140178
assert.equal(
141179
parsed.commands.some(
142180
(command) => command.kind === 'scroll' || command.kind === 'scrollUntilVisible',
@@ -147,7 +185,9 @@ function assertSettleFlowSemantics(source: string): void {
147185
parsed.commands.filter((command) => command.kind === 'tap'),
148186
[{ kind: 'tap', longPress: false, repeat: 1, target: { selector: { text: 'Settings' } } }],
149187
);
150-
assert.match(source, /retryTapIfNoChange: true/);
188+
const tap = program.commands.find((command) => command.kind === 'tapOn');
189+
assert.equal(tap?.kind, 'tapOn');
190+
assert.equal(tap?.retryTapIfNoChange, true);
151191
assert.equal(
152192
parsed.commands.some(
153193
(command) =>
@@ -163,9 +203,10 @@ test('the settle detector reaches its tap without an unrelated setup command', (
163203
assertSettleFlowSemantics(fs.readFileSync(SETTLE_FLOW_PATH, 'utf8'));
164204
});
165205

166-
test('the settle flow guard rejects a changed tap target or inserted scroll', () => {
206+
test('the settle flow guard rejects a changed tap target, disabled retry, or inserted scroll', () => {
167207
const flow = fs.readFileSync(SETTLE_FLOW_PATH, 'utf8');
168208
assert.throws(() => assertSettleFlowSemantics(flow.replace('text: Settings', 'text: Home')));
209+
assert.throws(() => assertSettleFlowSemantics(flow.replace(/\n\s*retryTapIfNoChange: true/, '')));
169210
assert.throws(() => assertSettleFlowSemantics(flow.replace('- tapOn:', '- scroll\n- tapOn:')));
170211
});
171212

‎packages/maestro/test/conformance/differential/invariants.ts‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,19 @@
1212
// The evaluator is pure and unit-tested against synthetic traces; the device run
1313
// that produces a real trace happens only on the scheduled workflow.
1414
import fs from 'node:fs';
15+
import type { MaestroRuntimeMetrics } from '../../../src/internal/engine-types.ts';
1516

1617
export type TraceEvent = {
1718
type: string;
1819
step?: number;
1920
command?: string;
2021
ok?: boolean;
2122
durationMs?: number;
22-
/** Per-step MaestroRuntimeMetrics delta (hierarchyCaptures/screenshotCaptures/tapRetries). */
23+
/** Per-step MaestroRuntimeMetrics delta. */
2324
resultTiming?: Record<string, unknown>;
2425
};
2526

26-
export type MetricKey =
27-
| 'tapRetries'
28-
| 'hierarchyCaptures'
29-
| 'screenshotCaptures'
30-
| 'settleTimeouts';
27+
export type MetricKey = keyof MaestroRuntimeMetrics;
3128

3229
export type Invariant =
3330
| {

‎packages/maestro/test/conformance/differential/scenarios.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ export const DIFFERENTIAL_SCENARIOS: DifferentialScenario[] = [
9696
// Outcome parity cannot see settle ordering: a tap that burns the whole
9797
// budget still passes. This invariant is the actual bug-class-4 detector.
9898
engineInvariants: [
99+
{
100+
kind: 'metricAtLeast',
101+
command: 'tapOn',
102+
metric: 'settleLatches',
103+
min: 1,
104+
because: 'the scenario must execute and latch its inline stability loop',
105+
},
99106
{
100107
kind: 'metricAtMost',
101108
command: 'tapOn',

‎src/daemon/adapters/maestro/__tests__/daemon-runtime-port-targets.test.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ test('retries an iOS non-hittable coordinate fallback when the hierarchy does no
235235
hierarchyCaptures: 5,
236236
screenshotCaptures: 2,
237237
tapRetries: 1,
238+
settleLatches: 2,
238239
settleTimeouts: 0,
239240
});
240241
});
@@ -299,6 +300,7 @@ test('does not retry an iOS tap when only the rendered surface changes', async (
299300
hierarchyCaptures: 3,
300301
screenshotCaptures: 2,
301302
tapRetries: 0,
303+
settleLatches: 1,
302304
settleTimeouts: 0,
303305
});
304306
});
@@ -346,10 +348,54 @@ test('uses screenshot evidence without a redundant hierarchy baseline for iOS po
346348
hierarchyCaptures: 4,
347349
screenshotCaptures: 2,
348350
tapRetries: 1,
351+
settleLatches: 2,
349352
settleTimeouts: 0,
350353
});
351354
});
352355

356+
test('records an exhausted inline tap settle', async () => {
357+
const clock = { value: 0 };
358+
let snapshots = 0;
359+
const port = createDaemonMaestroRuntimePort({
360+
baseReq: makeBaseRequest({ flags: { platform: 'android', replayBackend: 'maestro' } }),
361+
invoke: async (request) => {
362+
if (request.command !== 'snapshot') return { ok: true, data: {} };
363+
snapshots += 1;
364+
return {
365+
ok: true,
366+
data: {
367+
nodes: [
368+
{ index: 0, type: 'Application' },
369+
{
370+
index: 1,
371+
parentIndex: 0,
372+
type: snapshots === 1 ? 'Button' : 'Text',
373+
...(snapshots === 1 ? { identifier: 'continue' } : { value: String(snapshots) }),
374+
rect: { x: 20, y: 40, width: 120, height: 44 },
375+
},
376+
],
377+
},
378+
};
379+
},
380+
dependencies: makeDependencies(clock),
381+
platform: 'android',
382+
});
383+
384+
await port.execute({
385+
command: {
386+
kind: 'tapOn',
387+
source: { line: 2 },
388+
target: { space: 'target', selector: { id: 'continue' } },
389+
retryTapIfNoChange: true,
390+
},
391+
generation: 0,
392+
env: {},
393+
invalidateObservation() {},
394+
});
395+
396+
expect(port.readMetrics?.()).toMatchObject({ settleTimeouts: 1 });
397+
});
398+
353399
function solidPng(value: number): Buffer {
354400
const image = new PNG({ width: 2, height: 2 });
355401
image.data.fill(value);

‎src/daemon/adapters/maestro/__tests__/daemon-runtime-port.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ test('uses an observation as the baseline for a later mutation barrier', async (
249249
hierarchyCaptures: 2,
250250
screenshotCaptures: 0,
251251
tapRetries: 0,
252+
settleLatches: 1,
252253
settleTimeouts: 0,
253254
});
254255
expect(clock.value).toBe(MAESTRO_OBSERVATION_POLL_MS);

‎src/daemon/adapters/maestro/daemon-runtime-port-observation.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ export type MaestroSnapshotSource = {
4343
readonly requireStability: (generation: number) => void;
4444
readonly consumeStabilityFromVisualWait: (context: MaestroRuntimeReadContext) => void;
4545
readonly prime: (generation: number, snapshot: SnapshotState) => void;
46-
readonly settlePending: (context: MaestroRuntimeReadContext) => Promise<void>;
46+
readonly settlePending: (
47+
context: MaestroRuntimeReadContext,
48+
) => Promise<StableMaestroSnapshot | undefined>;
4749
};
4850

4951
export type StableMaestroSnapshot = {

‎src/daemon/adapters/maestro/daemon-runtime-port-snapshot-source.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ export function createDaemonMaestroSnapshotSource(
101101
primed = { generation, snapshot };
102102
},
103103
settlePending: async (context) => {
104-
if (stabilityRequiredGeneration === undefined) return;
104+
if (stabilityRequiredGeneration === undefined) return undefined;
105105
if (stabilityRequiredGeneration !== context.generation) {
106106
throw new AppError(
107107
'COMMAND_FAILED',
@@ -120,6 +120,7 @@ export function createDaemonMaestroSnapshotSource(
120120
stabilityRequiredGeneration = undefined;
121121
stabilityBaseline = undefined;
122122
primed = { generation: context.generation, snapshot: stable.snapshot };
123+
return stable;
123124
},
124125
};
125126
}

0 commit comments

Comments
 (0)