@@ -7,12 +7,18 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => {
77import fs from 'node:fs' ;
88import os from 'node:os' ;
99import path from 'node:path' ;
10- import { buildTypedMaestroFailureReportProjection } from '../session-replay-maestro-failure.ts' ;
10+ import {
11+ buildTypedMaestroFailureReportProjection ,
12+ buildTypedMaestroFailureResponse ,
13+ } from '../session-replay-maestro-failure.ts' ;
1114import { runReplayScriptFile } from '../session-replay-runtime.ts' ;
1215import { SessionStore } from '../../session-store.ts' ;
1316import { dispatchCommand } from '../../../core/dispatch.ts' ;
1417import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts' ;
1518import { baseReplayRequest as baseReq } from './session-replay-runtime.fixtures.ts' ;
19+ import type { MaestroCommand } from '../../../compat/maestro/program-ir.ts' ;
20+ import type { MaestroReplayPlan } from '../../../compat/maestro/replay-plan-types.ts' ;
21+ import type { SnapshotNode } from '../../../kernel/snapshot.ts' ;
1622
1723const mockDispatchCommand = vi . mocked ( dispatchCommand ) ;
1824
@@ -21,6 +27,54 @@ beforeEach(() => {
2127 mockDispatchCommand . mockResolvedValue ( { } ) ;
2228} ) ;
2329
30+ function makeMaestroPlan ( ) : MaestroReplayPlan {
31+ return {
32+ kind : 'maestroReplayPlan' ,
33+ platform : 'ios' ,
34+ initialStaticEnv : { } ,
35+ steps : [ ] ,
36+ total : 1 ,
37+ digest : 'typed-maestro-test-plan' ,
38+ compatibility : {
39+ staticallyExecutedControls : 0 ,
40+ staticallySkippedControls : 0 ,
41+ } ,
42+ } ;
43+ }
44+
45+ async function buildFailureResponse (
46+ command : MaestroCommand ,
47+ nodes : SnapshotNode [ ] ,
48+ ) : Promise < Extract < Awaited < ReturnType < typeof buildTypedMaestroFailureResponse > > , { ok : false } > > {
49+ const root = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'agent-device-maestro-suggestions-' ) ) ;
50+ const sessionName = 'default' ;
51+ const sessionStore = new SessionStore ( path . join ( root , 'sessions' ) ) ;
52+ sessionStore . set ( sessionName , makeIosSession ( sessionName ) ) ;
53+ mockDispatchCommand . mockResolvedValue ( { nodes, truncated : false , backend : 'xctest' } ) ;
54+ const response = await buildTypedMaestroFailureResponse ( {
55+ error : { code : 'COMMAND_FAILED' , message : 'typed Maestro action failed' } ,
56+ event : {
57+ command,
58+ source : command . source ,
59+ generation : 0 ,
60+ stepIndex : 1 ,
61+ stepTotal : 1 ,
62+ durationMs : 12 ,
63+ error : new Error ( 'typed Maestro action failed' ) ,
64+ artifactPaths : [ ] ,
65+ expandedVariables : { } ,
66+ } ,
67+ plan : makeMaestroPlan ( ) ,
68+ replayPath : path . join ( root , 'flow.yaml' ) ,
69+ req : baseReq ( { flags : { replayBackend : 'maestro' , platform : 'ios' } } ) ,
70+ sessionName,
71+ sessionStore,
72+ logPath : path . join ( root , 'daemon.log' ) ,
73+ } ) ;
74+ if ( response . ok ) throw new Error ( 'expected typed Maestro failure response' ) ;
75+ return response ;
76+ }
77+
2478test ( 'typed Maestro failure projection is report-only and preserves authored provenance' , ( ) => {
2579 const command = {
2680 kind : 'tapOn' as const ,
@@ -221,3 +275,140 @@ test('typed Maestro nested scopes scrub failure values after unwind and keep ret
221275 } ) ,
222276 ] ) ;
223277} ) ;
278+
279+ test ( 'typed Maestro suggestions rank visible childOf candidates and exclude out-of-scope nodes' , async ( ) => {
280+ const command = {
281+ kind : 'tapOn' as const ,
282+ source : { path : '/flows/actions.yaml' , line : 4 } ,
283+ target : { space : 'target' as const , selector : { text : 'save.*' } } ,
284+ childOf : { id : 'actions' } ,
285+ } satisfies Extract < MaestroCommand , { kind : 'tapOn' } > ;
286+ const response = await buildFailureResponse ( command , [
287+ {
288+ ref : 'e1' ,
289+ index : 0 ,
290+ type : 'Application' ,
291+ rect : { x : 0 , y : 0 , width : 402 , height : 874 } ,
292+ } ,
293+ {
294+ ref : 'e2' ,
295+ index : 1 ,
296+ parentIndex : 0 ,
297+ identifier : 'actions' ,
298+ type : 'View' ,
299+ rect : { x : 0 , y : 0 , width : 402 , height : 300 } ,
300+ } ,
301+ {
302+ ref : 'e3' ,
303+ index : 2 ,
304+ parentIndex : 1 ,
305+ identifier : 'save-primary' ,
306+ label : 'Primary' ,
307+ type : 'Button' ,
308+ rect : { x : 16 , y : 40 , width : 140 , height : 44 } ,
309+ hittable : true ,
310+ } ,
311+ {
312+ ref : 'e4' ,
313+ index : 3 ,
314+ parentIndex : 1 ,
315+ identifier : 'secondary' ,
316+ label : 'Save secondary' ,
317+ type : 'Button' ,
318+ rect : { x : 16 , y : 96 , width : 140 , height : 44 } ,
319+ hittable : true ,
320+ } ,
321+ {
322+ ref : 'e5' ,
323+ index : 4 ,
324+ parentIndex : 0 ,
325+ identifier : 'save-outside' ,
326+ label : 'Save outside' ,
327+ type : 'Button' ,
328+ rect : { x : 16 , y : 152 , width : 140 , height : 44 } ,
329+ hittable : true ,
330+ } ,
331+ {
332+ ref : 'e6' ,
333+ index : 5 ,
334+ parentIndex : 1 ,
335+ identifier : 'save-hidden' ,
336+ label : 'Save hidden' ,
337+ type : 'Button' ,
338+ rect : { x : 16 , y : 208 , width : 140 , height : 0 } ,
339+ hittable : false ,
340+ } ,
341+ ] ) ;
342+ const divergence = response . error . details ?. divergence as {
343+ suggestionCount : number ;
344+ suggestions : Array < { selector : string ; basis : string } > ;
345+ } ;
346+
347+ expect ( divergence . suggestions ) . toHaveLength ( 2 ) ;
348+ expect ( divergence . suggestions . map ( ( { basis } ) => basis ) ) . toEqual ( [ 'id' , 'label' ] ) ;
349+ expect ( divergence . suggestions [ 0 ] ?. selector ) . toContain ( 'save-primary' ) ;
350+ expect ( divergence . suggestions [ 1 ] ?. selector ) . toContain ( 'Save secondary' ) ;
351+ expect ( divergence . suggestionCount ) . toBe ( 2 ) ;
352+ } ) ;
353+
354+ test ( 'typed Maestro text matching an identifier reports id basis' , async ( ) => {
355+ const command = {
356+ kind : 'tapOn' as const ,
357+ source : { path : '/flows/actions.yaml' , line : 4 } ,
358+ target : { space : 'target' as const , selector : { text : 'accessibility-save' } } ,
359+ } satisfies Extract < MaestroCommand , { kind : 'tapOn' } > ;
360+ const response = await buildFailureResponse ( command , [
361+ {
362+ ref : 'e1' ,
363+ index : 0 ,
364+ type : 'Application' ,
365+ rect : { x : 0 , y : 0 , width : 402 , height : 874 } ,
366+ } ,
367+ {
368+ ref : 'e2' ,
369+ index : 1 ,
370+ parentIndex : 0 ,
371+ identifier : 'accessibility-save' ,
372+ type : 'Button' ,
373+ rect : { x : 16 , y : 40 , width : 140 , height : 44 } ,
374+ hittable : true ,
375+ } ,
376+ ] ) ;
377+ const divergence = response . error . details ?. divergence as {
378+ suggestions : Array < { basis : string } > ;
379+ } ;
380+
381+ expect ( divergence . suggestions ) . toEqual ( [ expect . objectContaining ( { basis : 'id' } ) ] ) ;
382+ } ) ;
383+
384+ test ( 'typed Maestro suggestions retain total count before the five-entry cap' , async ( ) => {
385+ const command = {
386+ kind : 'tapOn' as const ,
387+ source : { path : '/flows/actions.yaml' , line : 4 } ,
388+ target : { space : 'target' as const , selector : { label : 'Save' } } ,
389+ } satisfies Extract < MaestroCommand , { kind : 'tapOn' } > ;
390+ const response = await buildFailureResponse ( command , [
391+ {
392+ ref : 'e1' ,
393+ index : 0 ,
394+ type : 'Application' ,
395+ rect : { x : 0 , y : 0 , width : 402 , height : 874 } ,
396+ } ,
397+ ...Array . from ( { length : 6 } , ( _ , offset ) => ( {
398+ ref : `e${ offset + 2 } ` ,
399+ index : offset + 1 ,
400+ parentIndex : 0 ,
401+ label : 'Save' ,
402+ type : 'Button' ,
403+ rect : { x : 16 , y : 40 + offset * 50 , width : 140 , height : 44 } ,
404+ hittable : true ,
405+ } ) ) ,
406+ ] ) ;
407+ const divergence = response . error . details ?. divergence as {
408+ suggestionCount : number ;
409+ suggestions : unknown [ ] ;
410+ } ;
411+
412+ expect ( divergence . suggestionCount ) . toBe ( 6 ) ;
413+ expect ( divergence . suggestions ) . toHaveLength ( 5 ) ;
414+ } ) ;
0 commit comments