@@ -18,6 +18,7 @@ import (
1818
1919 "github.com/alphawavesystems/flutter-probe/internal/ai"
2020 "github.com/alphawavesystems/flutter-probe/internal/config"
21+ "github.com/alphawavesystems/flutter-probe/internal/device"
2122 "github.com/alphawavesystems/flutter-probe/internal/parser"
2223 "github.com/alphawavesystems/flutter-probe/internal/probelink"
2324 "github.com/alphawavesystems/flutter-probe/internal/redact"
@@ -407,6 +408,11 @@ func (e *Executor) stepDescription(step parser.Step) string {
407408 case parser .VerbOpen :
408409 return "open the app"
409410 case parser .VerbClose :
411+ // Campaign finding: `close keyboard` used to print "close the
412+ // app" in progress output — Name distinguishes the two forms.
413+ if s .Name == "keyboard" {
414+ return "close keyboard"
415+ }
410416 return "close the app"
411417 case parser .VerbGoBack :
412418 return "go back"
@@ -439,6 +445,11 @@ func (e *Executor) stepDescription(step parser.Step) string {
439445 if s .Sel != nil {
440446 return fmt .Sprintf ("long press %q" , s .Sel .Text )
441447 }
448+ case parser .VerbToggle :
449+ if s .Sel != nil {
450+ return fmt .Sprintf ("toggle %q" , s .Sel .Text )
451+ }
452+ return fmt .Sprintf ("toggle %q" , s .Name )
442453 case parser .VerbKill :
443454 return "kill the app"
444455 case parser .VerbCopyClipboard :
@@ -632,7 +643,19 @@ func (e *Executor) runAction(ctx context.Context, a parser.ActionStep) error {
632643 return e .client .DeviceAction (ctx , "rotate" , a .Name )
633644
634645 case parser .VerbToggle :
635- return e .client .DeviceAction (ctx , "toggle" , a .Name )
646+ // Campaign finding: the agent's `toggle` DeviceAction has always
647+ // been a no-op (`case 'toggle': break;` in executor.dart) — every
648+ // `toggle` step "passed" without touching the device. A Switch
649+ // toggles on tap, so dispatch the parsed selector as a real tap.
650+ // Name is the legacy pre-selector field, kept as a text-selector
651+ // fallback for any step built programmatically the old way.
652+ if a .Sel != nil {
653+ return e .client .Tap (ctx , toSelectorParam (e .resolveSelector (* a .Sel )))
654+ }
655+ if a .Name != "" {
656+ return e .client .Tap (ctx , probelink.SelectorParam {Kind : "text" , Text : e .resolve (a .Name )})
657+ }
658+ return fmt .Errorf ("toggle: missing selector at line %d" , a .Line )
636659
637660 case parser .VerbShake :
638661 return e .client .DeviceAction (ctx , "shake" , "" )
@@ -765,25 +788,37 @@ func (e *Executor) runAction(ctx context.Context, a parser.ActionStep) error {
765788 // Cloud mode: permissions auto-granted via Appium capabilities
766789 return nil
767790 }
768- return e .deviceCtx .AllowPermission (ctx , a .Name )
791+ if err := e .deviceCtx .AllowPermission (ctx , a .Name ); err != nil {
792+ return err
793+ }
794+ return e .relaunchAfterIOSPermissionChange (ctx )
769795
770796 case parser .VerbDenyPermission :
771797 if e .deviceCtx == nil {
772798 return nil
773799 }
774- return e .deviceCtx .DenyPermission (ctx , a .Name )
800+ if err := e .deviceCtx .DenyPermission (ctx , a .Name ); err != nil {
801+ return err
802+ }
803+ return e .relaunchAfterIOSPermissionChange (ctx )
775804
776805 case parser .VerbGrantAllPerms :
777806 if e .deviceCtx == nil {
778807 return nil
779808 }
780- return e .deviceCtx .GrantAllPermissions (ctx )
809+ if err := e .deviceCtx .GrantAllPermissions (ctx ); err != nil {
810+ return err
811+ }
812+ return e .relaunchAfterIOSPermissionChange (ctx )
781813
782814 case parser .VerbRevokeAllPerms :
783815 if e .deviceCtx == nil {
784816 return nil
785817 }
786- return e .deviceCtx .RevokeAllPermissions (ctx )
818+ if err := e .deviceCtx .RevokeAllPermissions (ctx ); err != nil {
819+ return err
820+ }
821+ return e .relaunchAfterIOSPermissionChange (ctx )
787822
788823 case parser .VerbKill :
789824 if e .deviceCtx == nil {
@@ -900,6 +935,36 @@ func (e *Executor) runAction(ctx context.Context, a parser.ActionStep) error {
900935 return fmt .Errorf ("unknown action verb %q at line %d" , a .Verb , a .Line )
901936}
902937
938+ // relaunchAfterIOSPermissionChange restores the session after any of the
939+ // permission verbs runs on an iOS simulator. Campaign finding (full-feature
940+ // run, 2026-08-15): `simctl privacy grant/revoke/reset` silently TERMINATES
941+ // the target app — confirmed live via launchctl after a grant — but the
942+ // CLI's WebSocket can linger half-open with no error, so the *next* RPC
943+ // hangs until its step timeout instead of failing fast enough to trigger
944+ // auto-reconnect. Relaunch and reconnect eagerly instead, mirroring what
945+ // `restart the app` already does. No-op on Android and physical iOS (their
946+ // permission paths don't kill the app).
947+ func (e * Executor ) relaunchAfterIOSPermissionChange (ctx context.Context ) error {
948+ dc := e .deviceCtx
949+ if dc == nil || dc .Platform != device .PlatformIOS || dc .IsPhysical {
950+ return nil
951+ }
952+ e .client .Close ()
953+ if err := dc .RestartApp (ctx ); err != nil {
954+ return fmt .Errorf ("relaunch after permission change: %w" , err )
955+ }
956+ newClient , err := dc .Reconnect (ctx )
957+ if err != nil {
958+ return fmt .Errorf ("reconnect after permission change: %w" , err )
959+ }
960+ e .client = newClient
961+ e .clientGen .Add (1 )
962+ if e .onReconnect != nil {
963+ e .onReconnect (newClient )
964+ }
965+ return nil
966+ }
967+
903968// runAssertNative handles `see native "..."` / `don't see native "..."`:
904969// a native (non-Flutter) UI element matched by uiautomator's text or
905970// resource-id, dispatched through DeviceContext instead of the Dart agent.
@@ -1097,8 +1162,23 @@ func redactSelector(raw string) parser.Selector {
10971162// ---- Wait execution ----
10981163
10991164func (e * Executor ) runWait (ctx context.Context , w parser.WaitStep ) error {
1165+ // Campaign finding: a plain `wait N seconds` used to round-trip through
1166+ // the agent as an RPC — so `kill the app` followed by `wait 2 seconds`
1167+ // hit the dead connection, triggered auto-reconnect against an
1168+ // intentionally-killed app, and burned the whole step timeout (through
1169+ // an adb forward, "nothing listening" surfaces as accept-then-EOF, not
1170+ // ECONNREFUSED, so PT-18's relaunch heuristic never fired either).
1171+ // Waiting for wall-clock time needs no device at all — sleep CLI-side.
1172+ if w .Kind == parser .WaitDuration {
1173+ select {
1174+ case <- time .After (time .Duration (w .Duration * float64 (time .Second ))):
1175+ return nil
1176+ case <- ctx .Done ():
1177+ return ctx .Err ()
1178+ }
1179+ }
1180+
11001181 kindStr := map [parser.WaitKind ]string {
1101- parser .WaitDuration : "duration" ,
11021182 parser .WaitAppears : "appears" ,
11031183 parser .WaitDisappears : "disappears" ,
11041184 parser .WaitPageLoad : "page_load" ,
@@ -1216,6 +1296,21 @@ func (e *Executor) runRecipeCall(ctx context.Context, rc parser.RecipeCall) erro
12161296 stripped = stripRecipeCallArgs (rc .Name )
12171297 recipe , ok = e .recipes [stripped ]
12181298 }
1299+ if ! ok {
1300+ // Campaign finding: stripping was applied to the CALL name only,
1301+ // never the DEFINITION name — so a recipe whose own name contains a
1302+ // filler word, e.g. `recipe "add and verify" (x)`, was unreachable
1303+ // by its exact written name: the call `add and verify "v"` parses as
1304+ // "add and verify <arg>", strips to "add verify", and "add verify"
1305+ // matches nothing because the definition kept its "and". Normalize
1306+ // both sides the same way before comparing.
1307+ for defName , def := range e .recipes {
1308+ if stripRecipeCallArgs (defName ) == stripped {
1309+ recipe , ok = def , true
1310+ break
1311+ }
1312+ }
1313+ }
12191314 if ! ok {
12201315 // PT-02(a): an unrecognized recipe call used to silently no-op ("may
12211316 // be a filler line"), which masked genuine typos and broken recipe
0 commit comments