mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
refactor(spec): one candidate producer over one target-eligibility rule
Both hosts routed verbs themselves and both policies enumerated their own actions, and all four drifted. Web sent `swipes` to scrollable containers only, so swipe-to-dismiss on a list row was reachable on native and unreachable on web; the model policy folded gestures its own way and could not reach what the seeded picker drew. A host now reports facts about every element and never decides which verb may act on it: targets.ts acceptsTarget owns that for both. pick.ts builtinCandidates is the single enumeration, and the model policy reads it through __sanderlingEnumerateBuiltin__ instead of reimplementing it in Go. Gesture verbs change with it: scrolls stay vertical over scrollable containers, swipes go free-form in all four directions from any element with real bounds. Claude-Session: https://claude.ai/code/session_01Fj4wJUikdABuMQEETwW55J
This commit is contained in:
1 parent
ef04a6de9d
commit
b5f64bf665
23 files changed
+1494
-594
No files matched your search
@@ -91,7 +91,7 @@ func (v *Verifier) installRuntimeBindings() error {
|
||||
|
||||
// installHost exposes globalThis.__sanderlingHost__ for the goja runtime entry.
|
||||
// The shared picker (pick.ts) draws against it: platform() drives the verb
|
||||
// matrix and press-key pool; seedHi/seedLo construct its Pcg; queryCandidates
|
||||
// matrix and press-key pool; seedHi/seedLo construct its Pcg; queryTargets
|
||||
// enumerates targets over the hierarchy tree; reportUnsupported records the
|
||||
// verb for the run report.
|
||||
func (v *Verifier) installHost() error {
|
||||
@@ -111,7 +111,7 @@ func (v *Verifier) installHost() error {
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := host.Set("queryCandidates", v.bindQueryCandidates); err != nil {
|
||||
if err := host.Set("queryTargets", v.bindQueryTargets); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := host.Set("reportUnsupported", func(call goja.FunctionCall) goja.Value {
|
||||
@@ -133,20 +133,25 @@ func (v *Verifier) recordUnsupported(verb string) {
|
||||
v.unsupported = append(v.unsupported, verb)
|
||||
}
|
||||
|
||||
// bindQueryCandidates returns the host-enumerated targets for a verb as an
|
||||
// array of {x, y, selector, width, height}, in tree order.
|
||||
func (v *Verifier) bindQueryCandidates(call goja.FunctionCall) goja.Value {
|
||||
verb := call.Argument(0).String()
|
||||
candidates := v.candidatesForVerb(verb)
|
||||
// bindQueryTargets returns every host-enumerated target as an array of
|
||||
// {x, y, selector, width, height, clickable, enabled, editable, scrollable}, in
|
||||
// tree order. It takes no verb: the shared rule in targets.ts decides which of
|
||||
// these a verb may act on.
|
||||
func (v *Verifier) bindQueryTargets(goja.FunctionCall) goja.Value {
|
||||
targets := v.targets()
|
||||
array := v.runtime.NewArray()
|
||||
for index, candidate := range candidates {
|
||||
for index, target := range targets {
|
||||
item := v.runtime.NewObject()
|
||||
_ = item.Set("x", candidate.x)
|
||||
_ = item.Set("y", candidate.y)
|
||||
_ = item.Set("width", candidate.width)
|
||||
_ = item.Set("height", candidate.height)
|
||||
if candidate.selector != "" {
|
||||
_ = item.Set("selector", candidate.selector)
|
||||
_ = item.Set("x", target.x)
|
||||
_ = item.Set("y", target.y)
|
||||
_ = item.Set("width", target.width)
|
||||
_ = item.Set("height", target.height)
|
||||
_ = item.Set("clickable", target.clickable)
|
||||
_ = item.Set("enabled", target.enabled)
|
||||
_ = item.Set("editable", target.editable)
|
||||
_ = item.Set("scrollable", target.scrollable)
|
||||
if target.selector != "" {
|
||||
_ = item.Set("selector", target.selector)
|
||||
}
|
||||
_ = array.Set(fmt.Sprintf("%d", index), item)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package verifier
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// hostParityScreen is the canonical screen both hosts are driven over: one row
|
||||
// per fact combination that any verb distinguishes. Each host builds it in its
|
||||
// own model (this file as a hierarchy tree, pkg/spec/test/host-parity.test.ts as
|
||||
// a DOM), enumerates every verb, and asserts the SAME committed golden.
|
||||
var hostParityScreen = []struct {
|
||||
name string
|
||||
clickable bool
|
||||
enabled bool
|
||||
editable bool
|
||||
scrollable bool
|
||||
positiveBounds bool
|
||||
}{
|
||||
{name: "root", enabled: true, scrollable: true, positiveBounds: true},
|
||||
{name: "save", clickable: true, enabled: true, positiveBounds: true},
|
||||
{name: "cancel", clickable: true, positiveBounds: true},
|
||||
{name: "amount", enabled: true, editable: true, positiveBounds: true},
|
||||
{name: "list", enabled: true, scrollable: true, positiveBounds: true},
|
||||
{name: "row", enabled: true, positiveBounds: true},
|
||||
{name: "collapsed", clickable: true, enabled: true},
|
||||
}
|
||||
|
||||
// hostParityTreeJSON is hostParityScreen as the native host sees it. Tree order
|
||||
// is pre-order, so it matches hostParityScreen index for index.
|
||||
const hostParityTreeJSON = `{
|
||||
"attributes": {"resource-id": "root", "scrollable": "true", "bounds": "[0,0,400,800]"},
|
||||
"enabled": true,
|
||||
"children": [
|
||||
{"attributes": {"resource-id": "save", "bounds": "[0,0,200,60]"}, "clickable": true, "enabled": true, "children": []},
|
||||
{"attributes": {"resource-id": "cancel", "bounds": "[200,0,400,60]"}, "clickable": true, "enabled": false, "children": []},
|
||||
{"attributes": {"resource-id": "amount", "bounds": "[0,100,400,160]"}, "editable": true, "enabled": true, "children": []},
|
||||
{"attributes": {"resource-id": "list", "scrollable": "true", "bounds": "[0,200,400,600]"}, "enabled": true, "children": []},
|
||||
{"attributes": {"resource-id": "row", "bounds": "[0,600,400,680]"}, "enabled": true, "children": []},
|
||||
{"attributes": {"resource-id": "collapsed", "bounds": "[0,0,0,0]"}, "clickable": true, "enabled": true, "children": []}
|
||||
]
|
||||
}`
|
||||
|
||||
// TestHostsAgreeOnTargetEligibility is the guard on the claim that one
|
||||
// specification induces one action space on every platform. The native host and
|
||||
// the web host used to route verbs themselves and had drifted: web sent
|
||||
// `swipes` to scrollable containers only, so a swipe on a list row was
|
||||
// reachable on Android and unreachable on web for the same spec.
|
||||
//
|
||||
// Per-verb eligibility now has ONE definition (pkg/spec/src/targets.ts); a host
|
||||
// reports facts and never filters. This test is what notices if a second
|
||||
// definition grows back on either side: both hosts enumerate the same canonical
|
||||
// screen and must name the same targets, verb for verb, in the same order.
|
||||
// pkg/spec/test/host-parity.test.ts asserts the SAME golden from the web host,
|
||||
// so a match on both sides proves the two hosts agree without either invoking
|
||||
// the other.
|
||||
func TestHostsAgreeOnTargetEligibility(t *testing.T) {
|
||||
golden := loadHostParityGolden(t)
|
||||
for _, verb := range policyVerbs {
|
||||
t.Run(verb, func(t *testing.T) {
|
||||
want, ok := golden[verb]
|
||||
if !ok {
|
||||
t.Fatalf("golden has no entry for %s", verb)
|
||||
}
|
||||
verifier := newVerifier(t, WithSeed(0x5eed))
|
||||
loadActionSpec(t, verifier, fmt.Sprintf(
|
||||
"import { %s } from \"@sanderling/spec\";\nglobalThis.actions = %s;", verb, verb))
|
||||
pushTree(t, verifier, hostParityTreeJSON)
|
||||
|
||||
entries, err := verifier.enumerateBuiltin(verb)
|
||||
if err != nil {
|
||||
t.Fatalf("enumerate %s: %v", verb, err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
t.Fatalf("%s enumerated nothing at all", verb)
|
||||
}
|
||||
got := []string{}
|
||||
for _, entry := range entries {
|
||||
if entry.targetIndex < 0 {
|
||||
continue
|
||||
}
|
||||
if entry.targetIndex >= len(hostParityScreen) {
|
||||
t.Fatalf("%s produced target index %d, off the %d-row screen",
|
||||
verb, entry.targetIndex, len(hostParityScreen))
|
||||
}
|
||||
got = append(got, hostParityScreen[entry.targetIndex].name)
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("native host targets for %s\n got=%v\nwant=%v", verb, got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func loadHostParityGolden(t *testing.T) map[string][]string {
|
||||
t.Helper()
|
||||
path, err := filepath.Abs("../../pkg/spec/test/fixtures/host-parity-golden.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read golden: %v", err)
|
||||
}
|
||||
golden := map[string][]string{}
|
||||
if err := json.Unmarshal(body, &golden); err != nil {
|
||||
t.Fatalf("decode golden: %v", err)
|
||||
}
|
||||
return golden
|
||||
}
|
||||
+100
-121
@@ -1,6 +1,7 @@
|
||||
package verifier
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
@@ -130,54 +131,21 @@ type ActionCandidate struct {
|
||||
prob float64
|
||||
}
|
||||
|
||||
// verbActionKind maps a picker verb to the action kind it dispatches.
|
||||
func verbActionKind(verb string) ActionKind {
|
||||
switch verb {
|
||||
case "taps":
|
||||
return ActionKindTap
|
||||
case "doubleTaps":
|
||||
return ActionKindDoubleTap
|
||||
case "longPresses":
|
||||
return ActionKindLongPress
|
||||
case "typing":
|
||||
return ActionKindInputText
|
||||
case "scrolls":
|
||||
return ActionKindScroll
|
||||
case "swipes":
|
||||
return ActionKindSwipe
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// maxLabelRunes caps a visible-text label so joined descendant text stays short
|
||||
// enough to render on one numbered line.
|
||||
const maxLabelRunes = 40
|
||||
|
||||
// gestureDirections are the directional scrolls emitted per scrollable
|
||||
// container. Vertical only: most mobile lists scroll up/down, and keeping the
|
||||
// set tiny is the whole point of folding per-element swipes away.
|
||||
var gestureDirections = []string{"down", "up"}
|
||||
|
||||
// Candidates enumerates every action the spec's weighted actionsRoot yields at
|
||||
// the current step, each tagged with a plainly-worded description and its
|
||||
// effective weight, for the LLM generator to pick one number from. It walks the
|
||||
// SAME tree the seeded picker draws: weighted branches recurse (accumulating the
|
||||
// selection probability), authored actions()/whenRoute leaves are called once
|
||||
// for their concrete actions, and builtin verbs enumerate per applicable
|
||||
// element. Disabled controls are dropped, per-element gestures fold into a few
|
||||
// directional scrolls over scrollable containers, and identical descriptions
|
||||
// dedup (summing weight).
|
||||
// for their concrete actions, and builtin verbs come straight from the picker's
|
||||
// own enumeration. Identical descriptions dedup, summing weight.
|
||||
func (v *Verifier) Candidates() []ActionCandidate {
|
||||
if v.lastTree == nil {
|
||||
return nil
|
||||
}
|
||||
// A cross-fade frame's layout is mid-animation, often in a collapsed
|
||||
// coordinate space, so acting on it taps garbage (e.g. the soft keyboard).
|
||||
// Skip it so the LLM re-observes a settled frame next step.
|
||||
if v.lastTree.Transitional() {
|
||||
return nil
|
||||
}
|
||||
root := v.runtime.GlobalObject().Get("actions")
|
||||
if root == nil || goja.IsUndefined(root) || goja.IsNull(root) {
|
||||
return nil
|
||||
@@ -333,6 +301,18 @@ func (v *Verifier) candidateFromDescriptor(value goja.Value, nodeIndex map[*hier
|
||||
Direction: direction,
|
||||
Action: Action{Kind: kind, On: target.selector, Direction: direction},
|
||||
}, true
|
||||
case ActionKindSwipe:
|
||||
from := v.resolveTarget(object.Get("from"), nodeIndex)
|
||||
to := v.resolveTarget(object.Get("to"), nodeIndex)
|
||||
return ActionCandidate{
|
||||
Kind: kind,
|
||||
Action: Action{
|
||||
Kind: kind,
|
||||
FromX: from.x, FromY: from.y,
|
||||
ToX: to.x, ToY: to.y,
|
||||
DurationMillis: intField(object, "durationMillis"),
|
||||
},
|
||||
}, true
|
||||
case ActionKindPressKey:
|
||||
return ActionCandidate{
|
||||
Kind: kind,
|
||||
@@ -410,103 +390,80 @@ func (v *Verifier) findBySelector(selector string) *hierarchy.Element {
|
||||
return v.lastTree.Find(selector)
|
||||
}
|
||||
|
||||
// collectBuiltin enumerates a builtin verb over the current tree: tap-family and
|
||||
// typing emit one candidate per applicable element; scrolls/swipes fold into
|
||||
// directional gestures over scrollable containers.
|
||||
// collectBuiltin turns the picker's own enumeration of a builtin verb into
|
||||
// candidates. The list comes from the bundle's __sanderlingEnumerateBuiltin__
|
||||
// (pick.ts builtinCandidates), which is what the seeded policy draws from, so
|
||||
// the two policies select over one action space and cannot drift apart. Each
|
||||
// entry's action arrives on the wire contract DecodeAction already reads, so a
|
||||
// chosen candidate executes the action the seeded draw would have executed.
|
||||
func (v *Verifier) collectBuiltin(verb string, prob float64, weighted bool, nodeIndex map[*hierarchy.Element]*hierarchy.Node, out *[]ActionCandidate) {
|
||||
switch verb {
|
||||
case "taps", "doubleTaps", "longPresses":
|
||||
kind := verbActionKind(verb)
|
||||
for _, element := range v.elementsForVerb(verb) {
|
||||
x, y := element.Bounds.Center()
|
||||
*out = append(*out, ActionCandidate{
|
||||
Kind: kind,
|
||||
Label: visibleLabel(element, nodeIndex),
|
||||
Action: Action{Kind: kind, On: selectorForElement(v.lastTree, element), X: x, Y: y},
|
||||
prob: prob,
|
||||
Weighted: weighted,
|
||||
})
|
||||
entries, err := v.enumerateBuiltin(verb)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
targets := v.targets()
|
||||
for _, entry := range entries {
|
||||
candidate := ActionCandidate{
|
||||
Kind: entry.action.Kind,
|
||||
Direction: entry.action.Direction,
|
||||
// Builtin typing enumerates the field, not the value: the seeded
|
||||
// policy draws its text from the corpus and the model writes its own.
|
||||
LLMText: entry.action.Kind == ActionKindInputText,
|
||||
Action: entry.action,
|
||||
prob: prob,
|
||||
Weighted: weighted,
|
||||
}
|
||||
case "typing":
|
||||
for _, element := range v.elementsForVerb(verb) {
|
||||
x, y := element.Bounds.Center()
|
||||
*out = append(*out, ActionCandidate{
|
||||
Kind: ActionKindInputText,
|
||||
Label: visibleLabel(element, nodeIndex),
|
||||
InputType: inputTypeHint(element),
|
||||
LLMText: true,
|
||||
Action: Action{Kind: ActionKindInputText, On: selectorForElement(v.lastTree, element), X: x, Y: y},
|
||||
prob: prob,
|
||||
Weighted: weighted,
|
||||
})
|
||||
if entry.targetIndex >= 0 && entry.targetIndex < len(targets) {
|
||||
element := targets[entry.targetIndex].element
|
||||
candidate.Label = visibleLabel(element, nodeIndex)
|
||||
candidate.InputType = inputTypeHint(element)
|
||||
}
|
||||
case "scrolls", "swipes":
|
||||
v.collectGestures(prob, weighted, out)
|
||||
*out = append(*out, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
// collectGestures emits directional scrolls scoped to each scrollable container,
|
||||
// never per element and never element-labeled. Folding both scrolls and swipes
|
||||
// here is what removes the flood of mislabeled `Swipe "X"` gestures.
|
||||
func (v *Verifier) collectGestures(prob float64, weighted bool, out *[]ActionCandidate) {
|
||||
scope := v.scopedElements()
|
||||
for _, element := range v.lastTree.Elements {
|
||||
if !scope[element] {
|
||||
continue
|
||||
}
|
||||
if element.Attributes["scrollable"] != "true" {
|
||||
continue
|
||||
}
|
||||
if element.Bounds.Width() <= 0 || element.Bounds.Height() <= 0 {
|
||||
continue
|
||||
}
|
||||
selector := selectorForElement(v.lastTree, element)
|
||||
for _, direction := range gestureDirections {
|
||||
action := Action{Kind: ActionKindScroll, On: selector, Direction: direction}
|
||||
if selector == "" {
|
||||
action.FromX, action.FromY, action.ToX, action.ToY = scrollGeometry(element.Bounds, direction)
|
||||
}
|
||||
*out = append(*out, ActionCandidate{
|
||||
Kind: ActionKindScroll,
|
||||
Direction: direction,
|
||||
Action: action,
|
||||
prob: prob,
|
||||
Weighted: weighted,
|
||||
})
|
||||
}
|
||||
}
|
||||
// builtinCandidate is one entry of the shared builtin enumeration: the concrete
|
||||
// action, plus the index of the host candidate it targets (-1 when the verb has
|
||||
// no target, as for a key press or a wait).
|
||||
type builtinCandidate struct {
|
||||
action Action
|
||||
targetIndex int
|
||||
}
|
||||
|
||||
// scrollGeometry lowers a directional scroll to swipe endpoints over the given
|
||||
// container bounds, matching the runner's own derivation, used only when the
|
||||
// container has no resolving selector.
|
||||
func scrollGeometry(bounds hierarchy.Bounds, direction string) (fromX, fromY, toX, toY int) {
|
||||
cx, cy := bounds.Center()
|
||||
fromX, fromY, toX, toY = cx, cy, cx, cy
|
||||
switch direction {
|
||||
case "down":
|
||||
toY = cy - 4*bounds.Height()/10
|
||||
case "up":
|
||||
toY = cy + 4*bounds.Height()/10
|
||||
case "left":
|
||||
toX = cx + 4*bounds.Width()/10
|
||||
case "right":
|
||||
toX = cx - 4*bounds.Width()/10
|
||||
// enumerateBuiltin invokes the bundle's shared enumeration for one verb. A spec
|
||||
// loaded without the runtime entry (a raw-JS unit fixture) has no enumerator, so
|
||||
// the verb contributes nothing rather than falling back to a second enumeration.
|
||||
func (v *Verifier) enumerateBuiltin(verb string) ([]builtinCandidate, error) {
|
||||
if v.enumerateBuiltinFn == nil {
|
||||
return nil, errors.New("verifier: builtin enumeration not available")
|
||||
}
|
||||
return fromX, fromY, max(0, toX), max(0, toY)
|
||||
}
|
||||
|
||||
// elementsForVerb returns the in-scope elements a builtin verb applies to, in
|
||||
// tree order (the seeded picker's enumeration order), reusing verbAccepts.
|
||||
func (v *Verifier) elementsForVerb(verb string) []*hierarchy.Element {
|
||||
scope := v.scopedElements()
|
||||
var elements []*hierarchy.Element
|
||||
for _, element := range v.lastTree.Elements {
|
||||
if scope[element] && verbAccepts(verb, element) {
|
||||
elements = append(elements, element)
|
||||
value, err := v.enumerateBuiltinFn(goja.Undefined(), v.runtime.ToValue(verb))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("enumerate %s: %w", verb, err)
|
||||
}
|
||||
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
||||
return nil, nil
|
||||
}
|
||||
raw, err := json.Marshal(value.Export())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal %s enumeration: %w", verb, err)
|
||||
}
|
||||
var wire []struct {
|
||||
Action json.RawMessage `json:"action"`
|
||||
TargetIndex int `json:"targetIndex"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wire); err != nil {
|
||||
return nil, fmt.Errorf("decode %s enumeration: %w", verb, err)
|
||||
}
|
||||
entries := make([]builtinCandidate, 0, len(wire))
|
||||
for _, item := range wire {
|
||||
action, err := DecodeAction(item.Action)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, builtinCandidate{action: action, targetIndex: item.TargetIndex})
|
||||
}
|
||||
return elements
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// finalizeCandidates renders each candidate's description, dedups identical
|
||||
@@ -555,6 +512,18 @@ func describeCandidate(candidate ActionCandidate) string {
|
||||
return fmt.Sprintf("Type %q into %q", candidate.Action.Text, candidate.Label)
|
||||
case ActionKindScroll:
|
||||
return "Scroll " + candidate.Direction
|
||||
case ActionKindSwipe:
|
||||
// A swipe carries endpoints and no selector, so the coordinates are what
|
||||
// keep two swipes distinct. The label is prepended when the origin
|
||||
// element has one, because "swipe that row" is the interaction a model
|
||||
// reaches for and a bare pair of points does not say which row.
|
||||
where := fmt.Sprintf("from (%d,%d) to (%d,%d)",
|
||||
candidate.Action.FromX, candidate.Action.FromY,
|
||||
candidate.Action.ToX, candidate.Action.ToY)
|
||||
if candidate.Label == "" {
|
||||
return "Swipe " + where
|
||||
}
|
||||
return fmt.Sprintf("Swipe %q %s", candidate.Label, where)
|
||||
case ActionKindPressKey:
|
||||
return "Press " + candidate.Action.Key
|
||||
case ActionKindWait:
|
||||
@@ -682,3 +651,13 @@ func stringField(object *goja.Object, key string) string {
|
||||
}
|
||||
return value.String()
|
||||
}
|
||||
|
||||
// intField reads a numeric property off a goja object, returning 0 when absent,
|
||||
// null, or undefined.
|
||||
func intField(object *goja.Object, key string) int {
|
||||
value := object.Get(key)
|
||||
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
||||
return 0
|
||||
}
|
||||
return int(value.ToInteger())
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// enumTreeJSON exercises every labeling path: a clickable wrapper whose own text
|
||||
// is empty but whose child Text reads "Add credit" (descendant borrowing), an
|
||||
// editable field labeled by its hint, a text-labeled button, a DISABLED button,
|
||||
// and a scrollable list (the only valid gesture origin).
|
||||
// and a scrollable list (the only valid scroll origin).
|
||||
const enumTreeJSON = `{
|
||||
"attributes": {"bounds": "[0,0,1080,2400]"},
|
||||
"children": [
|
||||
@@ -25,10 +25,13 @@ const enumTreeJSON = `{
|
||||
}`
|
||||
|
||||
// enumVerifier loads a spec whose actions root is the given plain-object graph
|
||||
// and stages the given tree, so Candidates walks a controlled action tree.
|
||||
// and stages the given tree, so Candidates walks a controlled action tree. The
|
||||
// spec is bundled with the goja runtime entry because the model arm reads the
|
||||
// picker's own builtin enumeration out of that bundle.
|
||||
func enumVerifier(t *testing.T, actionsJS, treeJSON string) *Verifier {
|
||||
t.Helper()
|
||||
v := newLoadedVerifier(t, "globalThis.actions = "+actionsJS+";")
|
||||
v := newVerifier(t)
|
||||
loadActionSpec(t, v, "globalThis.actions = "+actionsJS+";")
|
||||
tree, err := hierarchy.Parse(treeJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("parse tree: %v", err)
|
||||
@@ -119,26 +122,38 @@ func TestCandidatesLabelsEditableFieldByHintNotTypedValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidatesFoldsGesturesIntoDirectionalScrolls(t *testing.T) {
|
||||
func TestCandidatesKeepsGestureVerbsDistinct(t *testing.T) {
|
||||
v := enumVerifier(t,
|
||||
"{kind:'weighted', branches:[[1,{kind:'builtin',verb:'scrolls'}],[1,{kind:'builtin',verb:'swipes'}]]}",
|
||||
enumTreeJSON)
|
||||
candidates := v.Candidates()
|
||||
|
||||
// Gestures are directional and scoped to the one scrollable container: no
|
||||
// per-element, element-labeled Swipe entries.
|
||||
for _, candidate := range candidates {
|
||||
if strings.HasPrefix(candidate.Description, "Swipe") {
|
||||
t.Errorf("gesture kept as element-labeled swipe: %q", candidate.Description)
|
||||
}
|
||||
}
|
||||
// `scrolls` folds to one directional pair over the single scrollable
|
||||
// container, which is what keeps the list short.
|
||||
if !hasCandidate(candidates, "Scroll down") || !hasCandidate(candidates, "Scroll up") {
|
||||
t.Errorf("want directional scrolls, got %v", descriptions(candidates))
|
||||
}
|
||||
// scrolls and swipes fold into the SAME directional entries: one each.
|
||||
if got := count(candidates, "Scroll down"); got != 1 {
|
||||
t.Errorf("Scroll down appears %d times, want 1 (folded)", got)
|
||||
t.Errorf("Scroll down appears %d times, want 1 over the one container", got)
|
||||
}
|
||||
// `swipes` is a different verb, not a second name for the scroll: a
|
||||
// free-form drag from any element, named by the control it starts on. That
|
||||
// is what puts swipe-to-dismiss on a row within the model's reach.
|
||||
if !hasCandidatePrefix(candidates, `Swipe "Sign in"`) {
|
||||
t.Errorf("want a swipe naming the non-scrollable row, got %v", descriptions(candidates))
|
||||
}
|
||||
if hasCandidatePrefix(candidates, "Scroll \"") {
|
||||
t.Errorf("scroll candidates must stay container-scoped: %v", descriptions(candidates))
|
||||
}
|
||||
}
|
||||
|
||||
func hasCandidatePrefix(candidates []ActionCandidate, prefix string) bool {
|
||||
for _, candidate := range candidates {
|
||||
if strings.HasPrefix(candidate.Description, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestCandidatesWeightsCombineAcrossPaths(t *testing.T) {
|
||||
@@ -228,6 +243,23 @@ func TestCandidatesCallsAuthoredLeafOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidatesSurfaceAuthoredUntargetedActions(t *testing.T) {
|
||||
// A spec that authors a swipe, a key press, or a wait must reach the model
|
||||
// with all three: the seeded picker executes whatever the leaf returns, so a
|
||||
// kind the enumeration drops is an action only one policy can take.
|
||||
actions := `{kind:'actions', generate: () => [
|
||||
{kind:'Swipe', from:{x:10,y:600}, to:{x:10,y:100}, durationMillis: 250},
|
||||
{kind:'PressKey', key:'back'},
|
||||
{kind:'Wait'}
|
||||
]}`
|
||||
candidates := enumVerifier(t, actions, enumTreeJSON).Candidates()
|
||||
for _, want := range []string{"Swipe from (10,600) to (10,100)", "Press back", "Wait"} {
|
||||
if !hasCandidate(candidates, want) {
|
||||
t.Errorf("authored %q missing: %v", want, descriptions(candidates))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidatesOffRouteLeafYieldsNothing(t *testing.T) {
|
||||
v := enumVerifier(t, "{kind:'actions', generate: () => []}", enumTreeJSON)
|
||||
if got := v.Candidates(); len(got) != 0 {
|
||||
@@ -248,9 +280,14 @@ func TestCandidatesSkipsCrossFadeFrames(t *testing.T) {
|
||||
]
|
||||
}`
|
||||
v := enumVerifier(t, "{kind:'builtin', verb:'taps'}", crossFade)
|
||||
if got := v.Candidates(); got != nil {
|
||||
if got := v.Candidates(); len(got) != 0 {
|
||||
t.Errorf("cross-fade frame should yield no candidates, got %v", descriptions(got))
|
||||
}
|
||||
// The seeded policy is skipped by the SAME guard, in the shared producer,
|
||||
// so neither arm acts on a mid-animation layout.
|
||||
if got := v.targets(); len(got) != 0 {
|
||||
t.Errorf("cross-fade frame should yield no host targets, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidatesNilWithoutTreeOrActions(t *testing.T) {
|
||||
@@ -345,5 +382,3 @@ func newLoadedVerifier(t *testing.T, source string) *Verifier {
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
|
||||
@@ -48,22 +48,25 @@ func TestCrossRuntimeParity(t *testing.T) {
|
||||
}
|
||||
|
||||
// installStubHost replaces the verifier's hierarchy-backed __sanderlingHost__
|
||||
// with one returning a FIXED candidate list for every verb, keeping the seed the
|
||||
// verifier was constructed with. It must run BEFORE Load, because the bundled
|
||||
// goja runtime entry captures the host when the spec evaluates.
|
||||
// with one returning a FIXED target list, keeping the seed the verifier was
|
||||
// constructed with. Every fact is set so the shared eligibility rule admits all
|
||||
// three targets for every verb, leaving the draw order as the only variable. It
|
||||
// must run BEFORE Load, because the bundled goja runtime entry captures the host
|
||||
// when the spec evaluates.
|
||||
func installStubHost(t *testing.T, verifier *Verifier) {
|
||||
t.Helper()
|
||||
const stub = `
|
||||
const candidates = [
|
||||
{ x: 50, y: 60, selector: "id:alpha", width: 100, height: 40 },
|
||||
{ x: 150, y: 160, selector: "id:beta", width: 120, height: 48 },
|
||||
{ x: 250, y: 260, selector: "id:gamma", width: 80, height: 32 },
|
||||
const facts = { clickable: true, enabled: true, editable: true, scrollable: true };
|
||||
const targets = [
|
||||
{ x: 50, y: 60, selector: "id:alpha", width: 100, height: 40, ...facts },
|
||||
{ x: 150, y: 160, selector: "id:beta", width: 120, height: 48, ...facts },
|
||||
{ x: 250, y: 260, selector: "id:gamma", width: 80, height: 32, ...facts },
|
||||
];
|
||||
const seedHi = globalThis.__sanderlingHost__.seedHi;
|
||||
const seedLo = globalThis.__sanderlingHost__.seedLo;
|
||||
globalThis.__sanderlingHost__ = {
|
||||
platform: () => "android",
|
||||
queryCandidates: () => candidates,
|
||||
queryTargets: () => targets,
|
||||
reportUnsupported: () => {},
|
||||
seedHi,
|
||||
seedLo,
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package verifier
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// policyTreeJSON gives every builtin verb something to act on, with every label
|
||||
// distinct so no two candidates render the same way.
|
||||
const policyTreeJSON = `{
|
||||
"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": "Cancel", "text": "Cancel", "bounds": "[200,0,400,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": "Note", "class": "EditText", "hintText": "Note", "bounds": "[0,200,400,260]"}, "enabled": true, "children": []},
|
||||
{"attributes": {"resource-id": "List", "scrollable": "true", "bounds": "[0,300,400,700]"}, "children": []}
|
||||
]
|
||||
}`
|
||||
|
||||
// policyVerbs is every builtin verb a spec can put in its action tree.
|
||||
var policyVerbs = []string{
|
||||
"taps", "doubleTaps", "longPresses", "typing", "scrolls", "swipes", "pressKeys", "waitOnce",
|
||||
}
|
||||
|
||||
// seededDrawBudget is how many times the seeded picker is driven per verb. The
|
||||
// candidate sets here are a handful of entries wide, so this exhausts them many
|
||||
// times over; a miss would mean the picker cannot reach one of its own
|
||||
// candidates, which is itself the bug worth failing on.
|
||||
const seededDrawBudget = 300
|
||||
|
||||
// TestPoliciesEnumerateTheSameActions is the guard on the claim the paper makes
|
||||
// about the two action policies: they differ in the pick and in nothing else.
|
||||
// For every builtin verb, the actions the seeded picker can draw and the
|
||||
// candidates the model policy is offered must be the same set. Enumeration lives
|
||||
// in one place (pick.ts builtinCandidates) precisely so this cannot drift, and
|
||||
// this test is what notices if a second one ever grows back.
|
||||
func TestPoliciesEnumerateTheSameActions(t *testing.T) {
|
||||
for _, verb := range policyVerbs {
|
||||
t.Run(verb, func(t *testing.T) {
|
||||
seeded := seededReachableActions(t, verb)
|
||||
model := modelOfferedActions(t, verb)
|
||||
if len(model) == 0 {
|
||||
t.Fatalf("%s offered the model no candidates at all", verb)
|
||||
}
|
||||
if !slices.Equal(slices.Sorted(maps.Keys(seeded)), slices.Sorted(maps.Keys(model))) {
|
||||
t.Errorf("action spaces differ for %s\n seeded=%v\n model=%v",
|
||||
verb, slices.Sorted(maps.Keys(seeded)), slices.Sorted(maps.Keys(model)))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelCandidateDescriptionsAreUniqueAndNamed checks the rendering half of
|
||||
// the contract: every enumerated action reaches the model as its own distinctly
|
||||
// named line, so the number it picks and the action that executes agree.
|
||||
func TestModelCandidateDescriptionsAreUniqueAndNamed(t *testing.T) {
|
||||
for _, verb := range policyVerbs {
|
||||
t.Run(verb, func(t *testing.T) {
|
||||
verifier := loadVerbSpec(t, verb)
|
||||
seen := map[string]bool{}
|
||||
for _, candidate := range verifier.Candidates() {
|
||||
if candidate.Description == "" {
|
||||
t.Fatalf("%s produced a candidate with no description: %+v", verb, candidate.Action)
|
||||
}
|
||||
if seen[candidate.Description] {
|
||||
t.Errorf("%s rendered %q twice, so the model cannot address both",
|
||||
verb, candidate.Description)
|
||||
}
|
||||
seen[candidate.Description] = true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelIsOfferedTheUntargetedVerbs pins the two verbs the model arm used to
|
||||
// be blind to: with no element to enumerate over, a key press and a wait were
|
||||
// dropped, so the model could never navigate back or let the app settle.
|
||||
func TestModelIsOfferedTheUntargetedVerbs(t *testing.T) {
|
||||
verifier := loadVerbSpec(t, "pressKeys")
|
||||
if !hasCandidate(verifier.Candidates(), "Press back") {
|
||||
t.Errorf("pressKeys missing from the model's candidates: %v",
|
||||
descriptions(verifier.Candidates()))
|
||||
}
|
||||
verifier = loadVerbSpec(t, "waitOnce")
|
||||
if !hasCandidate(verifier.Candidates(), "Wait") {
|
||||
t.Errorf("waitOnce missing from the model's candidates: %v",
|
||||
descriptions(verifier.Candidates()))
|
||||
}
|
||||
}
|
||||
|
||||
// loadVerbSpec builds a verifier whose whole action tree is one builtin verb,
|
||||
// with policyTreeJSON pushed as the current state.
|
||||
func loadVerbSpec(t *testing.T, verb string) *Verifier {
|
||||
t.Helper()
|
||||
verifier := newVerifier(t, WithSeed(0x5eed))
|
||||
loadActionSpec(t, verifier, fmt.Sprintf(
|
||||
"import { %s } from \"@sanderling/spec\";\nglobalThis.actions = %s;", verb, verb))
|
||||
pushTree(t, verifier, policyTreeJSON)
|
||||
return verifier
|
||||
}
|
||||
|
||||
// seededReachableActions drives the seeded picker over its draw budget and
|
||||
// returns every distinct action it produced.
|
||||
func seededReachableActions(t *testing.T, verb string) map[string]Action {
|
||||
t.Helper()
|
||||
verifier := loadVerbSpec(t, verb)
|
||||
reachable := map[string]Action{}
|
||||
for range seededDrawBudget {
|
||||
action, err := verifier.NextAction()
|
||||
if errors.Is(err, ErrNoAction) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("%s next action: %v", verb, err)
|
||||
}
|
||||
reachable[actionIdentity(action)] = action
|
||||
}
|
||||
return reachable
|
||||
}
|
||||
|
||||
// modelOfferedActions returns the actions behind the numbered list the model
|
||||
// policy picks from.
|
||||
func modelOfferedActions(t *testing.T, verb string) map[string]Action {
|
||||
t.Helper()
|
||||
verifier := loadVerbSpec(t, verb)
|
||||
offered := map[string]Action{}
|
||||
for _, candidate := range verifier.Candidates() {
|
||||
offered[actionIdentity(candidate.Action)] = candidate.Action
|
||||
}
|
||||
return offered
|
||||
}
|
||||
|
||||
// actionIdentity keys an action by everything except the values the policy owns
|
||||
// rather than the candidate set: the typed text, which the seeded arm draws from
|
||||
// the edge-case corpus and the model writes itself, and a swipe's drag distance,
|
||||
// which the seeded arm draws and the enumeration lists at a nominal length.
|
||||
// Comparing those would compare policies instead of action spaces. A swipe's
|
||||
// direction is NOT policy-owned, so it survives as the sign of the drag.
|
||||
func actionIdentity(action Action) string {
|
||||
action.Text = ""
|
||||
if action.Kind == ActionKindSwipe {
|
||||
action.ToX = sign(action.ToX - action.FromX)
|
||||
action.ToY = sign(action.ToY - action.FromY)
|
||||
}
|
||||
return fmt.Sprintf("%+v", action)
|
||||
}
|
||||
|
||||
func sign(value int) int {
|
||||
switch {
|
||||
case value > 0:
|
||||
return 1
|
||||
case value < 0:
|
||||
return -1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
+101
-23
@@ -105,30 +105,108 @@ func TestTyping_ExcludeOffAppPackage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwipes_ExcludeOffAppPackage proves swipes anchor on app nodes only, so
|
||||
// exploration never scrolls the keyboard's emoji list instead of the app.
|
||||
func TestSwipes_ExcludeOffAppPackage(t *testing.T) {
|
||||
verifier := newVerifier(t, WithAppPackage("com.folio"))
|
||||
loadActionSpec(t, verifier, `
|
||||
import { swipes } from "@sanderling/spec";
|
||||
globalThis.actions = swipes;
|
||||
`)
|
||||
pushTree(t, verifier, scopedTreeJSON)
|
||||
// gestureTreeJSON gives each gesture verb a legal and an illegal anchor: an app
|
||||
// list holding a plain row, against the soft keyboard's own scrollable strip
|
||||
// holding one of its keys.
|
||||
const gestureTreeJSON = `{
|
||||
"attributes": {"resource-id": "root", "bounds": "[0,0,100,500]", "package": "com.folio"},
|
||||
"children": [
|
||||
{"attributes": {"testTag": "AppList", "scrollable": "true", "bounds": "[0,0,100,300]", "package": "com.folio"}, "children": [
|
||||
{"attributes": {"testTag": "Row", "bounds": "[0,0,100,60]", "package": "com.folio"}, "children": []}
|
||||
]},
|
||||
{"attributes": {"testTag": "EmojiStrip", "scrollable": "true", "bounds": "[0,400,100,440]", "package": "com.google.android.inputmethod.latin"}, "children": [
|
||||
{"attributes": {"testTag": "EmojiKey", "bounds": "[0,400,100,420]", "package": "com.google.android.inputmethod.latin"}, "children": []}
|
||||
]}
|
||||
]
|
||||
}`
|
||||
|
||||
// Both the root and SubmitButton (com.folio) are valid anchors; only the
|
||||
// keyboard key at center (50,420) must be excluded. Draw many times so the
|
||||
// invariant is not satisfied by a lucky seed.
|
||||
for i := range 200 {
|
||||
action, err := verifier.NextAction()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if action.Kind != ActionKindSwipe {
|
||||
t.Fatalf("kind = %v, want Swipe", action.Kind)
|
||||
}
|
||||
if action.FromX == 50 && action.FromY == 420 {
|
||||
t.Fatalf("draw %d anchored on the keyboard key (50,420); off-app node leaked into swipe targets", i)
|
||||
}
|
||||
// TestGestures_ExcludeOffAppPackage proves both gesture verbs anchor on app
|
||||
// nodes only, so exploration never drags the keyboard instead of the app, and
|
||||
// pins what each verb accepts within the app and which way it drags. Scrolls
|
||||
// anchor on the scrollable list alone and stay vertical, because every
|
||||
// scrollable container earns a candidate and scrolling a list means up and down.
|
||||
// Swipes anchor on any app element, the row and the root included, and drag in
|
||||
// all four directions, which is what puts swipe-to-dismiss on a list row inside
|
||||
// the action space.
|
||||
func TestGestures_ExcludeOffAppPackage(t *testing.T) {
|
||||
tests := []struct {
|
||||
verb string
|
||||
kind ActionKind
|
||||
anchors map[[2]int]bool
|
||||
directions map[string]bool
|
||||
}{
|
||||
{
|
||||
"scrolls",
|
||||
ActionKindScroll,
|
||||
map[[2]int]bool{{50, 150}: true},
|
||||
map[string]bool{"down": true, "up": true},
|
||||
},
|
||||
{
|
||||
"swipes",
|
||||
ActionKindSwipe,
|
||||
map[[2]int]bool{{50, 250}: true, {50, 150}: true, {50, 30}: true},
|
||||
map[string]bool{"down": true, "up": true, "left": true, "right": true},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.verb, func(t *testing.T) {
|
||||
verifier := newVerifier(t, WithAppPackage("com.folio"))
|
||||
loadActionSpec(t, verifier, `
|
||||
import { `+test.verb+` } from "@sanderling/spec";
|
||||
globalThis.actions = `+test.verb+`;
|
||||
`)
|
||||
pushTree(t, verifier, gestureTreeJSON)
|
||||
|
||||
// Draw many times so neither the exclusion nor the coverage below is
|
||||
// satisfied by a lucky seed. The keyboard strip (50,420) and its key
|
||||
// (50,410) are the anchors that must never appear.
|
||||
seen := map[[2]int]bool{}
|
||||
seenDirections := map[string]bool{}
|
||||
for i := range 400 {
|
||||
action, err := verifier.NextAction()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if action.Kind != test.kind {
|
||||
t.Fatalf("kind = %v, want %v", action.Kind, test.kind)
|
||||
}
|
||||
anchor := [2]int{action.FromX, action.FromY}
|
||||
if !test.anchors[anchor] {
|
||||
t.Fatalf("draw %d anchored at %v, outside %v", i, anchor, test.anchors)
|
||||
}
|
||||
direction := gestureDirection(action)
|
||||
if !test.directions[direction] {
|
||||
t.Fatalf("draw %d dragged %s, outside %v", i, direction, test.directions)
|
||||
}
|
||||
seen[anchor] = true
|
||||
seenDirections[direction] = true
|
||||
}
|
||||
if len(seen) != len(test.anchors) {
|
||||
t.Errorf("reached anchors %v, want all of %v", seen, test.anchors)
|
||||
}
|
||||
if len(seenDirections) != len(test.directions) {
|
||||
t.Errorf("reached directions %v, want all of %v", seenDirections, test.directions)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// gestureDirection names which way a drawn gesture drags. A scroll carries the
|
||||
// name it was enumerated under; a swipe carries only its endpoints, so the sign
|
||||
// of the drag is what says where the finger went.
|
||||
func gestureDirection(action Action) string {
|
||||
if action.Kind == ActionKindScroll {
|
||||
return action.Direction
|
||||
}
|
||||
switch {
|
||||
case action.ToX > action.FromX:
|
||||
return "right"
|
||||
case action.ToX < action.FromX:
|
||||
return "left"
|
||||
case action.ToY > action.FromY:
|
||||
return "down"
|
||||
default:
|
||||
return "up"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+68
-55
@@ -39,22 +39,30 @@ type Verifier struct {
|
||||
// reimplementing the corpus on the Go side.
|
||||
sampleInputFn goja.Callable
|
||||
|
||||
// enumerateBuiltinFn is the bundle-installed __sanderlingEnumerateBuiltin__,
|
||||
// which lists every action a builtin verb can yield right now. It is the same
|
||||
// enumeration the seeded picker draws from, so the LLM action backend selects
|
||||
// over the picker's action space rather than one of its own.
|
||||
enumerateBuiltinFn goja.Callable
|
||||
|
||||
evaluators map[string]*ltl.Evaluator
|
||||
|
||||
priorVerdicts map[string]ltl.Verdict
|
||||
newlyViolated []string
|
||||
witnesses map[string]Witness
|
||||
|
||||
lastTree *hierarchy.Tree
|
||||
lastScreenshot []byte
|
||||
scopeCache map[*hierarchy.Element]bool
|
||||
scopeCacheTree *hierarchy.Tree
|
||||
lastAction *Action
|
||||
lastLogs []LogEntry
|
||||
lastExceptions []Exception
|
||||
stepTime time.Time
|
||||
stepIndex int
|
||||
runStart time.Time
|
||||
lastTree *hierarchy.Tree
|
||||
lastScreenshot []byte
|
||||
scopeCache map[*hierarchy.Element]bool
|
||||
scopeCacheTree *hierarchy.Tree
|
||||
targetCache []targetElement
|
||||
targetCacheTree *hierarchy.Tree
|
||||
lastAction *Action
|
||||
lastLogs []LogEntry
|
||||
lastExceptions []Exception
|
||||
stepTime time.Time
|
||||
stepIndex int
|
||||
runStart time.Time
|
||||
|
||||
appPackage string
|
||||
platform string
|
||||
@@ -178,6 +186,12 @@ func (v *Verifier) Load(source string) error {
|
||||
}
|
||||
}
|
||||
|
||||
if fn := v.runtime.GlobalObject().Get("__sanderlingEnumerateBuiltin__"); fn != nil {
|
||||
if callable, ok := goja.AssertFunction(fn); ok {
|
||||
v.enumerateBuiltinFn = callable
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -433,7 +447,7 @@ type SnapshotInput struct {
|
||||
// callers may leave it nil.
|
||||
ScreenshotPNG []byte
|
||||
LastAction *Action
|
||||
StepTime time.Time
|
||||
StepTime time.Time
|
||||
// StepIndex is the runner's step number for this snapshot. Evaluators label
|
||||
// observations with it so violation witnesses carry runner step numbers even
|
||||
// when transitional steps were skipped. Zero means unlabeled; evaluators
|
||||
@@ -750,66 +764,65 @@ func selectorForElement(tree *hierarchy.Tree, element *hierarchy.Element) string
|
||||
return ""
|
||||
}
|
||||
|
||||
// candidatesForVerb enumerates the host-side targets a builtin verb may draw
|
||||
// from, in v.lastTree.Elements ORDER (the order is part of the picker's parity
|
||||
// contract). The filters are LIFTED from the old Go picker:
|
||||
// targets enumerates every element this host can offer, in v.lastTree.Elements
|
||||
// ORDER (the order is part of the picker's parity contract). It is the native
|
||||
// half of the single candidate producer: the picker reads it through
|
||||
// __sanderlingHost__.queryTargets, applies the SHARED per-verb eligibility rule
|
||||
// (pkg/spec/src/targets.ts), and expands what survives into concrete actions for
|
||||
// both policies. Which verb may act on which element is decided there, once, so
|
||||
// this host and the web host cannot mean different things by the same verb.
|
||||
//
|
||||
// taps/doubleTaps/longPresses: clickable + enabled + positive bounds
|
||||
// typing: editable + enabled + positive bounds
|
||||
// scrolls: scrollable attribute + positive bounds
|
||||
// swipes: any in-scope element with positive bounds
|
||||
// This host's job is the facts: clickable/enabled/editable come off the
|
||||
// accessibility node, scrollable off its attribute, and the geometry off its
|
||||
// bounds. Every target carries the resolving selector so the runner can re-route
|
||||
// by id/text. Out-of-scope nodes (the soft keyboard, system UI, the launcher)
|
||||
// are dropped by scopedElements.
|
||||
//
|
||||
// Every candidate carries the resolving selector so the runner can re-route by
|
||||
// id/text. Out-of-scope nodes (the soft keyboard, system UI, the launcher) are
|
||||
// dropped by scopedElements.
|
||||
func (v *Verifier) candidatesForVerb(verb string) []candidate {
|
||||
if v.lastTree == nil {
|
||||
// A cross-fade frame yields nothing at all. Its layout is mid-animation, often
|
||||
// in a collapsed coordinate space, so acting on it lands on garbage; skipping it
|
||||
// here rather than in one policy means both policies re-observe a settled frame
|
||||
// instead of one of them acting on the animation.
|
||||
func (v *Verifier) targets() []targetElement {
|
||||
if v.lastTree == nil || v.lastTree.Transitional() {
|
||||
return nil
|
||||
}
|
||||
if v.targetCacheTree == v.lastTree {
|
||||
return v.targetCache
|
||||
}
|
||||
scope := v.scopedElements()
|
||||
var result []candidate
|
||||
result := make([]targetElement, 0, len(v.lastTree.Elements))
|
||||
for _, element := range v.lastTree.Elements {
|
||||
if !scope[element] {
|
||||
continue
|
||||
}
|
||||
if !verbAccepts(verb, element) {
|
||||
continue
|
||||
}
|
||||
x, y := element.Bounds.Center()
|
||||
result = append(result, candidate{
|
||||
x: x,
|
||||
y: y,
|
||||
width: element.Bounds.Width(),
|
||||
height: element.Bounds.Height(),
|
||||
selector: selectorForElement(v.lastTree, element),
|
||||
result = append(result, targetElement{
|
||||
element: element,
|
||||
x: x,
|
||||
y: y,
|
||||
width: element.Bounds.Width(),
|
||||
height: element.Bounds.Height(),
|
||||
selector: selectorForElement(v.lastTree, element),
|
||||
clickable: element.Clickable,
|
||||
enabled: element.Enabled,
|
||||
editable: element.Editable,
|
||||
scrollable: element.Attributes["scrollable"] == "true",
|
||||
})
|
||||
}
|
||||
v.targetCache = result
|
||||
v.targetCacheTree = v.lastTree
|
||||
return result
|
||||
}
|
||||
|
||||
type candidate struct {
|
||||
// targetElement is one host-enumerated element with the facts the shared
|
||||
// eligibility rule reads. The host reports facts; it does not filter by verb.
|
||||
type targetElement struct {
|
||||
element *hierarchy.Element
|
||||
x, y int
|
||||
width, height int
|
||||
selector string
|
||||
}
|
||||
|
||||
// verbAccepts applies the per-verb element filter.
|
||||
func verbAccepts(verb string, element *hierarchy.Element) bool {
|
||||
positiveBounds := element.Bounds.Width() > 0 && element.Bounds.Height() > 0
|
||||
switch verb {
|
||||
case "taps", "doubleTaps", "longPresses":
|
||||
return element.Clickable && element.Enabled && positiveBounds
|
||||
case "typing":
|
||||
return element.Editable && element.Enabled && positiveBounds
|
||||
case "scrolls":
|
||||
return element.Attributes["scrollable"] == "true" && positiveBounds
|
||||
case "swipes":
|
||||
// Any visible element is a valid swipe origin, but it must have real
|
||||
// bounds: a zero-bounds node centers at (0,0), and a downward swipe from
|
||||
// the top-left corner is the system gesture that pulls down the
|
||||
// notification shade, dragging the fuzzer out of the app.
|
||||
return positiveBounds
|
||||
default:
|
||||
return false
|
||||
}
|
||||
clickable bool
|
||||
enabled bool
|
||||
editable bool
|
||||
scrollable bool
|
||||
}
|
||||
@@ -1,24 +1,45 @@
|
||||
package verifier
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
||||
)
|
||||
|
||||
// TestVerbAcceptsSwipeRequiresPositiveBounds locks the fix for the notification
|
||||
// shade: a zero-bounds element centers at (0,0), and a downward swipe from the
|
||||
// top-left corner is the system gesture that pulls the shade over the app. The
|
||||
// swipe verb must reject zero-bounds nodes like every other verb does.
|
||||
func TestVerbAcceptsSwipeRequiresPositiveBounds(t *testing.T) {
|
||||
zeroBounds := &hierarchy.Element{Bounds: hierarchy.Bounds{}}
|
||||
if verbAccepts("swipes", zeroBounds) {
|
||||
t.Error("swipes must reject a zero-bounds element (it centers at (0,0) and pulls the notification shade)")
|
||||
// TestTargetsReportFactsWithoutFiltering pins the native host's half of the
|
||||
// split introduced to stop the two hosts drifting: it reports what an element
|
||||
// IS and never decides which verb may act on it. The verb decision is one shared
|
||||
// rule (pkg/spec/src/targets.ts) both hosts consume, asserted across engines by
|
||||
// TestHostsAgreeOnTargetEligibility.
|
||||
func TestTargetsReportFactsWithoutFiltering(t *testing.T) {
|
||||
tree, err := hierarchy.Parse(hostParityTreeJSON)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
realBounds := &hierarchy.Element{Bounds: hierarchy.Bounds{Left: 100, Top: 400, Right: 980, Bottom: 600}}
|
||||
if !verbAccepts("swipes", realBounds) {
|
||||
t.Error("swipes must accept an element with positive bounds")
|
||||
v := &Verifier{lastTree: tree}
|
||||
targets := v.targets()
|
||||
if len(targets) != len(hostParityScreen) {
|
||||
t.Fatalf("targets() returned %d elements, want all %d in the tree",
|
||||
len(targets), len(hostParityScreen))
|
||||
}
|
||||
for index, target := range targets {
|
||||
want := hostParityScreen[index]
|
||||
got := []bool{
|
||||
target.clickable, target.enabled, target.editable, target.scrollable,
|
||||
}
|
||||
expected := []bool{
|
||||
want.clickable, want.enabled, want.editable, want.scrollable,
|
||||
}
|
||||
if !slices.Equal(got, expected) {
|
||||
t.Errorf("%s clickable/enabled/editable/scrollable = %v, want %v",
|
||||
want.name, got, expected)
|
||||
}
|
||||
positiveBounds := target.width > 0 && target.height > 0
|
||||
if positiveBounds != want.positiveBounds {
|
||||
t.Errorf("%s positive bounds = %v, want %v",
|
||||
want.name, positiveBounds, want.positiveBounds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,9 +65,9 @@ export type GeneratorNode =
|
||||
| { kind: "builtin"; verb: BuiltinVerb }
|
||||
| { kind: "llm"; config: { model: string; instructions?: string } };
|
||||
|
||||
// Candidate is one host-enumerated target for a builtin verb. The host
|
||||
// resolves geometry (and a native selector) so no element handle crosses into
|
||||
// the picker. width/height let swipe/scroll size a gesture off the element.
|
||||
// Candidate is the resolved geometry of one target. The host resolves it (and a
|
||||
// native selector) so no element handle crosses into the picker. width/height
|
||||
// let swipe/scroll size a gesture off the element.
|
||||
export interface Candidate {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -76,15 +76,27 @@ export interface Candidate {
|
||||
height?: number;
|
||||
}
|
||||
|
||||
// TargetElement is one host-enumerated element, offered to EVERY verb, carrying
|
||||
// the facts per-verb eligibility is decided from. The host reports the facts; it
|
||||
// does not apply them. targets.ts acceptsTarget owns that decision for both
|
||||
// hosts, so a verb cannot mean one thing on native and another on web.
|
||||
export interface TargetElement extends Candidate {
|
||||
clickable: boolean;
|
||||
enabled: boolean;
|
||||
editable: boolean;
|
||||
scrollable: boolean;
|
||||
}
|
||||
|
||||
// Host is the platform backing the picker draws against. In this foundation
|
||||
// workflow only the interface is defined and exercised against a stub; the
|
||||
// goja and DOM implementations land in the rewire workflow.
|
||||
export interface Host {
|
||||
platform(): "android" | "ios" | "web";
|
||||
// queryCandidates returns the host-enumerated targets for a verb, in a
|
||||
// deterministic order. The picker indexes into this list with the PCG, so
|
||||
// the order is part of the parity contract.
|
||||
queryCandidates(verb: BuiltinVerb): Candidate[];
|
||||
// queryTargets returns every element the host can offer, in a deterministic
|
||||
// order, with no per-verb filtering: the picker applies acceptsTarget. The
|
||||
// picker indexes into this list with the PCG, so the order is part of the
|
||||
// parity contract.
|
||||
queryTargets(): TargetElement[];
|
||||
// reportUnsupported is invoked at most once per verb@platform (see verbs.ts)
|
||||
// when a verb has no support on this platform.
|
||||
reportUnsupported(verb: BuiltinVerb): void;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Goja-side runtime entry for the native verifier (internal/verifier).
|
||||
//
|
||||
// Go installs globalThis.__sanderlingHost__ (platform/seed/queryCandidates/
|
||||
// Go installs globalThis.__sanderlingHost__ (platform/seed/queryTargets/
|
||||
// reportUnsupported, implemented over the hierarchy tree in bindings.go) before
|
||||
// the spec evaluates. This module bundles AFTER the spec, reads that host, and
|
||||
// wires the shared picker via installRuntime so the goja verifier and the V8 web
|
||||
|
||||
+161
-119
@@ -1,29 +1,44 @@
|
||||
// The shared deterministic action picker for W2 approach B.
|
||||
// The shared deterministic action picker.
|
||||
//
|
||||
// walk() traverses a GeneratorNode tree, and nextAction() wraps it with the
|
||||
// 16-attempt retry that matches worker.go NextAction. Both engines (the goja
|
||||
// verifier and the V8 web runtime) run THIS code, drawing through the shared
|
||||
// Pcg, so a given seed yields an identical action stream on every platform.
|
||||
//
|
||||
// builtinCandidates() is the ONE enumeration of what a builtin verb can do at
|
||||
// the current step. The seeded policy draws a single entry from it below; the
|
||||
// model policy (Go, internal/verifier/llm.go) reads the same list through
|
||||
// __sanderlingEnumerateBuiltin__. Neither policy can reach an action the other
|
||||
// cannot, because neither owns an enumeration of its own.
|
||||
//
|
||||
// PARITY CONTRACT - draw order.
|
||||
// Every random decision goes through the Pcg in a FIXED, pinned order. Changing
|
||||
// this order shifts the stream for a seed and breaks cross-engine
|
||||
// reproducibility, so treat it as load-bearing:
|
||||
//
|
||||
// pickUniform(list): NO draw for an empty or single-entry list, otherwise ONE
|
||||
// intN(list.length) draw. Both the actions node and the
|
||||
// builtin node select through it.
|
||||
// weighted node: ONE float64() draw, then an ASCENDING cumulative scan over
|
||||
// max(0, weight). (matches worker.go pickWeighted.)
|
||||
// actions node: the generator runs FIRST (any from(...).generate() inside
|
||||
// draws intN(itemCount) for >1 items, nothing otherwise),
|
||||
// THEN if the returned list has >1 entry, ONE intN(len) draw;
|
||||
// a 0- or 1-element list draws nothing. (pickFromResult.)
|
||||
// builtin node, per verb, in this exact sequence:
|
||||
// taps/doubleTaps/longPresses: intN(candidateCount) [1 draw]
|
||||
// typing: intN(candidateCount), intN(corpusLength)
|
||||
// swipes: intN(candidateCount),
|
||||
// 200 + intN(401) magnitude, intN(4) direction
|
||||
// scrolls: intN(candidateCount), intN(4) direction
|
||||
// pressKeys: intN(keyCount)
|
||||
// waitOnce: no draw
|
||||
// THEN pickUniform over the returned list.
|
||||
// builtin node: pickUniform over builtinCandidates(verb), THEN the one
|
||||
// value that verb's enumeration leaves to the policy:
|
||||
// `typing` ONE intN(corpusLength) draw for the text,
|
||||
// `swipes` ONE 200 + intN(401) draw for the drag distance.
|
||||
// Every other verb draws nothing further.
|
||||
//
|
||||
// The enumerated candidate count per verb, over the host targets the verb
|
||||
// accepts (targets.ts acceptsTarget), which is what pickUniform draws over:
|
||||
//
|
||||
// taps/doubleTaps/longPresses: one per accepted target
|
||||
// typing: one per accepted target
|
||||
// scrolls: two per scrollable container (down, up)
|
||||
// swipes: four per accepted target (down, up, left, right)
|
||||
// pressKeys: one per key in the platform's pool
|
||||
// waitOnce: exactly one, so it never draws
|
||||
//
|
||||
// Builtin targets are resolved to a {x, y} Point by the host BEFORE the picker
|
||||
// sees them, so no element handle crosses into this module.
|
||||
@@ -32,24 +47,114 @@ import type { Pcg } from "./pcg.ts";
|
||||
import type {
|
||||
ActionDescriptor,
|
||||
BuiltinVerb,
|
||||
Candidate,
|
||||
GeneratorNode,
|
||||
Host,
|
||||
} from "./action-tree.ts";
|
||||
import type { Direction, Point } from "./types.ts";
|
||||
import { INPUT_CORPUS, NATIVE_PRESS_KEYS, WEB_PRESS_KEYS } from "./corpus.ts";
|
||||
import { setSamplerRng } from "./sampler-rng.ts";
|
||||
import { acceptsTarget } from "./targets.ts";
|
||||
import { supports, warnUnsupportedOnce } from "./verbs.ts";
|
||||
|
||||
// SWIPE_MIN_MAGNITUDE / SWIPE_MAGNITUDE_SPAN reproduce worker.go's
|
||||
// `200 + rng.IntN(401)` swipe distance in pixels (200..600 inclusive).
|
||||
const WAIT_MILLIS = 500;
|
||||
|
||||
// SCROLL_DIRECTIONS and SWIPE_DIRECTIONS are the directions each gesture verb
|
||||
// enumerates, one candidate per (target, direction). Scrolls stay vertical:
|
||||
// they target every scrollable container, so they are what makes the enumerated
|
||||
// list long, and scrolling a mobile list means up and down. Swipes take all
|
||||
// four, because swipe-to-dismiss and swipe-to-delete are horizontal gestures on
|
||||
// list rows, and folding them away puts that defect class out of reach.
|
||||
const SCROLL_DIRECTIONS: readonly Direction[] = ["down", "up"];
|
||||
const SWIPE_DIRECTIONS: readonly Direction[] = ["down", "up", "left", "right"];
|
||||
|
||||
// SWIPE_MIN_MAGNITUDE / SWIPE_MAGNITUDE_SPAN are the free-form swipe distance in
|
||||
// pixels the seeded policy draws, 200..600 inclusive. SWIPE_NOMINAL_MAGNITUDE is
|
||||
// the distance the enumeration lists, so every enumerated candidate is already a
|
||||
// runnable gesture before any policy has drawn anything.
|
||||
const SWIPE_MIN_MAGNITUDE = 200;
|
||||
const SWIPE_MAGNITUDE_SPAN = 401;
|
||||
const SWIPE_NOMINAL_MAGNITUDE = 400;
|
||||
const SWIPE_DURATION_MILLIS = 250;
|
||||
|
||||
const DIRECTIONS: readonly Direction[] = ["up", "down", "left", "right"];
|
||||
|
||||
const MAX_RETRIES = 16;
|
||||
|
||||
// BuiltinCandidate is one enumerated action for a builtin verb, paired with the
|
||||
// index of the host target it acts on, in host.queryTargets() order (NOT the
|
||||
// verb-filtered order, so a host can resolve it without repeating the filter).
|
||||
// targetIndex is -1 for the untargeted verbs (a key press, a wait); the model
|
||||
// policy uses it to name the control the action lands on.
|
||||
export interface BuiltinCandidate {
|
||||
action: ActionDescriptor;
|
||||
targetIndex: number;
|
||||
// swipe is set on a `swipes` candidate only. The enumeration fixes where the
|
||||
// drag starts and which way it travels and lists a nominal distance; the
|
||||
// seeded policy rebuilds the gesture from these at a drawn distance, the way a
|
||||
// typing candidate names the field and leaves the text to the policy.
|
||||
swipe?: { origin: Point; direction: Direction };
|
||||
}
|
||||
|
||||
// builtinCandidates enumerates EVERY action a builtin verb can yield against the
|
||||
// host's current state. It is the single candidate producer both policies read,
|
||||
// over the single eligibility rule both hosts consume. An unsupported verb
|
||||
// reports once and enumerates nothing, so a platform that cannot dispatch a verb
|
||||
// never offers it to either policy.
|
||||
export function builtinCandidates(verb: BuiltinVerb, host: Host): BuiltinCandidate[] {
|
||||
if (!supports(verb, host.platform())) {
|
||||
warnUnsupportedOnce(host, verb);
|
||||
return [];
|
||||
}
|
||||
if (verb === "waitOnce") {
|
||||
return [{ action: { kind: "Wait", durationMillis: WAIT_MILLIS }, targetIndex: -1 }];
|
||||
}
|
||||
if (verb === "pressKeys") {
|
||||
const keys = host.platform() === "web" ? WEB_PRESS_KEYS : NATIVE_PRESS_KEYS;
|
||||
return keys.map((key) => ({
|
||||
action: { kind: "PressKey", key } as ActionDescriptor,
|
||||
targetIndex: -1,
|
||||
}));
|
||||
}
|
||||
|
||||
const candidates: BuiltinCandidate[] = [];
|
||||
host.queryTargets().forEach((target, targetIndex) => {
|
||||
if (!acceptsTarget(verb, target)) return;
|
||||
const point = withSelector({ x: target.x, y: target.y }, target.selector);
|
||||
const add = (action: ActionDescriptor) => candidates.push({ action, targetIndex });
|
||||
switch (verb) {
|
||||
case "taps":
|
||||
add({ kind: "Tap", on: point });
|
||||
break;
|
||||
case "doubleTaps":
|
||||
add({ kind: "DoubleTap", on: point });
|
||||
break;
|
||||
case "longPresses":
|
||||
add({ kind: "LongPress", on: point });
|
||||
break;
|
||||
case "typing":
|
||||
// text is left empty: it is the one value the policy supplies, drawn
|
||||
// from the corpus by the seeded arm and written by the model.
|
||||
add({ kind: "InputText", into: point, text: "" });
|
||||
break;
|
||||
case "scrolls":
|
||||
for (const direction of SCROLL_DIRECTIONS) {
|
||||
add(scrollDescriptor({ x: target.x, y: target.y }, direction, target));
|
||||
}
|
||||
break;
|
||||
case "swipes":
|
||||
for (const direction of SWIPE_DIRECTIONS) {
|
||||
const origin = { x: target.x, y: target.y };
|
||||
candidates.push({
|
||||
action: swipeDescriptor(origin, direction, SWIPE_NOMINAL_MAGNITUDE),
|
||||
targetIndex,
|
||||
swipe: { origin, direction },
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
return candidates;
|
||||
}
|
||||
|
||||
// nextAction resolves an action for the current step, retrying walk() up to 16
|
||||
// times when it yields null (matches worker.go NextAction). Returns null when
|
||||
// every attempt comes up empty.
|
||||
@@ -79,7 +184,7 @@ export function walk(
|
||||
// generator so author sampling shares this single deterministic stream.
|
||||
setSamplerRng(rng);
|
||||
try {
|
||||
return walkActions(node.generate(), rng);
|
||||
return pickUniform(node.generate(), rng);
|
||||
} finally {
|
||||
setSamplerRng(null);
|
||||
}
|
||||
@@ -115,13 +220,13 @@ function walkWeighted(
|
||||
return last ? walk(last[1], rng, host) : null;
|
||||
}
|
||||
|
||||
function walkActions(
|
||||
generated: ActionDescriptor[],
|
||||
rng: Pcg,
|
||||
): ActionDescriptor | null {
|
||||
if (generated.length === 0) return null;
|
||||
if (generated.length === 1) return generated[0] ?? null;
|
||||
return generated[rng.intN(generated.length)] ?? null;
|
||||
// pickUniform selects one entry from a list, drawing nothing when there is no
|
||||
// choice to make. It is the only selection rule in this module: the seeded
|
||||
// policy is exactly "enumerate, then pickUniform".
|
||||
function pickUniform<T>(list: readonly T[], rng: Pcg): T | null {
|
||||
if (list.length === 0) return null;
|
||||
if (list.length === 1) return list[0] ?? null;
|
||||
return list[rng.intN(list.length)] ?? null;
|
||||
}
|
||||
|
||||
function walkBuiltin(
|
||||
@@ -129,41 +234,17 @@ function walkBuiltin(
|
||||
rng: Pcg,
|
||||
host: Host,
|
||||
): ActionDescriptor | null {
|
||||
if (!supports(verb, host.platform())) {
|
||||
warnUnsupportedOnce(host, verb);
|
||||
return null;
|
||||
}
|
||||
if (verb === "waitOnce") {
|
||||
return { kind: "Wait", durationMillis: 500 };
|
||||
}
|
||||
if (verb === "pressKeys") {
|
||||
return walkPressKey(rng, host);
|
||||
}
|
||||
|
||||
const candidates = host.queryCandidates(verb);
|
||||
if (candidates.length === 0) return null;
|
||||
const picked = candidates[rng.intN(candidates.length)];
|
||||
const picked = pickUniform(builtinCandidates(verb, host), rng);
|
||||
if (!picked) return null;
|
||||
const point: Point = { x: picked.x, y: picked.y };
|
||||
|
||||
switch (verb) {
|
||||
case "taps":
|
||||
return tapDescriptor("Tap", point, picked.selector);
|
||||
case "doubleTaps":
|
||||
return tapDescriptor("DoubleTap", point, picked.selector);
|
||||
case "longPresses":
|
||||
return tapDescriptor("LongPress", point, picked.selector);
|
||||
case "typing": {
|
||||
const text = INPUT_CORPUS[rng.intN(INPUT_CORPUS.length)] ?? "";
|
||||
return { kind: "InputText", into: withSelector(point, picked.selector), text };
|
||||
}
|
||||
case "swipes":
|
||||
return buildSwipe(point, rng);
|
||||
case "scrolls": {
|
||||
const direction = DIRECTIONS[rng.intN(DIRECTIONS.length)] ?? "down";
|
||||
return buildScroll(point, direction, picked, rng);
|
||||
}
|
||||
if (picked.action.kind === "InputText") {
|
||||
return { ...picked.action, text: INPUT_CORPUS[rng.intN(INPUT_CORPUS.length)] ?? "" };
|
||||
}
|
||||
if (picked.swipe) {
|
||||
const magnitude = SWIPE_MIN_MAGNITUDE + rng.intN(SWIPE_MAGNITUDE_SPAN);
|
||||
const { origin, direction } = picked.swipe;
|
||||
return swipeDescriptor(origin, direction, magnitude);
|
||||
}
|
||||
return picked.action;
|
||||
}
|
||||
|
||||
// withSelector attaches a native selector to a resolved Point so the runner can
|
||||
@@ -173,82 +254,43 @@ function withSelector(point: Point, selector?: string): Point {
|
||||
return { ...point, selector } as Point;
|
||||
}
|
||||
|
||||
function tapDescriptor(
|
||||
kind: "Tap" | "DoubleTap" | "LongPress",
|
||||
point: Point,
|
||||
selector?: string,
|
||||
): ActionDescriptor {
|
||||
return { kind, on: withSelector(point, selector) } as ActionDescriptor;
|
||||
}
|
||||
|
||||
// buildScroll lowers a scroll to a swipe over the container, matching
|
||||
// worker.go's geometry: the gesture drags opposite the named content motion,
|
||||
// scrollDescriptor lowers a scroll to a drag over the container, matching
|
||||
// runner.go's geometry: the gesture drags opposite the named content motion,
|
||||
// magnitude 40% of the container extent. Missing width/height (web root) yields
|
||||
// a zero-length endpoint, which the runner re-derives from container bounds.
|
||||
function buildScroll(
|
||||
function scrollDescriptor(
|
||||
from: Point,
|
||||
direction: Direction,
|
||||
candidate: { width?: number; height?: number },
|
||||
_rng: Pcg,
|
||||
candidate: Candidate,
|
||||
): ActionDescriptor {
|
||||
const width = candidate.width ?? 0;
|
||||
const height = candidate.height ?? 0;
|
||||
let toX = from.x;
|
||||
let toY = from.y;
|
||||
switch (direction) {
|
||||
case "down":
|
||||
toY = from.y - Math.trunc((4 * height) / 10);
|
||||
break;
|
||||
case "up":
|
||||
toY = from.y + Math.trunc((4 * height) / 10);
|
||||
break;
|
||||
case "left":
|
||||
toX = from.x + Math.trunc((4 * width) / 10);
|
||||
break;
|
||||
case "right":
|
||||
toX = from.x - Math.trunc((4 * width) / 10);
|
||||
break;
|
||||
}
|
||||
if (direction === "down") toY = from.y - Math.trunc((4 * height) / 10);
|
||||
if (direction === "up") toY = from.y + Math.trunc((4 * height) / 10);
|
||||
return {
|
||||
kind: "Scroll",
|
||||
direction,
|
||||
in: from,
|
||||
from,
|
||||
to: { x: Math.max(0, toX), y: Math.max(0, toY) },
|
||||
to: { x: from.x, y: Math.max(0, toY) },
|
||||
} as ActionDescriptor;
|
||||
}
|
||||
|
||||
function walkPressKey(rng: Pcg, host: Host): ActionDescriptor | null {
|
||||
const keys = host.platform() === "web" ? WEB_PRESS_KEYS : NATIVE_PRESS_KEYS;
|
||||
if (keys.length === 0) return null;
|
||||
const key = keys[rng.intN(keys.length)] ?? keys[0];
|
||||
if (key === undefined) return null;
|
||||
return { kind: "PressKey", key };
|
||||
// swipeDescriptor builds a free-form drag from a point over a raw pixel
|
||||
// distance, rather than a fraction of a container extent: `swipes` targets any
|
||||
// element with real bounds, and most of those have no scroll extent to size a
|
||||
// gesture against. `direction` names where the finger travels, because a swipe
|
||||
// IS the gesture; a scroll names content motion and drags the other way.
|
||||
function swipeDescriptor(
|
||||
from: Point,
|
||||
direction: Direction,
|
||||
magnitude: number,
|
||||
): ActionDescriptor {
|
||||
const horizontal = direction === "left" || direction === "right";
|
||||
const forward = direction === "down" || direction === "right";
|
||||
const travel = forward ? magnitude : -magnitude;
|
||||
const to = horizontal
|
||||
? { x: Math.max(0, from.x + travel), y: from.y }
|
||||
: { x: from.x, y: Math.max(0, from.y + travel) };
|
||||
return { kind: "Swipe", from, to, durationMillis: SWIPE_DURATION_MILLIS };
|
||||
}
|
||||
|
||||
function buildSwipe(from: Point, rng: Pcg): ActionDescriptor {
|
||||
const magnitude = SWIPE_MIN_MAGNITUDE + rng.intN(SWIPE_MAGNITUDE_SPAN);
|
||||
let toX = from.x;
|
||||
let toY = from.y;
|
||||
switch (rng.intN(4)) {
|
||||
case 0:
|
||||
toY = from.y - magnitude;
|
||||
break;
|
||||
case 1:
|
||||
toY = from.y + magnitude;
|
||||
break;
|
||||
case 2:
|
||||
toX = from.x - magnitude;
|
||||
break;
|
||||
case 3:
|
||||
toX = from.x + magnitude;
|
||||
break;
|
||||
}
|
||||
return {
|
||||
kind: "Swipe",
|
||||
from,
|
||||
to: { x: Math.max(0, toX), y: Math.max(0, toY) },
|
||||
durationMillis: SWIPE_DURATION_MILLIS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
// identical action stream by construction.
|
||||
|
||||
import { Pcg } from "./pcg.ts";
|
||||
import { nextAction, walk } from "./pick.ts";
|
||||
import { builtinCandidates, nextAction, walk } from "./pick.ts";
|
||||
import { INPUT_CORPUS } from "./corpus.ts";
|
||||
import type { ActionDescriptor, GeneratorNode, Host } from "./action-tree.ts";
|
||||
import type { ActionDescriptor, BuiltinVerb, GeneratorNode, Host } from "./action-tree.ts";
|
||||
import type { Point } from "./types.ts";
|
||||
|
||||
// SerializedAction is the flat, camelCase wire shape JS emits and Go decodes
|
||||
@@ -138,6 +138,16 @@ export function installRuntime(
|
||||
"__sanderlingSampleInput__",
|
||||
() => INPUT_CORPUS[rng.intN(INPUT_CORPUS.length)] ?? "",
|
||||
);
|
||||
// The model policy (Go) selects from the SAME enumeration the seeded picker
|
||||
// draws from, reached through here rather than reimplemented on the Go side.
|
||||
// Each entry is serialized with the wire contract Go already decodes, so the
|
||||
// two policies also agree on the action a chosen candidate executes.
|
||||
defineLockedGlobal("__sanderlingEnumerateBuiltin__", (verb: BuiltinVerb) =>
|
||||
builtinCandidates(verb, host).map((candidate) => ({
|
||||
action: serializeAction(candidate.action),
|
||||
targetIndex: candidate.targetIndex,
|
||||
})),
|
||||
);
|
||||
defineLockedGlobal("__sanderlingExtractors__", () => evaluateExtractors());
|
||||
// __sanderlingSetupAction__ walks ONLY the setup generator once, for the LLM
|
||||
// action generator (Go), which drives selection itself and must not run the
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Per-verb target eligibility: the ONE definition both hosts consume.
|
||||
//
|
||||
// A host enumerates every element it can offer (the goja verifier over the
|
||||
// hierarchy tree, the V8 web runtime over the DOM) and reports a fixed set of
|
||||
// facts about each. It does NOT decide which verb may act on which element:
|
||||
// acceptsTarget does, here, once. When each host routed verbs itself the two
|
||||
// drifted, and the same spec induced a different action space per platform --
|
||||
// web sent `swipes` to scrollable containers only, so swipe-to-dismiss on a
|
||||
// list row was reachable on native and unreachable on web.
|
||||
//
|
||||
// What stays platform-specific is how a fact is COMPUTED, because the source
|
||||
// models genuinely differ: `clickable` is an accessibility attribute on
|
||||
// Android/iOS and a CSS selector match on web. The fact vocabulary below is the
|
||||
// contract; the mapping onto it is each host's business, and it is the only
|
||||
// place the platforms are allowed to disagree.
|
||||
|
||||
import type { BuiltinVerb, TargetElement } from "./action-tree.ts";
|
||||
|
||||
// TargetFact names one property of a target a verb can require.
|
||||
export type TargetFact =
|
||||
| "clickable"
|
||||
| "enabled"
|
||||
| "editable"
|
||||
| "scrollable"
|
||||
| "positiveBounds";
|
||||
|
||||
// VERB_REQUIRED_FACTS lists the facts a target must have for a verb to act on
|
||||
// it. `null` marks a verb with no target at all: a key press and a wait are
|
||||
// enumerated by the picker without consulting the host.
|
||||
const VERB_REQUIRED_FACTS: Record<BuiltinVerb, readonly TargetFact[] | null> = {
|
||||
taps: ["clickable", "enabled", "positiveBounds"],
|
||||
doubleTaps: ["clickable", "enabled", "positiveBounds"],
|
||||
longPresses: ["clickable", "enabled", "positiveBounds"],
|
||||
typing: ["editable", "enabled", "positiveBounds"],
|
||||
// Scrolls target containers that can actually scroll, and enumerate down/up.
|
||||
scrolls: ["scrollable", "positiveBounds"],
|
||||
// Any visible element is a valid swipe origin. Swipe-to-dismiss and
|
||||
// swipe-to-delete live on list rows and cards, which are not scrollable
|
||||
// containers, so scoping swipes to containers would put that whole class of
|
||||
// interaction out of reach.
|
||||
swipes: ["positiveBounds"],
|
||||
pressKeys: null,
|
||||
waitOnce: null,
|
||||
};
|
||||
|
||||
// acceptsTarget is the per-verb element filter. Every targeted verb demands
|
||||
// real bounds: a zero-bounds node centers at (0,0), and a downward gesture from
|
||||
// the top-left corner is the system gesture that pulls down the notification
|
||||
// shade, dragging the fuzzer out of the app.
|
||||
export function acceptsTarget(verb: BuiltinVerb, target: TargetElement): boolean {
|
||||
const required = VERB_REQUIRED_FACTS[verb];
|
||||
if (required === null) return false;
|
||||
return required.every((fact) => hasFact(target, fact));
|
||||
}
|
||||
|
||||
function hasFact(target: TargetElement, fact: TargetFact): boolean {
|
||||
switch (fact) {
|
||||
case "clickable":
|
||||
return target.clickable;
|
||||
case "enabled":
|
||||
return target.enabled;
|
||||
case "editable":
|
||||
return target.editable;
|
||||
case "scrollable":
|
||||
return target.scrollable;
|
||||
case "positiveBounds":
|
||||
return (target.width ?? 0) > 0 && (target.height ?? 0) > 0;
|
||||
}
|
||||
}
|
||||
+50
-72
@@ -4,7 +4,7 @@
|
||||
//
|
||||
// This file is the WEB Host. It installs globalThis.__sanderling__ (extract +
|
||||
// LTL formula binds) before the spec evaluates, implements the Host interface
|
||||
// (platform/seed/queryCandidates/reportUnsupported) over the live DOM, and then
|
||||
// (platform/seed/queryTargets/reportUnsupported) over the live DOM, and then
|
||||
// delegates ALL action generation to the shared picker via installRuntime
|
||||
// (runtime-entry.ts -> pick.ts). The goja verifier runs the SAME picker over the
|
||||
// SAME Pcg, so a given seed yields an identical action stream by construction.
|
||||
@@ -13,11 +13,11 @@
|
||||
// window.__sanderlingNextAction__() over CDP each tick. LTL predicates are
|
||||
// stubbed: properties run host-side in goja, which loads its own bundle.
|
||||
//
|
||||
// Element references never cross V8/host. queryCandidates resolves each element
|
||||
// to a {x, y} Point via getBoundingClientRect before the picker sees it.
|
||||
// Element references never cross V8/host. queryTargets resolves each element to
|
||||
// a {x, y} Point via getBoundingClientRect before the picker sees it.
|
||||
|
||||
import { installRuntime } from "./runtime-entry.ts";
|
||||
import type { BuiltinVerb, Candidate, Host } from "./action-tree.ts";
|
||||
import type { BuiltinVerb, Candidate, Host, TargetElement } from "./action-tree.ts";
|
||||
|
||||
interface Handle {
|
||||
readonly current: unknown;
|
||||
@@ -467,9 +467,12 @@ function sanitizeAt(value: unknown, depth: number, seen: WeakSet<object>): unkno
|
||||
return out;
|
||||
}
|
||||
|
||||
// Per-verb DOM selector sets. The tappable set backs taps/doubleTaps/longPresses
|
||||
// (every tappable element is also a valid long-press target); the editable set
|
||||
// backs typing; swipes/scrolls target the scrollable element.
|
||||
// The DOM half of the fact mapping. Which verb may act on which target is NOT
|
||||
// decided here: queryTargets reports facts and targets.ts acceptsTarget applies
|
||||
// them, the same rule the native host's targets run through. These selectors are
|
||||
// only how the DOM answers "is this clickable" / "is this editable", the two
|
||||
// facts with no direct DOM equivalent of the accessibility attributes native
|
||||
// platforms expose.
|
||||
const TAPPABLE_SELECTOR = 'a, button, input, select, textarea, [role="button"], [onclick]';
|
||||
const EDITABLE_SELECTOR = "input, textarea, [contenteditable]";
|
||||
|
||||
@@ -477,12 +480,6 @@ const NON_TEXT_INPUT_TYPES = [
|
||||
"button", "submit", "checkbox", "radio", "range", "color", "file", "image", "reset",
|
||||
];
|
||||
|
||||
function isVisible(element: HTMLElement): boolean {
|
||||
if ((element as HTMLButtonElement).disabled) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
|
||||
function isEditableElement(element: HTMLElement): boolean {
|
||||
if (element.isContentEditable) return true;
|
||||
const tag = element.tagName.toLowerCase();
|
||||
@@ -494,6 +491,14 @@ function isEditableElement(element: HTMLElement): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// isScrollable mirrors the native `scrollable` accessibility attribute: the
|
||||
// container can actually scroll, i.e. its content overflows its box. The
|
||||
// document scrolling root is not special-cased in: when the page does not
|
||||
// overflow there is no scroll to perform, and native would offer none either.
|
||||
function isScrollable(element: HTMLElement): boolean {
|
||||
return element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth;
|
||||
}
|
||||
|
||||
function pointOf(element: Element): Candidate {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
@@ -504,41 +509,33 @@ function pointOf(element: Element): Candidate {
|
||||
};
|
||||
}
|
||||
|
||||
function tappableCandidates(): Candidate[] {
|
||||
return Array.from(document.querySelectorAll<HTMLElement>(TAPPABLE_SELECTOR))
|
||||
.filter(isVisible)
|
||||
.map(pointOf);
|
||||
// collectTargets walks the document ONCE and reports every element with the facts
|
||||
// the shared eligibility rule reads. The tappable/editable membership sets are
|
||||
// resolved by selector first so the DOM's answer to "clickable" and "editable"
|
||||
// stays expressed in CSS, as it always was.
|
||||
function collectTargets(): TargetElement[] {
|
||||
const clickable = new Set<Element>(Array.from(document.querySelectorAll(TAPPABLE_SELECTOR)));
|
||||
const editable = new Set<Element>(
|
||||
Array.from(document.querySelectorAll<HTMLElement>(EDITABLE_SELECTOR)).filter(
|
||||
isEditableElement,
|
||||
),
|
||||
);
|
||||
return Array.from(document.querySelectorAll<HTMLElement>("*")).map((element) => ({
|
||||
...pointOf(element),
|
||||
clickable: clickable.has(element),
|
||||
enabled: !(element as HTMLButtonElement).disabled,
|
||||
editable: editable.has(element),
|
||||
scrollable: isScrollable(element),
|
||||
}));
|
||||
}
|
||||
|
||||
function editableCandidates(): Candidate[] {
|
||||
return Array.from(document.querySelectorAll<HTMLElement>(EDITABLE_SELECTOR))
|
||||
.filter((element) => isEditableElement(element) && isVisible(element))
|
||||
.map(pointOf);
|
||||
}
|
||||
|
||||
// Scrollable candidates back swipe/scroll: elements that overflow their box,
|
||||
// plus the scrolling root so a page-level scroll always has a target.
|
||||
function scrollableCandidates(): Candidate[] {
|
||||
const root = document.scrollingElement ?? document.documentElement;
|
||||
const candidates: Candidate[] = root ? [pointOf(root)] : [];
|
||||
for (const element of Array.from(document.querySelectorAll<HTMLElement>("*"))) {
|
||||
if (element === root) continue;
|
||||
if (element.scrollHeight <= element.clientHeight && element.scrollWidth <= element.clientWidth) {
|
||||
continue;
|
||||
}
|
||||
if (!isVisible(element)) continue;
|
||||
candidates.push(pointOf(element));
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
// Per-tick candidate cache: the picker's 16-attempt retry re-queries the same
|
||||
// verb, so we avoid re-walking the DOM and re-flushing layout within one tick.
|
||||
// Per-tick target cache: the picker's 16-attempt retry re-queries every tick, so
|
||||
// we avoid re-walking the DOM and re-flushing layout within one tick.
|
||||
// installRuntime resets it before each __sanderlingNextAction__ invocation.
|
||||
const candidateCache = new Map<BuiltinVerb, Candidate[]>();
|
||||
let cachedTargets: TargetElement[] | null = null;
|
||||
|
||||
function resetCandidateCache(): void {
|
||||
candidateCache.clear();
|
||||
function resetTargetCache(): void {
|
||||
cachedTargets = null;
|
||||
}
|
||||
|
||||
const host: Host = {
|
||||
@@ -546,28 +543,9 @@ const host: Host = {
|
||||
seedHi: () => SEED_HI,
|
||||
// lo = 0 matches the goja side's rand.NewPCG(seed, 0).
|
||||
seedLo: () => 0n,
|
||||
queryCandidates(verb: BuiltinVerb): Candidate[] {
|
||||
const cached = candidateCache.get(verb);
|
||||
if (cached) return cached;
|
||||
let candidates: Candidate[];
|
||||
switch (verb) {
|
||||
case "taps":
|
||||
case "doubleTaps":
|
||||
case "longPresses":
|
||||
candidates = tappableCandidates();
|
||||
break;
|
||||
case "typing":
|
||||
candidates = editableCandidates();
|
||||
break;
|
||||
case "swipes":
|
||||
case "scrolls":
|
||||
candidates = scrollableCandidates();
|
||||
break;
|
||||
default:
|
||||
candidates = [];
|
||||
}
|
||||
candidateCache.set(verb, candidates);
|
||||
return candidates;
|
||||
queryTargets(): TargetElement[] {
|
||||
if (!cachedTargets) cachedTargets = collectTargets();
|
||||
return cachedTargets;
|
||||
},
|
||||
reportUnsupported(verb: BuiltinVerb): void {
|
||||
console.warn(`[sanderling] verb ${verb} is unsupported on web`);
|
||||
@@ -578,11 +556,11 @@ const host: Host = {
|
||||
// this module (the web bundle imports the runtime first). Resolve the root
|
||||
// lazily so installRuntime captures it once the spec has evaluated. The root
|
||||
// resolver runs once per __sanderlingNextAction__ tick (before the retry loop),
|
||||
// so it is also where we reset the per-tick candidate cache.
|
||||
// so it is also where we reset the per-tick target cache.
|
||||
installRuntime(
|
||||
host,
|
||||
() => {
|
||||
resetCandidateCache();
|
||||
resetTargetCache();
|
||||
return (globalThis as { actions?: import("./action-tree.ts").GeneratorNode }).actions ?? null;
|
||||
},
|
||||
evaluateExtractors,
|
||||
@@ -593,10 +571,10 @@ installRuntime(
|
||||
export const __testing__ = {
|
||||
host,
|
||||
seedBigInt,
|
||||
tappableCandidates,
|
||||
editableCandidates,
|
||||
scrollableCandidates,
|
||||
resetCandidateCache,
|
||||
collectTargets,
|
||||
TAPPABLE_SELECTOR,
|
||||
EDITABLE_SELECTOR,
|
||||
resetTargetCache,
|
||||
runtime,
|
||||
extractors,
|
||||
evaluateExtractors,
|
||||
|
||||
@@ -15,10 +15,14 @@ import type {
|
||||
function installRuntime(initialState: State): void {
|
||||
const state = { current: initialState };
|
||||
const runtime: SanderlingRuntime = {
|
||||
extract: <T>(getter: (s: State) => T): Extracted<T> => ({
|
||||
current: getter(state.current),
|
||||
previous: undefined,
|
||||
}),
|
||||
extract: <T>(getter: (s: State) => T): Extracted<T> => {
|
||||
const handle: Extracted<T> = {
|
||||
current: getter(state.current),
|
||||
previous: undefined,
|
||||
named: () => handle,
|
||||
};
|
||||
return handle;
|
||||
},
|
||||
always: () => ({ __sanderlingFormula: true } as Formula),
|
||||
now: () => ({ __sanderlingFormula: true } as Formula),
|
||||
next: () => ({ __sanderlingFormula: true } as Formula),
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"taps": [
|
||||
"save"
|
||||
],
|
||||
"doubleTaps": [
|
||||
"save"
|
||||
],
|
||||
"longPresses": [
|
||||
"save"
|
||||
],
|
||||
"typing": [
|
||||
"amount"
|
||||
],
|
||||
"scrolls": [
|
||||
"root",
|
||||
"root",
|
||||
"list",
|
||||
"list"
|
||||
],
|
||||
"swipes": [
|
||||
"root",
|
||||
"root",
|
||||
"root",
|
||||
"root",
|
||||
"save",
|
||||
"save",
|
||||
"save",
|
||||
"save",
|
||||
"cancel",
|
||||
"cancel",
|
||||
"cancel",
|
||||
"cancel",
|
||||
"amount",
|
||||
"amount",
|
||||
"amount",
|
||||
"amount",
|
||||
"list",
|
||||
"list",
|
||||
"list",
|
||||
"list",
|
||||
"row",
|
||||
"row",
|
||||
"row",
|
||||
"row"
|
||||
],
|
||||
"pressKeys": [],
|
||||
"waitOnce": []
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { builtinCandidates } from "../src/pick.ts";
|
||||
import { resetWarnings } from "../src/verbs.ts";
|
||||
import type { BuiltinVerb } from "../src/action-tree.ts";
|
||||
import { fakeElement, withFakeDocument, type FakeElementSpec } from "./web-dom-harness.ts";
|
||||
import { __testing__ } from "../src/web-runtime.ts";
|
||||
|
||||
const { host } = __testing__;
|
||||
|
||||
const goldenPath = fileURLToPath(new URL("./fixtures/host-parity-golden.json", import.meta.url));
|
||||
const golden: Record<string, string[]> = JSON.parse(readFileSync(goldenPath, "utf8"));
|
||||
|
||||
const VERBS: BuiltinVerb[] = [
|
||||
"taps",
|
||||
"doubleTaps",
|
||||
"longPresses",
|
||||
"typing",
|
||||
"scrolls",
|
||||
"swipes",
|
||||
"pressKeys",
|
||||
"waitOnce",
|
||||
];
|
||||
|
||||
// SCREEN is the canonical screen both hosts are driven over: one row per fact
|
||||
// combination that any verb distinguishes. The native host builds the same rows,
|
||||
// in the same order, as a hierarchy tree in
|
||||
// internal/verifier/host_parity_test.go.
|
||||
const SCREEN: (FakeElementSpec & { name: string })[] = [
|
||||
{ name: "root", tag: "html", x: 0, y: 0, width: 400, height: 800, overflows: true },
|
||||
{ name: "save", tag: "button", x: 0, y: 0, width: 200, height: 60, clickable: true },
|
||||
{
|
||||
name: "cancel",
|
||||
tag: "button",
|
||||
x: 200,
|
||||
y: 0,
|
||||
width: 200,
|
||||
height: 60,
|
||||
clickable: true,
|
||||
disabled: true,
|
||||
},
|
||||
{ name: "amount", tag: "input", x: 0, y: 100, width: 400, height: 60, editable: true },
|
||||
{ name: "list", tag: "div", x: 0, y: 200, width: 400, height: 400, overflows: true },
|
||||
{ name: "row", tag: "div", x: 0, y: 600, width: 400, height: 80 },
|
||||
{ name: "collapsed", tag: "button", x: 0, y: 0, width: 0, height: 0, clickable: true },
|
||||
];
|
||||
|
||||
// The web host and the native host used to route verbs themselves and had
|
||||
// drifted: web sent `swipes` to scrollable containers only, so a swipe on a list
|
||||
// row was reachable on Android and unreachable on web for the same spec.
|
||||
//
|
||||
// Per-verb eligibility now has ONE definition (src/targets.ts); a host reports
|
||||
// facts and never filters. This test is what notices if a second definition
|
||||
// grows back on either side. internal/verifier/host_parity_test.go asserts the
|
||||
// SAME golden from the native host, so a match on both sides proves the two
|
||||
// hosts agree without either invoking the other.
|
||||
test("web host targets match the cross-host golden, verb for verb", () => {
|
||||
const elements = SCREEN.map(fakeElement);
|
||||
withFakeDocument(elements, () => {
|
||||
for (const verb of VERBS) {
|
||||
resetWarnings();
|
||||
const candidates = builtinCandidates(verb, host);
|
||||
assert.notEqual(candidates.length, 0, `${verb} enumerated nothing at all`);
|
||||
const named = candidates
|
||||
.filter((candidate) => candidate.targetIndex >= 0)
|
||||
.map((candidate) => SCREEN[candidate.targetIndex]!.name);
|
||||
assert.deepEqual(named, golden[verb], `web host targets for ${verb}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
// Shared cross-runtime parity scenario: the FIXED seed, FIXED candidate list,
|
||||
// Shared cross-runtime parity scenario: the FIXED seed, FIXED target list,
|
||||
// and FIXED action root that both the node test (parity.test.ts) and the goja
|
||||
// test (internal/verifier/parity_test.go) drive. Each side runs the SAME
|
||||
// pick.ts over the SAME Pcg and asserts the SAME committed golden
|
||||
// (fixtures/parity-golden.json); matching one golden on both sides proves the
|
||||
// two engines agree without either invoking the other.
|
||||
//
|
||||
// The candidate ORDER and the per-tick PCG draw order are the parity contract.
|
||||
// The target ORDER and the per-tick PCG draw order are the parity contract.
|
||||
// The weighted root mixes a tap branch (1 candidate draw) with a typing branch
|
||||
// (1 candidate draw + 1 corpus draw), so a tick exercises weighted selection, a
|
||||
// builtin, and the input corpus together; reordering candidates or adding or
|
||||
@@ -15,25 +15,29 @@ import { Pcg } from "../src/pcg.ts";
|
||||
import { nextAction } from "../src/pick.ts";
|
||||
import { serializeAction, type SerializedAction } from "../src/runtime-entry.ts";
|
||||
import { taps, typing, weighted } from "../src/actions.ts";
|
||||
import type { BuiltinVerb, Candidate, GeneratorNode, Host } from "../src/action-tree.ts";
|
||||
import type { GeneratorNode, Host, TargetElement } from "../src/action-tree.ts";
|
||||
|
||||
export const PARITY_SEED_HI = 0x9e3779b97f4a7c15n;
|
||||
export const PARITY_STEPS = 20;
|
||||
|
||||
export const PARITY_CANDIDATES: Candidate[] = [
|
||||
{ x: 50, y: 60, selector: "id:alpha", width: 100, height: 40 },
|
||||
{ x: 150, y: 160, selector: "id:beta", width: 120, height: 48 },
|
||||
{ x: 250, y: 260, selector: "id:gamma", width: 80, height: 32 },
|
||||
// Every fact is set so the shared eligibility rule admits all three targets for
|
||||
// every verb, leaving the draw order as the only variable.
|
||||
const EVERY_FACT = { clickable: true, enabled: true, editable: true, scrollable: true };
|
||||
|
||||
export const PARITY_TARGETS: TargetElement[] = [
|
||||
{ x: 50, y: 60, selector: "id:alpha", width: 100, height: 40, ...EVERY_FACT },
|
||||
{ x: 150, y: 160, selector: "id:beta", width: 120, height: 48, ...EVERY_FACT },
|
||||
{ x: 250, y: 260, selector: "id:gamma", width: 80, height: 32, ...EVERY_FACT },
|
||||
];
|
||||
|
||||
// A 3:1 weighted split over taps and typing. The stub host returns the same
|
||||
// candidate list for every verb so the only variables are the draw order and
|
||||
// the JS engine's number/bigint behavior.
|
||||
// A 3:1 weighted split over taps and typing. The stub host offers the same
|
||||
// target list to every verb so the only variables are the draw order and the JS
|
||||
// engine's number/bigint behavior.
|
||||
export const PARITY_ROOT: GeneratorNode = weighted([3, taps], [1, typing]);
|
||||
|
||||
const HOST: Host = {
|
||||
platform: () => "android",
|
||||
queryCandidates: (_verb: BuiltinVerb) => PARITY_CANDIDATES,
|
||||
queryTargets: () => PARITY_TARGETS,
|
||||
reportUnsupported: () => {},
|
||||
seedHi: () => PARITY_SEED_HI,
|
||||
seedLo: () => 0n,
|
||||
|
||||
+241
-60
@@ -1,30 +1,31 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { Pcg } from "../src/pcg.ts";
|
||||
import { nextAction, walk } from "../src/pick.ts";
|
||||
import { builtinCandidates, nextAction, walk } from "../src/pick.ts";
|
||||
import { INPUT_CORPUS, NATIVE_PRESS_KEYS, WEB_PRESS_KEYS } from "../src/corpus.ts";
|
||||
import { resetWarnings } from "../src/verbs.ts";
|
||||
import type {
|
||||
ActionDescriptor,
|
||||
BuiltinVerb,
|
||||
Candidate,
|
||||
GeneratorNode,
|
||||
Host,
|
||||
TargetElement,
|
||||
} from "../src/action-tree.ts";
|
||||
import type { Direction, Point } from "../src/types.ts";
|
||||
|
||||
type Platform = "android" | "ios" | "web";
|
||||
|
||||
// stubHost returns a fixed candidate list for every verb and records each
|
||||
// reportUnsupported call so warn-once semantics are observable.
|
||||
// stubHost returns a fixed target list, eligible for every verb, and records
|
||||
// each reportUnsupported call so warn-once semantics are observable.
|
||||
function stubHost(
|
||||
platform: Platform,
|
||||
candidates: Candidate[],
|
||||
targets: TargetElement[],
|
||||
): Host & { unsupported: BuiltinVerb[] } {
|
||||
const unsupported: BuiltinVerb[] = [];
|
||||
return {
|
||||
unsupported,
|
||||
platform: () => platform,
|
||||
queryCandidates: () => candidates,
|
||||
queryTargets: () => targets,
|
||||
reportUnsupported: (verb) => {
|
||||
unsupported.push(verb);
|
||||
},
|
||||
@@ -33,10 +34,12 @@ function stubHost(
|
||||
};
|
||||
}
|
||||
|
||||
const POINTS: Candidate[] = [
|
||||
{ x: 10, y: 20, selector: "id:a", width: 100, height: 200 },
|
||||
{ x: 30, y: 40, selector: "id:b", width: 100, height: 200 },
|
||||
{ x: 50, y: 60, selector: "id:c", width: 100, height: 200 },
|
||||
const EVERY_FACT = { clickable: true, enabled: true, editable: true, scrollable: true };
|
||||
|
||||
const POINTS: TargetElement[] = [
|
||||
{ x: 10, y: 20, selector: "id:a", width: 100, height: 200, ...EVERY_FACT },
|
||||
{ x: 30, y: 40, selector: "id:b", width: 100, height: 200, ...EVERY_FACT },
|
||||
{ x: 50, y: 60, selector: "id:c", width: 100, height: 200, ...EVERY_FACT },
|
||||
];
|
||||
|
||||
function builtin(verb: BuiltinVerb): GeneratorNode {
|
||||
@@ -81,59 +84,132 @@ test("typing draws candidate index then corpus index, in that order", () => {
|
||||
assert.equal(action.text, INPUT_CORPUS[corpusIndex]);
|
||||
});
|
||||
|
||||
test("swipes draw candidate, magnitude (200+intN(401)), then direction", () => {
|
||||
// scrollCandidate mirrors what the picker builds for one (container, direction)
|
||||
// pair: a drag opposite the named content motion, 40% of the container extent.
|
||||
function scrollCandidate(target: TargetElement, direction: "down" | "up") {
|
||||
const from = { x: target.x, y: target.y };
|
||||
const extent = Math.trunc((4 * (target.height ?? 0)) / 10);
|
||||
const toY = direction === "down" ? from.y - extent : from.y + extent;
|
||||
return {
|
||||
kind: "Scroll",
|
||||
direction,
|
||||
in: from,
|
||||
from,
|
||||
to: { x: from.x, y: Math.max(0, toY) },
|
||||
};
|
||||
}
|
||||
|
||||
// swipeCandidate mirrors the free-form drag: a raw pixel distance, with the
|
||||
// direction naming where the finger travels.
|
||||
function swipeCandidate(
|
||||
target: Point,
|
||||
direction: Direction,
|
||||
magnitude: number,
|
||||
) {
|
||||
const from = { x: target.x, y: target.y };
|
||||
const horizontal = direction === "left" || direction === "right";
|
||||
const forward = direction === "down" || direction === "right";
|
||||
const travel = forward ? magnitude : -magnitude;
|
||||
return {
|
||||
kind: "Swipe",
|
||||
from,
|
||||
to: horizontal
|
||||
? { x: Math.max(0, from.x + travel), y: from.y }
|
||||
: { x: from.x, y: Math.max(0, from.y + travel) },
|
||||
durationMillis: 250,
|
||||
};
|
||||
}
|
||||
|
||||
const NOMINAL_SWIPE_MAGNITUDE = 400;
|
||||
|
||||
const SCROLL_DIRECTIONS = ["down", "up"] as const;
|
||||
const SWIPE_DIRECTIONS = ["down", "up", "left", "right"] as const;
|
||||
|
||||
// swipeDirection recovers which way a drawn swipe travelled from its endpoints.
|
||||
function swipeDirection(from: Point, to: Point): Direction {
|
||||
if (to.x !== from.x) return to.x > from.x ? "right" : "left";
|
||||
return to.y > from.y ? "down" : "up";
|
||||
}
|
||||
|
||||
test("scrolls enumerate every container up and down only", () => {
|
||||
resetWarnings();
|
||||
const host = stubHost("android", POINTS);
|
||||
const rng = new Pcg(7n, 0n);
|
||||
const oracle = new Pcg(7n, 0n);
|
||||
const candidateIndex = oracle.intN(POINTS.length);
|
||||
const magnitude = 200 + oracle.intN(401);
|
||||
const direction = oracle.intN(4);
|
||||
|
||||
const from = { x: POINTS[candidateIndex]!.x, y: POINTS[candidateIndex]!.y };
|
||||
const expectedTo = { x: from.x, y: from.y };
|
||||
switch (direction) {
|
||||
case 0:
|
||||
expectedTo.y = Math.max(0, from.y - magnitude);
|
||||
break;
|
||||
case 1:
|
||||
expectedTo.y = Math.max(0, from.y + magnitude);
|
||||
break;
|
||||
case 2:
|
||||
expectedTo.x = Math.max(0, from.x - magnitude);
|
||||
break;
|
||||
case 3:
|
||||
expectedTo.x = Math.max(0, from.x + magnitude);
|
||||
break;
|
||||
}
|
||||
|
||||
const action = walk(builtin("swipes"), rng, host) as ActionDescriptor & {
|
||||
kind: "Swipe";
|
||||
};
|
||||
assert.equal(action.kind, "Swipe");
|
||||
assert.deepEqual(action.from, from);
|
||||
assert.deepEqual(action.to, expectedTo);
|
||||
assert.equal(action.durationMillis, 250);
|
||||
assert.deepEqual(
|
||||
builtinCandidates("scrolls", host),
|
||||
POINTS.flatMap((target, targetIndex) =>
|
||||
SCROLL_DIRECTIONS.map((direction) => ({
|
||||
action: scrollCandidate(target, direction),
|
||||
targetIndex,
|
||||
})),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("scrolls draw candidate index then direction", () => {
|
||||
test("swipes enumerate a free-form drag per target in all four directions", () => {
|
||||
resetWarnings();
|
||||
const host = stubHost("android", POINTS);
|
||||
const rng = new Pcg(99n, 0n);
|
||||
const oracle = new Pcg(99n, 0n);
|
||||
const candidateIndex = oracle.intN(POINTS.length);
|
||||
const directionIndex = oracle.intN(4);
|
||||
const directions = ["up", "down", "left", "right"] as const;
|
||||
// The swipe candidate is its own action shape, sized in raw pixels rather than
|
||||
// off the target's extent, and it carries what the policy needs to redraw the
|
||||
// distance. It is not the scroll gesture under a second name.
|
||||
assert.deepEqual(
|
||||
builtinCandidates("swipes", host),
|
||||
POINTS.flatMap((target, targetIndex) =>
|
||||
SWIPE_DIRECTIONS.map((direction) => ({
|
||||
action: swipeCandidate(target, direction, NOMINAL_SWIPE_MAGNITUDE),
|
||||
targetIndex,
|
||||
swipe: { origin: { x: target.x, y: target.y }, direction },
|
||||
})),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const action = walk(builtin("scrolls"), rng, host) as ActionDescriptor & {
|
||||
kind: "Scroll";
|
||||
};
|
||||
assert.equal(action.kind, "Scroll");
|
||||
assert.equal(action.direction, directions[directionIndex]);
|
||||
assert.deepEqual(action.in, {
|
||||
x: POINTS[candidateIndex]!.x,
|
||||
y: POINTS[candidateIndex]!.y,
|
||||
});
|
||||
test("the gesture verbs differ in target filter and in direction set", () => {
|
||||
resetWarnings();
|
||||
// Same host, same targets: what separates the two verbs here is the direction
|
||||
// set alone. Scrolls stay vertical because every scrollable container gets a
|
||||
// candidate; swipes reach sideways because swipe-to-dismiss does.
|
||||
const host = stubHost("android", POINTS);
|
||||
const scrolls = builtinCandidates("scrolls", host);
|
||||
const swipes = builtinCandidates("swipes", host);
|
||||
|
||||
const scrollDirections = new Set(
|
||||
scrolls.map(
|
||||
(entry) => (entry.action as ActionDescriptor & { kind: "Scroll" }).direction,
|
||||
),
|
||||
);
|
||||
const swipeDirections = new Set(swipes.map((entry) => entry.swipe!.direction));
|
||||
|
||||
assert.deepEqual([...scrollDirections].sort(), ["down", "up"]);
|
||||
assert.deepEqual([...swipeDirections].sort(), ["down", "left", "right", "up"]);
|
||||
assert.equal(scrolls.length, POINTS.length * 2);
|
||||
assert.equal(swipes.length, POINTS.length * 4);
|
||||
});
|
||||
|
||||
test("scrolls draw one index over the enumerated candidates", () => {
|
||||
resetWarnings();
|
||||
const host = stubHost("android", POINTS);
|
||||
const enumerated = builtinCandidates("scrolls", host);
|
||||
const oracle = new Pcg(99n, 0n);
|
||||
const index = oracle.intN(enumerated.length);
|
||||
|
||||
const action = walk(builtin("scrolls"), new Pcg(99n, 0n), host);
|
||||
assert.deepEqual(action, enumerated[index]!.action);
|
||||
});
|
||||
|
||||
test("swipes draw candidate index then magnitude, in that order", () => {
|
||||
resetWarnings();
|
||||
const host = stubHost("android", POINTS);
|
||||
const enumerated = builtinCandidates("swipes", host);
|
||||
const oracle = new Pcg(7n, 0n);
|
||||
const index = oracle.intN(enumerated.length);
|
||||
const magnitude = 200 + oracle.intN(401);
|
||||
const picked = enumerated[index]!.swipe!;
|
||||
|
||||
const action = walk(builtin("swipes"), new Pcg(7n, 0n), host);
|
||||
assert.deepEqual(
|
||||
action,
|
||||
swipeCandidate(picked.origin, picked.direction, magnitude),
|
||||
);
|
||||
});
|
||||
|
||||
test("doubleTaps and longPresses draw exactly one candidate index", () => {
|
||||
@@ -174,14 +250,28 @@ test("waitOnce emits a 500ms wait and draws nothing", () => {
|
||||
assert.notEqual(before, after);
|
||||
});
|
||||
|
||||
test("pressKeys on native draws from NATIVE_PRESS_KEYS", () => {
|
||||
test("pressKeys enumerates the platform's whole key pool", () => {
|
||||
resetWarnings();
|
||||
for (const [platform, keys] of [
|
||||
["android", NATIVE_PRESS_KEYS],
|
||||
["web", WEB_PRESS_KEYS],
|
||||
] as const) {
|
||||
const enumerated = builtinCandidates("pressKeys", stubHost(platform, POINTS));
|
||||
assert.deepEqual(
|
||||
enumerated,
|
||||
keys.map((key) => ({ action: { kind: "PressKey", key }, targetIndex: -1 })),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("pressKeys on native emits the only key without drawing", () => {
|
||||
resetWarnings();
|
||||
const host = stubHost("android", POINTS);
|
||||
const rng = new Pcg(42n, 0n);
|
||||
const oracle = new Pcg(42n, 0n);
|
||||
const index = oracle.intN(NATIVE_PRESS_KEYS.length);
|
||||
const action = walk(builtin("pressKeys"), rng, host);
|
||||
assert.deepEqual(action, { kind: "PressKey", key: NATIVE_PRESS_KEYS[index] });
|
||||
assert.deepEqual(action, { kind: "PressKey", key: NATIVE_PRESS_KEYS[0] });
|
||||
// One key is no choice, so the pool consumed no draw.
|
||||
assert.equal(rng.float64(), new Pcg(42n, 0n).float64());
|
||||
});
|
||||
|
||||
test("pressKeys on web draws from WEB_PRESS_KEYS", () => {
|
||||
@@ -194,6 +284,97 @@ test("pressKeys on web draws from WEB_PRESS_KEYS", () => {
|
||||
assert.deepEqual(action, { kind: "PressKey", key: WEB_PRESS_KEYS[index] });
|
||||
});
|
||||
|
||||
test("an unsupported verb enumerates nothing and reports once", () => {
|
||||
resetWarnings();
|
||||
// No platform in the matrix declines a verb today, so the branch is reached
|
||||
// through a platform the matrix has never heard of.
|
||||
const host = stubHost("desktop" as Platform, POINTS);
|
||||
assert.deepEqual(builtinCandidates("taps", host), []);
|
||||
assert.deepEqual(builtinCandidates("taps", host), []);
|
||||
assert.deepEqual(host.unsupported, ["taps"]);
|
||||
});
|
||||
|
||||
test("the seeded pick is an index into the shared enumeration", () => {
|
||||
resetWarnings();
|
||||
// Whatever the verb, the drawn action is one of the enumerated entries: the
|
||||
// seeded policy adds a choice, never an action the model policy cannot see.
|
||||
// `typing` and `swipes` are covered separately, being the two verbs whose
|
||||
// enumeration leaves one value for the policy to fill in.
|
||||
const host = stubHost("android", POINTS);
|
||||
for (const verb of [
|
||||
"taps",
|
||||
"doubleTaps",
|
||||
"longPresses",
|
||||
"scrolls",
|
||||
"pressKeys",
|
||||
"waitOnce",
|
||||
] as const) {
|
||||
const enumerated = builtinCandidates(verb, host).map((entry) =>
|
||||
JSON.stringify(entry.action),
|
||||
);
|
||||
for (let seed = 1; seed <= 50; seed++) {
|
||||
const action = walk(builtin(verb), new Pcg(BigInt(seed), 0n), host);
|
||||
assert.ok(
|
||||
enumerated.includes(JSON.stringify(action)),
|
||||
`${verb} drew ${JSON.stringify(action)}, which is not an enumerated candidate`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("typing enumerates the field and the policy supplies the text", () => {
|
||||
resetWarnings();
|
||||
const host = stubHost("android", POINTS);
|
||||
const enumerated = builtinCandidates("typing", host);
|
||||
// The candidate set names fields only: an empty text is the slot the seeded
|
||||
// corpus draw and the model's own value both fill.
|
||||
for (const entry of enumerated) {
|
||||
assert.equal((entry.action as ActionDescriptor & { kind: "InputText" }).text, "");
|
||||
}
|
||||
const oracle = new Pcg(2024n, 0n);
|
||||
const index = oracle.intN(enumerated.length);
|
||||
const text = INPUT_CORPUS[oracle.intN(INPUT_CORPUS.length)];
|
||||
assert.deepEqual(walk(builtin("typing"), new Pcg(2024n, 0n), host), {
|
||||
...enumerated[index]!.action,
|
||||
text,
|
||||
});
|
||||
});
|
||||
|
||||
test("swipes enumerate origin and direction, the policy adds distance", () => {
|
||||
resetWarnings();
|
||||
const host = stubHost("android", POINTS);
|
||||
const origins = new Set(
|
||||
builtinCandidates("swipes", host).map((entry) =>
|
||||
JSON.stringify([entry.swipe!.origin, entry.swipe!.direction]),
|
||||
),
|
||||
);
|
||||
const drawn = new Set<Direction>();
|
||||
for (let seed = 1; seed <= 200; seed++) {
|
||||
const action = walk(builtin("swipes"), new Pcg(BigInt(seed), 0n), host) as
|
||||
ActionDescriptor & { kind: "Swipe"; from: Point; to: Point };
|
||||
assert.equal(action.kind, "Swipe");
|
||||
const direction = swipeDirection(action.from, action.to);
|
||||
drawn.add(direction);
|
||||
assert.ok(
|
||||
origins.has(JSON.stringify([action.from, direction])),
|
||||
`swipe from ${JSON.stringify(action.from)} going ${direction} is not enumerated`,
|
||||
);
|
||||
// The drawn distance stays inside 200..600, clamped at the screen edge.
|
||||
const horizontal = direction === "left" || direction === "right";
|
||||
const distance = horizontal
|
||||
? Math.abs(action.to.x - action.from.x)
|
||||
: Math.abs(action.to.y - action.from.y);
|
||||
const clamped = horizontal ? action.to.x === 0 : action.to.y === 0;
|
||||
assert.ok(distance <= 600, `drag of ${distance}px exceeds the drawn range`);
|
||||
assert.ok(
|
||||
distance >= 200 || clamped,
|
||||
`drag of ${distance}px is under the drawn range and not clamped`,
|
||||
);
|
||||
}
|
||||
// Every enumerated direction is reachable by a draw, sideways included.
|
||||
assert.deepEqual([...drawn].sort(), ["down", "left", "right", "up"]);
|
||||
});
|
||||
|
||||
test("empty candidate list yields null without drawing", () => {
|
||||
resetWarnings();
|
||||
const host = stubHost("android", []);
|
||||
|
||||
@@ -19,7 +19,7 @@ function countingHost(platform: "android" | "ios" | "web"): Host & { calls: Buil
|
||||
return {
|
||||
calls,
|
||||
platform: () => platform,
|
||||
queryCandidates: () => [],
|
||||
queryTargets: () => [],
|
||||
reportUnsupported: (verb) => {
|
||||
calls.push(verb);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// A minimal stand-in for the DOM surface the web host reads, shared by the web
|
||||
// runtime's own tests and the cross-host eligibility test. The host asks the
|
||||
// document for three things -- every element, the tappable set, the editable set
|
||||
// -- and reads geometry, `disabled` and the scroll extents off each element, so
|
||||
// that is all a fake has to answer.
|
||||
|
||||
import { __testing__ } from "../src/web-runtime.ts";
|
||||
|
||||
const { TAPPABLE_SELECTOR, EDITABLE_SELECTOR } = __testing__;
|
||||
|
||||
export interface FakeElementSpec {
|
||||
tag: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
// clickable/editable place the element in the selector sets the host queries;
|
||||
// the fake answers those queries directly rather than matching CSS.
|
||||
clickable?: boolean;
|
||||
editable?: boolean;
|
||||
disabled?: boolean;
|
||||
// overflows makes the element's content taller than its box, which is how the
|
||||
// host decides an element is scrollable.
|
||||
overflows?: boolean;
|
||||
}
|
||||
|
||||
export interface FakeElement extends FakeElementSpec {
|
||||
tagName: string;
|
||||
type: string;
|
||||
isContentEditable: boolean;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
scrollWidth: number;
|
||||
clientWidth: number;
|
||||
getBoundingClientRect(): {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function fakeElement(spec: FakeElementSpec): FakeElement {
|
||||
const editable = spec.editable ?? false;
|
||||
return {
|
||||
...spec,
|
||||
tagName: spec.tag.toUpperCase(),
|
||||
type: spec.tag === "input" ? "text" : "",
|
||||
isContentEditable: editable && spec.tag !== "input" && spec.tag !== "textarea",
|
||||
scrollHeight: spec.overflows ? spec.height * 2 : spec.height,
|
||||
clientHeight: spec.height,
|
||||
scrollWidth: spec.width,
|
||||
clientWidth: spec.width,
|
||||
getBoundingClientRect: () => ({
|
||||
left: spec.x,
|
||||
top: spec.y,
|
||||
width: spec.width,
|
||||
height: spec.height,
|
||||
right: spec.x + spec.width,
|
||||
bottom: spec.y + spec.height,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// withFakeDocument installs a document answering the host's three queries over
|
||||
// `elements`, resets the host's per-tick cache, and restores the real document
|
||||
// afterwards.
|
||||
export function withFakeDocument(elements: FakeElement[], run: () => void): void {
|
||||
const global = globalThis as Record<string, unknown>;
|
||||
const original = global.document;
|
||||
const answers: Record<string, FakeElement[]> = {
|
||||
"*": elements,
|
||||
[TAPPABLE_SELECTOR]: elements.filter((element) => element.clickable),
|
||||
[EDITABLE_SELECTOR]: elements.filter((element) => element.editable),
|
||||
};
|
||||
global.document = {
|
||||
querySelectorAll: (selector: string) => answers[selector] ?? [],
|
||||
};
|
||||
__testing__.resetTargetCache();
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
__testing__.resetTargetCache();
|
||||
global.document = original;
|
||||
}
|
||||
}
|
||||
@@ -83,78 +83,60 @@ test("installRuntime defined the host-invoked globals", () => {
|
||||
assert.equal(typeof g.__sanderling__, "object");
|
||||
});
|
||||
|
||||
// A button and a text input, each with a deterministic bounding box, exercise
|
||||
// the per-verb selector routing without a full DOM.
|
||||
function fakeElement(tag: string, rect: { x: number; y: number; w: number; h: number }) {
|
||||
return {
|
||||
tagName: tag.toUpperCase(),
|
||||
disabled: false,
|
||||
isContentEditable: false,
|
||||
type: tag === "input" ? "text" : "",
|
||||
scrollHeight: 0,
|
||||
clientHeight: 0,
|
||||
scrollWidth: 0,
|
||||
clientWidth: 0,
|
||||
getBoundingClientRect: () => ({
|
||||
left: rect.x,
|
||||
top: rect.y,
|
||||
width: rect.w,
|
||||
height: rect.h,
|
||||
right: rect.x + rect.w,
|
||||
bottom: rect.y + rect.h,
|
||||
}),
|
||||
};
|
||||
}
|
||||
const { fakeElement, withFakeDocument } = await import("./web-dom-harness.ts");
|
||||
|
||||
function withFakeDocument(map: Record<string, unknown[]>, run: () => void) {
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
const original = g.document;
|
||||
g.document = {
|
||||
querySelectorAll: (selector: string) => map[selector] ?? [],
|
||||
scrollingElement: null,
|
||||
documentElement: null,
|
||||
};
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
g.document = original;
|
||||
}
|
||||
}
|
||||
|
||||
test("queryCandidates routes taps to the tappable selector set", () => {
|
||||
const button = fakeElement("button", { x: 10, y: 20, w: 40, h: 8 });
|
||||
withFakeDocument(
|
||||
{ 'a, button, input, select, textarea, [role="button"], [onclick]': [button] },
|
||||
() => {
|
||||
__testing__.resetCandidateCache();
|
||||
const candidates = host.queryCandidates("taps");
|
||||
assert.equal(candidates.length, 1);
|
||||
assert.deepEqual({ x: candidates[0]!.x, y: candidates[0]!.y }, { x: 30, y: 24 });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("queryCandidates routes typing to editable inputs only", () => {
|
||||
const input = fakeElement("input", { x: 0, y: 0, w: 100, h: 20 });
|
||||
withFakeDocument({ "input, textarea, [contenteditable]": [input] }, () => {
|
||||
__testing__.resetCandidateCache();
|
||||
const candidates = host.queryCandidates("typing");
|
||||
assert.equal(candidates.length, 1);
|
||||
assert.deepEqual({ x: candidates[0]!.x, y: candidates[0]!.y }, { x: 50, y: 10 });
|
||||
// The host reports facts and never routes verbs: which of these a verb may act
|
||||
// on is decided by the shared rule in src/targets.ts, exercised across both
|
||||
// engines by host-parity.test.ts.
|
||||
test("queryTargets reports the tappable selector set as clickable", () => {
|
||||
const button = fakeElement({ tag: "button", x: 10, y: 20, width: 40, height: 8, clickable: true });
|
||||
const plain = fakeElement({ tag: "div", x: 0, y: 0, width: 100, height: 100 });
|
||||
withFakeDocument([button, plain], () => {
|
||||
const targets = host.queryTargets();
|
||||
assert.equal(targets.length, 2);
|
||||
assert.equal(targets[0]!.clickable, true);
|
||||
assert.deepEqual({ x: targets[0]!.x, y: targets[0]!.y }, { x: 30, y: 24 });
|
||||
assert.equal(targets[1]!.clickable, false);
|
||||
});
|
||||
});
|
||||
|
||||
test("queryCandidates caches within a tick until reset", () => {
|
||||
const first = fakeElement("button", { x: 0, y: 0, w: 10, h: 10 });
|
||||
withFakeDocument(
|
||||
{ 'a, button, input, select, textarea, [role="button"], [onclick]': [first] },
|
||||
() => {
|
||||
__testing__.resetCandidateCache();
|
||||
const a = host.queryCandidates("taps");
|
||||
const b = host.queryCandidates("taps");
|
||||
assert.equal(a, b);
|
||||
},
|
||||
);
|
||||
test("queryTargets reports only real text inputs as editable", () => {
|
||||
const input = fakeElement({ tag: "input", x: 0, y: 0, width: 100, height: 20, editable: true });
|
||||
const checkbox = fakeElement({ tag: "input", x: 0, y: 40, width: 20, height: 20, editable: true });
|
||||
checkbox.type = "checkbox";
|
||||
withFakeDocument([input, checkbox], () => {
|
||||
const targets = host.queryTargets();
|
||||
assert.equal(targets[0]!.editable, true);
|
||||
assert.deepEqual({ x: targets[0]!.x, y: targets[0]!.y }, { x: 50, y: 10 });
|
||||
assert.equal(targets[1]!.editable, false);
|
||||
});
|
||||
});
|
||||
|
||||
// A disabled control used to be dropped from every verb's candidates, because
|
||||
// the web host folded `disabled` into its visibility check. It is a fact of its
|
||||
// own now, so `taps` still skips it while `swipes` can still start on it, which
|
||||
// is what the native host has always done.
|
||||
test("queryTargets reports a disabled control rather than dropping it", () => {
|
||||
const disabled = fakeElement({
|
||||
tag: "button", x: 0, y: 0, width: 40, height: 20, clickable: true, disabled: true,
|
||||
});
|
||||
withFakeDocument([disabled], () => {
|
||||
const targets = host.queryTargets();
|
||||
assert.equal(targets.length, 1);
|
||||
assert.equal(targets[0]!.clickable, true);
|
||||
assert.equal(targets[0]!.enabled, false);
|
||||
});
|
||||
});
|
||||
|
||||
test("queryTargets caches within a tick until reset", () => {
|
||||
const button = fakeElement({ tag: "button", x: 0, y: 0, width: 10, height: 10, clickable: true });
|
||||
withFakeDocument([button], () => {
|
||||
const first = host.queryTargets();
|
||||
const second = host.queryTargets();
|
||||
assert.equal(first, second);
|
||||
__testing__.resetTargetCache();
|
||||
assert.notEqual(host.queryTargets(), first);
|
||||
});
|
||||
});
|
||||
|
||||
// evaluateExtractors builds State, which references document and window.
|
||||
|
||||
Reference in new issue
Block a user