Files
sanderling/internal/runner/authored_action_parity_test.go
T
pj 7880e3e70f fix(runner): abort on a candidate enumeration that refused
Recorded as candidates_failed before the run stops, so the trace says why.

Claude-Session: https://claude.ai/code/session_01A5KmftdEJ49A9z5mF5ESrX
2026-08-13 00:44:00 +05:30

159 lines
6.5 KiB
Go

package runner
import (
"context"
"fmt"
"testing"
mockdriver "github.com/priyanshujain/sanderling/internal/driver/mock"
"github.com/priyanshujain/sanderling/internal/hierarchy"
"github.com/priyanshujain/sanderling/internal/verifier"
)
// authoredParityTreeJSON holds one target per authored action shape: a button to
// tap, a field to type into, a scrollable container to scroll, and a disabled
// button, which is a target like any other here.
const authoredParityTreeJSON = `{
"attributes": {"bounds": "[0,0,400,800]"},
"children": [
{"attributes": {"resource-id": "Save", "text": "Save", "bounds": "[0,0,200,60]"}, "clickable": true, "enabled": true, "children": []},
{"attributes": {"resource-id": "Amount", "class": "EditText", "hintText": "Amount", "bounds": "[0,100,400,160]"}, "enabled": true, "children": []},
{"attributes": {"resource-id": "List", "scrollable": "true", "bounds": "[0,300,400,700]"}, "children": []},
{"attributes": {"resource-id": "Off", "text": "Off", "bounds": "[0,700,200,760]"}, "clickable": true, "enabled": false, "children": []}
]
}`
// TestPoliciesDispatchTheSameAuthoredAction is the authored-leaf half of the
// policy-parity claim (policy_parity_test.go in the verifier package covers the
// builtin verbs): for one authored action, the seeded picker and the model's
// candidate must reach the DRIVER as the same call. The two carry it
// differently on purpose (a selector target keeps its coordinates on one side
// and re-resolves on the other), so the comparison is what executes, never the
// struct.
func TestPoliciesDispatchTheSameAuthoredAction(t *testing.T) {
fastFocusSettle(t)
cases := []struct {
name string
leaf string
}{
{"tap an element", `const e = state.ax.find("id:Save"); return e ? [Tap({on: e})] : [];`},
{"tap a selector", `return [Tap({on: "id:Save"})];`},
// Attempting a disabled control is a legitimate thing for a UI fuzzer to
// do and is exactly where boundary defects live, so neither policy may
// quietly refuse to offer it.
{"tap a disabled element", `const e = state.ax.find("id:Off"); return e ? [Tap({on: e})] : [];`},
{"tap a disabled selector", `return [Tap({on: "id:Off"})];`},
{"double-tap an element", `const e = state.ax.find("id:Save"); return e ? [DoubleTap({on: e})] : [];`},
{"long-press an element", `const e = state.ax.find("id:Save"); return e ? [LongPress({on: e})] : [];`},
{"type into an element", `const e = state.ax.find("id:Amount"); return e ? [InputText({into: e, text: "42"})] : [];`},
{"type into a selector", `return [InputText({into: "id:Amount", text: "42"})];`},
{"scroll an element", `const e = state.ax.find("id:List"); return e ? [Scroll({direction: "down", in: e})] : [];`},
{"scroll a selector", `return [Scroll({direction: "down", in: "id:List"})];`},
{"scroll with no container", `return [Scroll({direction: "up"})];`},
{"swipe with a duration", `return [Swipe({from: {x: 10, y: 600}, to: {x: 10, y: 100}, durationMillis: 300})];`},
{"swipe with no duration", `return [Swipe({from: {x: 10, y: 600}, to: {x: 10, y: 100}})];`},
{"press a key", `return [PressKey({key: "back"})];`},
{"wait", `return [Wait({durationMillis: 5})];`},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
spec := authoredSpec(testCase.leaf)
tree := mustParseTree(t, authoredParityTreeJSON)
seededVerifier := loadAuthoredSpec(t, spec, tree)
seededAction, err := seededVerifier.NextAction()
if err != nil {
t.Fatalf("seeded picker declined the authored action: %v", err)
}
modelVerifier := loadAuthoredSpec(t, spec, tree)
candidates := mustCandidates(t, modelVerifier, verifier.LabelSourceVisibleText)
if len(candidates) != 1 {
t.Fatalf("model was offered %d candidates, want the one authored action", len(candidates))
}
seeded := dispatchToMock(t, seededAction, tree)
model := dispatchToMock(t, candidates[0].Action, tree)
if len(seeded) != len(model) {
t.Fatalf("policies dispatched different calls\n seeded=%v\n model=%v", seeded, model)
}
for i := range seeded {
if seeded[i] != model[i] {
t.Errorf("call %d differs\n seeded=%+v\n model=%+v", i, seeded[i], model[i])
}
}
})
}
}
// TestSeededAuthoredScrollDragsInsideItsContainer is the half of the scroll
// contract parity alone cannot check: both policies agreeing on a drag from a
// point to itself would still be two policies scrolling nothing. The list sits
// at y 300..700, so the gesture has to start inside it and travel upward to
// reveal what is below.
func TestSeededAuthoredScrollDragsInsideItsContainer(t *testing.T) {
tree := mustParseTree(t, authoredParityTreeJSON)
spec := authoredSpec(`const e = state.ax.find("id:List"); return e ? [Scroll({direction: "down", in: e})] : [];`)
action, err := loadAuthoredSpec(t, spec, tree).NextAction()
if err != nil {
t.Fatalf("seeded picker declined the authored scroll: %v", err)
}
calls := dispatchToMock(t, action, tree)
if len(calls) != 1 {
t.Fatalf("want one driver call, got %v", calls)
}
swipe := calls[0]
if swipe.FromY < 300 || swipe.FromY > 700 {
t.Errorf("gesture starts at y=%d, outside the list (300..700)", swipe.FromY)
}
if swipe.ToY >= swipe.FromY {
t.Errorf("scroll down must drag upward, got from y=%d to y=%d", swipe.FromY, swipe.ToY)
}
}
func authoredSpec(leaf string) string {
return fmt.Sprintf(`
import { actions, DoubleTap, InputText, LongPress, PressKey, Scroll, Swipe, Tap, Wait } from "@sanderling/spec";
globalThis.actions = actions(() => { %s });
`, leaf)
}
func loadAuthoredSpec(t *testing.T, spec string, tree *hierarchy.Tree) *verifier.Verifier {
t.Helper()
loaded, err := verifier.New()
if err != nil {
t.Fatal(err)
}
if err := loaded.Load(bundleSpec(t, spec)); err != nil {
t.Fatal(err)
}
if err := loaded.PushSnapshot(verifier.SnapshotInput{Snapshots: verifier.Snapshots{}, Tree: tree}); err != nil {
t.Fatal(err)
}
return loaded
}
func mustParseTree(t *testing.T, treeJSON string) *hierarchy.Tree {
t.Helper()
tree, err := hierarchy.Parse(treeJSON)
if err != nil {
t.Fatalf("parse tree: %v", err)
}
return tree
}
// dispatchToMock runs one action through applyAction and returns what the
// driver was asked to do.
func dispatchToMock(t *testing.T, action verifier.Action, tree *hierarchy.Tree) []mockdriver.Action {
t.Helper()
drv := mockdriver.New()
skipped, err := applyAction(context.Background(), drv, action, tree)
if err != nil {
t.Fatalf("applyAction: %v", err)
}
if skipped != "" {
t.Fatalf("action %+v never reached the driver: %s", action, skipped)
}
return drv.Actions()
}