@@ -7,6 +7,7 @@ import os from 'node:os';
77import path from 'node:path' ;
88import { test } from 'node:test' ;
99import { MAESTRO_DEFAULT_SETTLE_TIMEOUT_MS , parseMaestroConformanceSource } from '../harness.ts' ;
10+ import { parseMaestroProgram } from '../../../src/internal/program-ir-parser.ts' ;
1011import { DIFFERENTIAL_SCENARIOS } from './scenarios.ts' ;
1112import { 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
4559test ( '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
5067test ( '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 , / s e t t l e T i m e o u t s w a s 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+
5684test ( '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
123154test ( '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
136173const SETTLE_FLOW_PATH = path . join ( import . meta. dirname , 'flows/settle-after-tap.yaml' ) ;
137174
138175function 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 , / r e t r y T a p I f N o C h a n g e : t r u e / ) ;
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 * r e t r y T a p I f N o C h a n g e : t r u e / , '' ) ) ) ;
169210 assert . throws ( ( ) => assertSettleFlowSemantics ( flow . replace ( '- tapOn:' , '- scroll\n- tapOn:' ) ) ) ;
170211} ) ;
171212
0 commit comments