mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-04 03:57:09 +00:00
llm action-selection backend (#68)
* feat(spec): add llm() action-backend marker * feat(spec): make llm marker inert on the JS picker * feat(spec): expose __sanderlingSampleInput__ corpus draw * feat(openrouter): minimal chat-completions client * test(openrouter): cover request shape, parse, and errors * feat(verifier): thread screenshot + capture corpus sampler * feat(verifier): LLM accessors — candidates, config, sampler * test(verifier): cover AllCandidates, LLMConfig, SampleInput * feat(trace): record action Source and LLMReasoning * feat(runner): thread step screenshot into PushSnapshot * feat(runner): llmSource selects actions via OpenRouter * feat(runner): wire llmSource selection and trace stamping * test(runner): cover llmSource selection, mapping, downscale * docs(folio): add llm action-backend example spec * docs(folio): document the LLM action backend run * feat(llmclient): support OPENAI_API_KEY, openrouter wins * refactor(runner): rename openrouter package to llmclient * docs: both api keys, example model gpt-5.4-nano * docs: add pr style rules to claude.md * fix(runner): explain action kinds in llm prompt to stop swipe loops * feat(trace): record llm ranked list and chosen rank * feat(runner): stamp llm ranked list and chosen rank on trace * fix(runner): tap by selector to survive layout shift after observe * revert(runner): drop selector-first tap; broke path/testTag selectors * feat(spec): llm() accepts optional instructions * feat(verifier): read llm instructions off config * feat(runner): append spec instructions to llm system prompt * docs(folio): describe app in llm spec instructions * feat(bundler): map generator export to globalThis.generator * feat(verifier): read llm config off globalThis.generator * feat(runner): gate llm source on --generator flag * feat(cmd): add --generator llm|seeded flag * test: cover --generator flag parsing and pickSources gating * feat(verifier): enumerate llm candidates by walking actionsRoot collect-walk the weighted action tree: recurse weighted branches accumulating selection probability, call authored leaves once for concrete actions, enumerate builtins per element. label controls by visible text (borrowing descendant text), fold gestures into directional scrolls over scrollable containers, drop disabled, dedup descriptions. * test(verifier): cover candidate enumeration walk * feat(verifier): add SetupAction to walk setup without the seeded root * test(verifier): cover SetupAction setup-only precedence * refactor(llmclient): make JSONSchema.Schema raw json for pinned field order * feat(trace): record llm choice number and chosen_action echo * feat(runner): llm picks one number from weighted candidates drop the seeded-root call for a setup-only precedence path, render a numbered weighted candidate list, pin a reasoning-first choice schema, strict-skip when chosen_action does not echo the numbered entry, and let the model supply typed values (corpus fallback when empty). * test(runner): cover choice schema, strict-skip, and setup precedence * refactor(verifier): drop the superseded AllCandidates enumeration * feat(folio): drive spec.ts under --generator llm; drop spec-llm.ts * fix(verifier): label editable fields by hint, not the typed value an editable field's own text is its transient content; prefer the hint so the field is named by purpose and the label stays stable. * test(runner): cover weight-suffixed echo and stripWeightSuffix * fix(runner): accept chosen_action echo that carries the weight suffix real runs showed the model copies the whole numbered line including the trailing (w34) weight annotation, so strict-skip rejected ~91% of picks and the llm was paralyzed. strip the weight suffix before comparing. also nudge the prompt to stress-test repeated submissions (idempotency). * fix(verifier): skip llm enumeration on cross-fade frames a navhost mid-transition carries >1 route *Screen in a collapsed coordinate space; acting on it taps garbage (soft keyboard). real runs showed the llm acting on 44% of steps being such frames. skip them so the llm re-observes a settled frame next step. * feat(folio): show current balance on the add-transaction screen renders the account's balance (testTag TxnCurrentBalance) below the account name, above the credit/debit toggle, so before/after screenshots carry comparison data. * fix(replay): derive device space from screen extent, not first node the first positive-bounds element is often a short status-bar node (320x24 on android); using it gave a 320/24 aspect ratio that squashed the screenshot overlay into a grey horizontal band. use the max extent across elements (like the runner's screenBounds) instead. * fix(folio): show balance as a compact one-line label per review: one line, account-name-sized, e.g. "Balance: $0.00" instead of a large balance card. * fix(folio): move balance into the header, one compact line under the account name * fix(replay): attribute deferred violations to the causing step, not detection * fix(replay): show a step's own violations in both panels, no next-step bleed * refactor(hierarchy): one Tree.Transitional, drop the duplicated cross-fade check * chore: ignore .playwright-mcp scratch output * docs: document the llm generator and --generator flag * docs(spec): correct the llm() comment; config reads off globalThis.generator * docs: add pr description rules
This commit is contained in:
40 files changed
+2930
-101
No files matched your search
@@ -0,0 +1,684 @@
|
||||
package verifier
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
||||
)
|
||||
|
||||
// LLMConfig is the spec-declared configuration for the LLM action generator,
|
||||
// read off globalThis.generator when the spec assigned `generator = llm({...})`.
|
||||
// It is orthogonal to globalThis.actions (the weighted tree the LLM picks from);
|
||||
// only the picker differs.
|
||||
type LLMConfig struct {
|
||||
Model string
|
||||
// Instructions is optional spec-level guidance appended to the prompt to
|
||||
// steer the model toward bug-hunting (empty when unset).
|
||||
Instructions string
|
||||
}
|
||||
|
||||
// LLMConfig reports the LLM action-generator config when the spec declared one
|
||||
// (globalThis.generator.kind === "llm"). The second return is false for every
|
||||
// other spec, so the runner falls back to the seeded picker.
|
||||
func (v *Verifier) LLMConfig() (LLMConfig, bool) {
|
||||
generator := v.runtime.GlobalObject().Get("generator")
|
||||
if generator == nil || goja.IsUndefined(generator) || goja.IsNull(generator) {
|
||||
return LLMConfig{}, false
|
||||
}
|
||||
object := generator.ToObject(v.runtime)
|
||||
if object == nil {
|
||||
return LLMConfig{}, false
|
||||
}
|
||||
kind := object.Get("kind")
|
||||
if kind == nil || kind.String() != "llm" {
|
||||
return LLMConfig{}, false
|
||||
}
|
||||
config := object.Get("config")
|
||||
if config == nil || goja.IsUndefined(config) || goja.IsNull(config) {
|
||||
return LLMConfig{}, false
|
||||
}
|
||||
configObject := config.ToObject(v.runtime)
|
||||
if configObject == nil {
|
||||
return LLMConfig{}, false
|
||||
}
|
||||
model := ""
|
||||
if value := configObject.Get("model"); value != nil && !goja.IsUndefined(value) {
|
||||
model = value.String()
|
||||
}
|
||||
instructions := ""
|
||||
if value := configObject.Get("instructions"); value != nil && !goja.IsUndefined(value) && !goja.IsNull(value) {
|
||||
instructions = value.String()
|
||||
}
|
||||
return LLMConfig{Model: model, Instructions: instructions}, true
|
||||
}
|
||||
|
||||
// Screenshot returns the most recent step's screenshot PNG (set by
|
||||
// PushSnapshot), or nil if none was captured.
|
||||
func (v *Verifier) Screenshot() []byte {
|
||||
return v.lastScreenshot
|
||||
}
|
||||
|
||||
// CurrentScreen returns the screen id of the most recent snapshot's first
|
||||
// element, matching the runner's own screen labeling. Empty when no tree is
|
||||
// loaded.
|
||||
func (v *Verifier) CurrentScreen() string {
|
||||
if v.lastTree == nil || len(v.lastTree.Elements) == 0 {
|
||||
return ""
|
||||
}
|
||||
return v.lastTree.Elements[0].Screen
|
||||
}
|
||||
|
||||
// SampleInput draws one InputText value from the shared corpus via the bundled
|
||||
// __sanderlingSampleInput__. It errors when the bundle did not install the
|
||||
// callable (a raw-JS fixture) so the caller can skip typing rather than send an
|
||||
// empty string.
|
||||
func (v *Verifier) SampleInput() (string, error) {
|
||||
if v.sampleInputFn == nil {
|
||||
return "", errors.New("verifier: input sampler not available")
|
||||
}
|
||||
value, err := v.sampleInputFn(goja.Undefined())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
||||
return "", nil
|
||||
}
|
||||
return value.String(), nil
|
||||
}
|
||||
|
||||
// ActionCandidate is one selectable action the LLM generator may choose from,
|
||||
// enumerated by collect-walking the spec's weighted actionsRoot (the same tree
|
||||
// the seeded picker draws). Each candidate is a concrete, ready-to-execute
|
||||
// action carrying a plainly-worded Description (numbered and echoed for
|
||||
// strict-skip) plus its effective Weight so the model sees the spec's testing
|
||||
// priorities.
|
||||
type ActionCandidate struct {
|
||||
// Index is the candidate's 1-based position in the numbered list the model
|
||||
// picks a number from.
|
||||
Index int
|
||||
// Kind is the resulting action kind.
|
||||
Kind ActionKind
|
||||
// Description is the rendered action shown to the model and echoed back as
|
||||
// chosen_action, e.g. `Tap "Add credit"`. Dedup keys on it, so it is unique.
|
||||
Description string
|
||||
// Label is the visible-text target label (empty for gestures).
|
||||
Label string
|
||||
// Weight is the effective selection weight as a percentage (1..100),
|
||||
// meaningful only when Weighted is true (the tree used `weighted`).
|
||||
Weight int
|
||||
Weighted bool
|
||||
// InputType hints a typing field's expected input (e.g. "number", or the
|
||||
// field's hint); empty when unknown or not a typing candidate.
|
||||
InputType string
|
||||
// Direction is up/down/left/right for gesture (Scroll) candidates, else "".
|
||||
Direction string
|
||||
// LLMText is true for builtin typing, where the model supplies the value;
|
||||
// false for authored InputText, whose sampled Action.Text is replayed as-is.
|
||||
LLMText bool
|
||||
// Action is the concrete action executed when this candidate is chosen. For
|
||||
// builtin typing it carries no text until the model's value is filled in.
|
||||
Action Action
|
||||
|
||||
// prob is the internal accumulated selection probability, summed across
|
||||
// dedup, then rounded into Weight. Not exposed in the prompt directly.
|
||||
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).
|
||||
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
|
||||
}
|
||||
nodeIndex := buildNodeIndex(v.lastTree)
|
||||
var raw []ActionCandidate
|
||||
v.collectNode(root, 1.0, false, nodeIndex, &raw)
|
||||
return finalizeCandidates(raw)
|
||||
}
|
||||
|
||||
// collectNode dispatches one GeneratorNode of the action tree. prob is the
|
||||
// accumulated probability the seeded picker reaches this node; weighted records
|
||||
// whether any weighted node lies on the path (so weights are shown only when the
|
||||
// spec actually declared them).
|
||||
func (v *Verifier) collectNode(node goja.Value, prob float64, weighted bool, nodeIndex map[*hierarchy.Element]*hierarchy.Node, out *[]ActionCandidate) {
|
||||
object := node.ToObject(v.runtime)
|
||||
if object == nil {
|
||||
return
|
||||
}
|
||||
kind := object.Get("kind")
|
||||
if kind == nil || goja.IsUndefined(kind) {
|
||||
return
|
||||
}
|
||||
switch kind.String() {
|
||||
case "weighted":
|
||||
v.collectWeighted(object, prob, nodeIndex, out)
|
||||
case "actions":
|
||||
v.collectActions(object, prob, weighted, nodeIndex, out)
|
||||
case "builtin":
|
||||
verb := object.Get("verb")
|
||||
if verb != nil && !goja.IsUndefined(verb) {
|
||||
v.collectBuiltin(verb.String(), prob, weighted, nodeIndex, out)
|
||||
}
|
||||
case "llm":
|
||||
// The llm marker is the generator, not part of the candidate tree.
|
||||
}
|
||||
}
|
||||
|
||||
// collectWeighted recurses each branch, splitting the incoming probability by
|
||||
// the branch weight over the sibling total (matching the seeded picker's single
|
||||
// weighted draw).
|
||||
func (v *Verifier) collectWeighted(object *goja.Object, prob float64, nodeIndex map[*hierarchy.Element]*hierarchy.Node, out *[]ActionCandidate) {
|
||||
branches := object.Get("branches")
|
||||
if branches == nil {
|
||||
return
|
||||
}
|
||||
array := branches.ToObject(v.runtime)
|
||||
if array == nil {
|
||||
return
|
||||
}
|
||||
length := int(array.Get("length").ToInteger())
|
||||
weights := make([]float64, length)
|
||||
children := make([]goja.Value, length)
|
||||
total := 0.0
|
||||
for i := range length {
|
||||
entry := array.Get(strconv.Itoa(i))
|
||||
pair := entry.ToObject(v.runtime)
|
||||
if pair == nil {
|
||||
continue
|
||||
}
|
||||
weight := pair.Get("0").ToFloat()
|
||||
if weight < 0 || math.IsNaN(weight) {
|
||||
weight = 0
|
||||
}
|
||||
weights[i] = weight
|
||||
children[i] = pair.Get("1")
|
||||
total += weight
|
||||
}
|
||||
if total <= 0 {
|
||||
return
|
||||
}
|
||||
for i := range length {
|
||||
if children[i] == nil {
|
||||
continue
|
||||
}
|
||||
v.collectNode(children[i], prob*weights[i]/total, true, nodeIndex, out)
|
||||
}
|
||||
}
|
||||
|
||||
// collectActions calls an authored leaf's generator once (safe: it reads state
|
||||
// and, off-route, returns []), turning each concrete descriptor into a
|
||||
// candidate. It runs OUTSIDE the picker's rng scope, so from(...).generate()
|
||||
// draws nothing and no seed advances.
|
||||
func (v *Verifier) collectActions(object *goja.Object, prob float64, weighted bool, nodeIndex map[*hierarchy.Element]*hierarchy.Node, out *[]ActionCandidate) {
|
||||
generate, ok := goja.AssertFunction(object.Get("generate"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := generate(goja.Undefined())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
array := result.ToObject(v.runtime)
|
||||
if array == nil {
|
||||
return
|
||||
}
|
||||
length := int(array.Get("length").ToInteger())
|
||||
for i := range length {
|
||||
candidate, ok := v.candidateFromDescriptor(array.Get(strconv.Itoa(i)), nodeIndex)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
candidate.prob = prob
|
||||
candidate.Weighted = weighted
|
||||
*out = append(*out, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
// candidateFromDescriptor lowers one authored ActionDescriptor (as a goja
|
||||
// object) into a ready-to-run candidate, resolving the target's coordinates,
|
||||
// selector, and visible-text label. Actions on a disabled control are dropped.
|
||||
func (v *Verifier) candidateFromDescriptor(value goja.Value, nodeIndex map[*hierarchy.Element]*hierarchy.Node) (ActionCandidate, bool) {
|
||||
object := value.ToObject(v.runtime)
|
||||
if object == nil {
|
||||
return ActionCandidate{}, false
|
||||
}
|
||||
kindValue := object.Get("kind")
|
||||
if kindValue == nil || goja.IsUndefined(kindValue) {
|
||||
return ActionCandidate{}, false
|
||||
}
|
||||
kind := ActionKind(kindValue.String())
|
||||
switch kind {
|
||||
case ActionKindTap, ActionKindDoubleTap, ActionKindLongPress:
|
||||
target := v.resolveTarget(object.Get("on"), nodeIndex)
|
||||
if target.disabled {
|
||||
return ActionCandidate{}, false
|
||||
}
|
||||
return ActionCandidate{
|
||||
Kind: kind,
|
||||
Label: target.label,
|
||||
Action: Action{Kind: kind, On: target.selector, X: target.x, Y: target.y},
|
||||
}, true
|
||||
case ActionKindInputText:
|
||||
target := v.resolveTarget(object.Get("into"), nodeIndex)
|
||||
if target.disabled {
|
||||
return ActionCandidate{}, false
|
||||
}
|
||||
text := stringField(object, "text")
|
||||
return ActionCandidate{
|
||||
Kind: kind,
|
||||
Label: target.label,
|
||||
InputType: target.inputType,
|
||||
Action: Action{Kind: kind, On: target.selector, X: target.x, Y: target.y, Text: text},
|
||||
}, true
|
||||
case ActionKindScroll:
|
||||
target := v.resolveTarget(object.Get("in"), nodeIndex)
|
||||
direction := stringField(object, "direction")
|
||||
if direction == "" {
|
||||
direction = "down"
|
||||
}
|
||||
return ActionCandidate{
|
||||
Kind: kind,
|
||||
Direction: direction,
|
||||
Action: Action{Kind: kind, On: target.selector, Direction: direction},
|
||||
}, true
|
||||
case ActionKindPressKey:
|
||||
return ActionCandidate{
|
||||
Kind: kind,
|
||||
Action: Action{Kind: kind, Key: stringField(object, "key")},
|
||||
}, true
|
||||
case ActionKindWait:
|
||||
return ActionCandidate{Kind: kind, Action: Action{Kind: kind}}, true
|
||||
default:
|
||||
return ActionCandidate{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// resolvedTarget is the geometry, selector, label, and input hint a target
|
||||
// (ax element, selector string, or bare point) resolves to.
|
||||
type resolvedTarget struct {
|
||||
x, y int
|
||||
selector string
|
||||
label string
|
||||
inputType string
|
||||
disabled bool
|
||||
}
|
||||
|
||||
// resolveTarget reads an authored action's target. Ax element handles carry
|
||||
// x/y/__sanderlingSelector plus their own text; a bare selector string resolves
|
||||
// against the current tree; a point carries geometry only.
|
||||
func (v *Verifier) resolveTarget(value goja.Value, nodeIndex map[*hierarchy.Element]*hierarchy.Node) resolvedTarget {
|
||||
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
||||
return resolvedTarget{}
|
||||
}
|
||||
if selector, ok := value.Export().(string); ok {
|
||||
return v.targetFromSelector(selector, nodeIndex)
|
||||
}
|
||||
object := value.ToObject(v.runtime)
|
||||
if object == nil {
|
||||
return resolvedTarget{}
|
||||
}
|
||||
selector := stringField(object, tagSelector)
|
||||
if selector == "" {
|
||||
selector = stringField(object, "selector")
|
||||
}
|
||||
target := resolvedTarget{
|
||||
x: int(object.Get("x").ToInteger()),
|
||||
y: int(object.Get("y").ToInteger()),
|
||||
selector: selector,
|
||||
}
|
||||
if element := v.findBySelector(selector); element != nil {
|
||||
target.label = visibleLabel(element, nodeIndex)
|
||||
target.inputType = inputTypeHint(element)
|
||||
target.disabled = !element.Enabled && hasEnabled(element)
|
||||
}
|
||||
if target.label == "" {
|
||||
target.label = truncateLabel(stringField(object, "text"))
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
// targetFromSelector resolves a bare selector-string target against the tree.
|
||||
func (v *Verifier) targetFromSelector(selector string, nodeIndex map[*hierarchy.Element]*hierarchy.Node) resolvedTarget {
|
||||
target := resolvedTarget{selector: selector}
|
||||
element := v.findBySelector(selector)
|
||||
if element == nil {
|
||||
return target
|
||||
}
|
||||
target.x, target.y = element.Bounds.Center()
|
||||
target.label = visibleLabel(element, nodeIndex)
|
||||
target.inputType = inputTypeHint(element)
|
||||
target.disabled = !element.Enabled && hasEnabled(element)
|
||||
return target
|
||||
}
|
||||
|
||||
func (v *Verifier) findBySelector(selector string) *hierarchy.Element {
|
||||
if selector == "" || v.lastTree == nil {
|
||||
return nil
|
||||
}
|
||||
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.
|
||||
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,
|
||||
})
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
case "scrolls", "swipes":
|
||||
v.collectGestures(prob, weighted, out)
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
return elements
|
||||
}
|
||||
|
||||
// finalizeCandidates renders each candidate's description, dedups identical
|
||||
// descriptions (summing weight), numbers the survivors 1..N, and rounds the
|
||||
// accumulated probability into a percentage Weight.
|
||||
func finalizeCandidates(raw []ActionCandidate) []ActionCandidate {
|
||||
seen := make(map[string]int, len(raw))
|
||||
result := make([]ActionCandidate, 0, len(raw))
|
||||
for _, candidate := range raw {
|
||||
candidate.Description = describeCandidate(candidate)
|
||||
if index, ok := seen[candidate.Description]; ok {
|
||||
result[index].prob += candidate.prob
|
||||
result[index].Weighted = result[index].Weighted || candidate.Weighted
|
||||
continue
|
||||
}
|
||||
seen[candidate.Description] = len(result)
|
||||
result = append(result, candidate)
|
||||
}
|
||||
for i := range result {
|
||||
result[i].Index = i + 1
|
||||
if result[i].Weighted {
|
||||
result[i].Weight = max(1, int(math.Round(result[i].prob*100)))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// describeCandidate renders the plain, echo-friendly description shown in the
|
||||
// numbered list. It is the dedup key, so it must be stable and unique per
|
||||
// distinct action.
|
||||
func describeCandidate(candidate ActionCandidate) string {
|
||||
switch candidate.Kind {
|
||||
case ActionKindTap:
|
||||
return fmt.Sprintf("Tap %q", candidate.Label)
|
||||
case ActionKindDoubleTap:
|
||||
return fmt.Sprintf("Double-tap %q", candidate.Label)
|
||||
case ActionKindLongPress:
|
||||
return fmt.Sprintf("Long-press %q", candidate.Label)
|
||||
case ActionKindInputText:
|
||||
if candidate.LLMText {
|
||||
if candidate.InputType != "" {
|
||||
return fmt.Sprintf("Type into %q (%s)", candidate.Label, candidate.InputType)
|
||||
}
|
||||
return fmt.Sprintf("Type into %q", candidate.Label)
|
||||
}
|
||||
return fmt.Sprintf("Type %q into %q", candidate.Action.Text, candidate.Label)
|
||||
case ActionKindScroll:
|
||||
return "Scroll " + candidate.Direction
|
||||
case ActionKindPressKey:
|
||||
return "Press " + candidate.Action.Key
|
||||
case ActionKindWait:
|
||||
return "Wait"
|
||||
default:
|
||||
return string(candidate.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
// buildNodeIndex maps each Element pointer to its Node so descendant text can be
|
||||
// borrowed for a control whose own text is empty.
|
||||
func buildNodeIndex(tree *hierarchy.Tree) map[*hierarchy.Element]*hierarchy.Node {
|
||||
index := make(map[*hierarchy.Element]*hierarchy.Node)
|
||||
if tree == nil || tree.Root == nil {
|
||||
return index
|
||||
}
|
||||
var walk func(node *hierarchy.Node)
|
||||
walk = func(node *hierarchy.Node) {
|
||||
index[&node.Element] = node
|
||||
for _, child := range node.Children {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(tree.Root)
|
||||
return index
|
||||
}
|
||||
|
||||
// visibleLabel names a control by what a user would read: its own text, then
|
||||
// description, then a field hint, then text borrowed from its descendants (the
|
||||
// case that fixes empty-text Compose buttons whose word lives on a child), then
|
||||
// its class as a last resort.
|
||||
func visibleLabel(element *hierarchy.Element, nodeIndex map[*hierarchy.Element]*hierarchy.Node) string {
|
||||
// An editable field's own text is the transient typed value ("1"); its hint
|
||||
// names its purpose ("Amount") and stays stable, so prefer the hint there.
|
||||
if element.Editable {
|
||||
if hint := element.Attributes["hintText"]; hint != "" {
|
||||
return truncateLabel(hint)
|
||||
}
|
||||
}
|
||||
if element.Text != "" {
|
||||
return truncateLabel(element.Text)
|
||||
}
|
||||
if element.Description != "" {
|
||||
return truncateLabel(element.Description)
|
||||
}
|
||||
if hint := element.Attributes["hintText"]; hint != "" {
|
||||
return truncateLabel(hint)
|
||||
}
|
||||
if node := nodeIndex[element]; node != nil {
|
||||
if text := descendantText(node); text != "" {
|
||||
return truncateLabel(text)
|
||||
}
|
||||
}
|
||||
if element.Class != "" {
|
||||
return element.Class
|
||||
}
|
||||
if element.ResourceID != "" {
|
||||
return element.ResourceID
|
||||
}
|
||||
return "control"
|
||||
}
|
||||
|
||||
// descendantText joins the visible text of a node's descendants in tree order,
|
||||
// so a clickable wrapper borrows the label of the Text child it contains.
|
||||
func descendantText(node *hierarchy.Node) string {
|
||||
var parts []string
|
||||
var walk func(node *hierarchy.Node)
|
||||
walk = func(node *hierarchy.Node) {
|
||||
for _, child := range node.Children {
|
||||
switch {
|
||||
case child.Element.Text != "":
|
||||
parts = append(parts, child.Element.Text)
|
||||
case child.Element.Description != "":
|
||||
parts = append(parts, child.Element.Description)
|
||||
}
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(node)
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// truncateLabel trims and shortens a label to one line's worth of runes.
|
||||
func truncateLabel(text string) string {
|
||||
text = strings.TrimSpace(strings.ReplaceAll(text, "\n", " "))
|
||||
runes := []rune(text)
|
||||
if len(runes) <= maxLabelRunes {
|
||||
return text
|
||||
}
|
||||
return strings.TrimSpace(string(runes[:maxLabelRunes])) + "…"
|
||||
}
|
||||
|
||||
// inputTypeHint reports a typing field's expected input as a short word the
|
||||
// model can use to synthesize a value, or "" when nothing distinguishes it.
|
||||
func inputTypeHint(element *hierarchy.Element) string {
|
||||
haystack := strings.ToLower(element.Class + " " +
|
||||
element.Attributes["inputType"] + " " + element.Attributes["hintText"])
|
||||
switch {
|
||||
case strings.Contains(haystack, "number") || strings.Contains(haystack, "amount") || strings.Contains(haystack, "numeric"):
|
||||
return "number"
|
||||
case strings.Contains(haystack, "email"):
|
||||
return "email"
|
||||
case strings.Contains(haystack, "password"):
|
||||
return "password"
|
||||
case strings.Contains(haystack, "phone"):
|
||||
return "phone"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// hasEnabled reports whether the source tree carried an explicit enabled flag
|
||||
// for the element, so a missing flag is not mistaken for "disabled".
|
||||
func hasEnabled(element *hierarchy.Element) bool {
|
||||
_, ok := element.Attributes["enabled"]
|
||||
return ok
|
||||
}
|
||||
|
||||
// stringField reads a string property off a goja object, returning "" when
|
||||
// absent, null, or undefined.
|
||||
func stringField(object *goja.Object, key string) string {
|
||||
value := object.Get(key)
|
||||
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
||||
return ""
|
||||
}
|
||||
return value.String()
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package verifier
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
||||
)
|
||||
|
||||
// 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).
|
||||
const enumTreeJSON = `{
|
||||
"attributes": {"bounds": "[0,0,1080,2400]"},
|
||||
"children": [
|
||||
{"attributes": {"resource-id": "AddCredit", "bounds": "[0,100,1080,200]"}, "clickable": true, "enabled": true, "children": [
|
||||
{"attributes": {"text": "Add credit", "bounds": "[0,100,540,200]"}, "children": []}
|
||||
]},
|
||||
{"attributes": {"resource-id": "Amount", "class": "EditText", "hintText": "Amount", "bounds": "[0,300,1080,400]"}, "enabled": true, "children": []},
|
||||
{"attributes": {"resource-id": "SignIn", "text": "Sign in", "bounds": "[0,450,1080,550]"}, "clickable": true, "enabled": true, "children": []},
|
||||
{"attributes": {"resource-id": "Off", "text": "Off", "bounds": "[0,600,1080,700]"}, "clickable": true, "enabled": false, "children": []},
|
||||
{"attributes": {"resource-id": "List", "scrollable": "true", "bounds": "[0,800,1080,2000]"}, "children": []}
|
||||
]
|
||||
}`
|
||||
|
||||
// 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.
|
||||
func enumVerifier(t *testing.T, actionsJS, treeJSON string) *Verifier {
|
||||
t.Helper()
|
||||
v := newLoadedVerifier(t, "globalThis.actions = "+actionsJS+";")
|
||||
tree, err := hierarchy.Parse(treeJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("parse tree: %v", err)
|
||||
}
|
||||
v.lastTree = tree
|
||||
return v
|
||||
}
|
||||
|
||||
func findCandidate(candidates []ActionCandidate, description string) (ActionCandidate, bool) {
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Description == description {
|
||||
return candidate, true
|
||||
}
|
||||
}
|
||||
return ActionCandidate{}, false
|
||||
}
|
||||
|
||||
func hasCandidate(candidates []ActionCandidate, description string) bool {
|
||||
_, ok := findCandidate(candidates, description)
|
||||
return ok
|
||||
}
|
||||
|
||||
func TestCandidatesLabelsControlsByVisibleText(t *testing.T) {
|
||||
v := enumVerifier(t, "{kind:'builtin', verb:'taps'}", enumTreeJSON)
|
||||
candidates := v.Candidates()
|
||||
|
||||
// The empty-text clickable wrapper is labeled by its child Text, NOT its
|
||||
// resource-id.
|
||||
if !hasCandidate(candidates, `Tap "Add credit"`) {
|
||||
t.Errorf("want Tap \"Add credit\" (descendant text), got %v", descriptions(candidates))
|
||||
}
|
||||
// The plain text button is labeled by its own text.
|
||||
if !hasCandidate(candidates, `Tap "Sign in"`) {
|
||||
t.Errorf("want Tap \"Sign in\", got %v", descriptions(candidates))
|
||||
}
|
||||
// Descriptions are never the opaque resource-id.
|
||||
if hasCandidate(candidates, `Tap "AddCredit"`) {
|
||||
t.Error("labeled a control by its resource-id instead of visible text")
|
||||
}
|
||||
// Indices are dense and 1-based.
|
||||
for i, candidate := range candidates {
|
||||
if candidate.Index != i+1 {
|
||||
t.Errorf("candidate %d has Index %d, want %d", i, candidate.Index, i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidatesDropsDisabledControls(t *testing.T) {
|
||||
v := enumVerifier(t, "{kind:'builtin', verb:'taps'}", enumTreeJSON)
|
||||
for _, candidate := range v.Candidates() {
|
||||
if strings.Contains(candidate.Description, "Off") {
|
||||
t.Errorf("disabled control surfaced as %q", candidate.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidatesTypingExposesInputType(t *testing.T) {
|
||||
v := enumVerifier(t, "{kind:'builtin', verb:'typing'}", enumTreeJSON)
|
||||
candidates := v.Candidates()
|
||||
candidate, ok := findCandidate(candidates, `Type into "Amount" (number)`)
|
||||
if !ok {
|
||||
t.Fatalf("want typing candidate with input type, got %v", descriptions(candidates))
|
||||
}
|
||||
if !candidate.LLMText {
|
||||
t.Error("builtin typing must flag LLMText so the model supplies the value")
|
||||
}
|
||||
if candidate.InputType != "number" {
|
||||
t.Errorf("InputType = %q, want number", candidate.InputType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidatesLabelsEditableFieldByHintNotTypedValue(t *testing.T) {
|
||||
// A field already showing "99" must still be labeled by its purpose (the
|
||||
// hint), not by its transient content, so the description stays stable.
|
||||
tree := `{
|
||||
"attributes": {"bounds": "[0,0,400,800]"},
|
||||
"children": [
|
||||
{"attributes": {"resource-id": "Amt", "class": "EditText", "hintText": "Amount", "text": "99", "bounds": "[0,0,400,100]"}, "enabled": true, "children": []}
|
||||
]
|
||||
}`
|
||||
v := enumVerifier(t, "{kind:'builtin', verb:'typing'}", tree)
|
||||
candidates := v.Candidates()
|
||||
if hasCandidate(candidates, `Type into "99" (number)`) || hasCandidate(candidates, `Type into "99"`) {
|
||||
t.Errorf("editable field labeled by its typed value: %v", descriptions(candidates))
|
||||
}
|
||||
if !hasCandidate(candidates, `Type into "Amount" (number)`) {
|
||||
t.Errorf("want the field labeled by its hint, got %v", descriptions(candidates))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidatesFoldsGesturesIntoDirectionalScrolls(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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidatesWeightsCombineAcrossPaths(t *testing.T) {
|
||||
// A single clickable reached through two equal branches: its weight sums to
|
||||
// the full distribution.
|
||||
oneClickable := `{
|
||||
"attributes": {"bounds": "[0,0,400,800]"},
|
||||
"children": [
|
||||
{"attributes": {"resource-id": "SignIn", "text": "Sign in", "bounds": "[0,0,400,100]"}, "clickable": true, "enabled": true, "children": []}
|
||||
]
|
||||
}`
|
||||
v := enumVerifier(t,
|
||||
"{kind:'weighted', branches:[[1,{kind:'builtin',verb:'taps'}],[1,{kind:'builtin',verb:'taps'}]]}",
|
||||
oneClickable)
|
||||
candidates := v.Candidates()
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("want one deduped candidate, got %v", descriptions(candidates))
|
||||
}
|
||||
candidate := candidates[0]
|
||||
if !candidate.Weighted {
|
||||
t.Fatal("candidate under a weighted tree must be Weighted")
|
||||
}
|
||||
if candidate.Weight != 100 {
|
||||
t.Errorf("summed weight = %d, want 100", candidate.Weight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidatesWeightReflectsBranchShare(t *testing.T) {
|
||||
v := enumVerifier(t,
|
||||
"{kind:'weighted', branches:[[1,{kind:'builtin',verb:'taps'}],[3,{kind:'builtin',verb:'typing'}]]}",
|
||||
enumTreeJSON)
|
||||
candidates := v.Candidates()
|
||||
tap, ok := findCandidate(candidates, `Tap "Sign in"`)
|
||||
if !ok {
|
||||
t.Fatalf("missing tap candidate: %v", descriptions(candidates))
|
||||
}
|
||||
if tap.Weight != 25 {
|
||||
t.Errorf("tap weight = %d, want 25 (1/4 share)", tap.Weight)
|
||||
}
|
||||
typing, ok := findCandidate(candidates, `Type into "Amount" (number)`)
|
||||
if !ok {
|
||||
t.Fatalf("missing typing candidate: %v", descriptions(candidates))
|
||||
}
|
||||
if typing.Weight != 75 {
|
||||
t.Errorf("typing weight = %d, want 75 (3/4 share)", typing.Weight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidatesUnweightedTreeShowsNoWeight(t *testing.T) {
|
||||
v := enumVerifier(t, "{kind:'builtin', verb:'taps'}", enumTreeJSON)
|
||||
for _, candidate := range v.Candidates() {
|
||||
if candidate.Weighted || candidate.Weight != 0 {
|
||||
t.Errorf("%q carries a weight despite no weighted node", candidate.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidatesCallsAuthoredLeafOnce(t *testing.T) {
|
||||
actions := `{kind:'actions', generate: () => [
|
||||
{kind:'Tap', on:'id:SignIn'},
|
||||
{kind:'Tap', on:'id:Off'},
|
||||
{kind:'InputText', into:'id:Amount', text:'42'}
|
||||
]}`
|
||||
v := enumVerifier(t, actions, enumTreeJSON)
|
||||
candidates := v.Candidates()
|
||||
|
||||
// Authored Tap resolves its selector to the visible-text label.
|
||||
if !hasCandidate(candidates, `Tap "Sign in"`) {
|
||||
t.Errorf("authored tap missing: %v", descriptions(candidates))
|
||||
}
|
||||
// A disabled authored target is dropped.
|
||||
for _, candidate := range candidates {
|
||||
if strings.Contains(candidate.Description, "Off") {
|
||||
t.Errorf("authored action on disabled control surfaced: %q", candidate.Description)
|
||||
}
|
||||
}
|
||||
// Authored InputText replays its own sampled value (LLM does not supply it).
|
||||
authored, ok := findCandidate(candidates, `Type "42" into "Amount"`)
|
||||
if !ok {
|
||||
t.Fatalf("authored typing missing: %v", descriptions(candidates))
|
||||
}
|
||||
if authored.LLMText {
|
||||
t.Error("authored InputText must not request an LLM-supplied value")
|
||||
}
|
||||
if authored.Action.Text != "42" {
|
||||
t.Errorf("authored text = %q, want 42", authored.Action.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidatesOffRouteLeafYieldsNothing(t *testing.T) {
|
||||
v := enumVerifier(t, "{kind:'actions', generate: () => []}", enumTreeJSON)
|
||||
if got := v.Candidates(); len(got) != 0 {
|
||||
t.Errorf("off-route leaf should yield no candidates, got %v", descriptions(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidatesSkipsCrossFadeFrames(t *testing.T) {
|
||||
// Two route *Screen tags alive at once is a NavHost cross-fade: its layout is
|
||||
// mid-animation (collapsed coordinate space), so the LLM must NOT act on it.
|
||||
crossFade := `{
|
||||
"attributes": {"bounds": "[0,0,320,640]"},
|
||||
"children": [
|
||||
{"attributes": {"resource-id": "LedgerScreen", "bounds": "[0,0,320,640]"}, "children": [
|
||||
{"attributes": {"resource-id": "TxnSubmit", "text": "Add credit", "bounds": "[20,332,300,380]"}, "clickable": true, "enabled": true, "children": []}
|
||||
]},
|
||||
{"attributes": {"resource-id": "AddTransactionScreen", "bounds": "[0,0,320,640]"}, "children": []}
|
||||
]
|
||||
}`
|
||||
v := enumVerifier(t, "{kind:'builtin', verb:'taps'}", crossFade)
|
||||
if got := v.Candidates(); got != nil {
|
||||
t.Errorf("cross-fade frame should yield no candidates, got %v", descriptions(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidatesNilWithoutTreeOrActions(t *testing.T) {
|
||||
withActions := newLoadedVerifier(t, "globalThis.actions = {kind:'builtin', verb:'taps'};")
|
||||
if got := withActions.Candidates(); got != nil {
|
||||
t.Errorf("Candidates with no tree = %v, want nil", got)
|
||||
}
|
||||
noActions := newLoadedVerifier(t, "globalThis.properties = {};")
|
||||
tree, _ := hierarchy.Parse(enumTreeJSON)
|
||||
noActions.lastTree = tree
|
||||
if got := noActions.Candidates(); got != nil {
|
||||
t.Errorf("Candidates with no actions root = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func descriptions(candidates []ActionCandidate) []string {
|
||||
out := make([]string, len(candidates))
|
||||
for i, candidate := range candidates {
|
||||
out[i] = candidate.Description
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func count(candidates []ActionCandidate, description string) int {
|
||||
n := 0
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Description == description {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestLLMConfigDetectsMarker(t *testing.T) {
|
||||
v := newLoadedVerifier(t, `globalThis.generator = { kind: "llm", config: { model: "vendor/model" } };`)
|
||||
config, ok := v.LLMConfig()
|
||||
if !ok {
|
||||
t.Fatal("LLMConfig not detected for llm marker")
|
||||
}
|
||||
if config.Model != "vendor/model" {
|
||||
t.Errorf("model = %q, want vendor/model", config.Model)
|
||||
}
|
||||
if config.Instructions != "" {
|
||||
t.Errorf("instructions = %q, want empty when unset", config.Instructions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMConfigReadsInstructions(t *testing.T) {
|
||||
v := newLoadedVerifier(t, `globalThis.generator = { kind: "llm", config: { model: "m", instructions: "find bugs" } };`)
|
||||
config, ok := v.LLMConfig()
|
||||
if !ok {
|
||||
t.Fatal("LLMConfig not detected")
|
||||
}
|
||||
if config.Instructions != "find bugs" {
|
||||
t.Errorf("instructions = %q, want %q", config.Instructions, "find bugs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMConfigAbsentForSeededSpec(t *testing.T) {
|
||||
v := newLoadedVerifier(t, `globalThis.actions = { kind: "builtin", verb: "taps" };`)
|
||||
if _, ok := v.LLMConfig(); ok {
|
||||
t.Error("LLMConfig should be false when no generator is declared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSampleInputErrorsWithoutBundle(t *testing.T) {
|
||||
v := newLoadedVerifier(t, `globalThis.actions = { kind: "llm", config: { model: "m" } };`)
|
||||
if _, err := v.SampleInput(); err == nil {
|
||||
t.Error("expected SampleInput to error when the sampler is not installed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSampleInputDrawsFromCorpus(t *testing.T) {
|
||||
v := newLoadedVerifier(t, `globalThis.__sanderlingSampleInput__ = () => "sampled";`)
|
||||
got, err := v.SampleInput()
|
||||
if err != nil {
|
||||
t.Fatalf("SampleInput: %v", err)
|
||||
}
|
||||
if got != "sampled" {
|
||||
t.Errorf("SampleInput = %q, want sampled", got)
|
||||
}
|
||||
}
|
||||
|
||||
func newLoadedVerifier(t *testing.T, source string) *Verifier {
|
||||
t.Helper()
|
||||
v, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if err := v.Load(source); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package verifier
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/bundler"
|
||||
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
||||
)
|
||||
|
||||
// bundleInlineSpec bundles an inline spec through the real @sanderling/spec API
|
||||
// and goja runtime entry, so the bundle installs __sanderlingSetupAction__ the
|
||||
// way the CLI does.
|
||||
func bundleInlineSpec(t *testing.T, source string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
specPath := filepath.Join(dir, "spec.ts")
|
||||
if err := os.WriteFile(specPath, []byte(source), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
abs := func(rel string) string {
|
||||
path, err := filepath.Abs(rel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
bundle, err := bundler.Bundle(bundler.Options{
|
||||
EntryFile: specPath,
|
||||
RuntimeFile: abs("../../pkg/spec/src/goja-runtime.ts"),
|
||||
Aliases: map[string]string{
|
||||
"@sanderling/spec": abs("../../pkg/spec/src/index.ts"),
|
||||
"@sanderling/spec/defaults": abs("../../pkg/spec/src/defaults/index.ts"),
|
||||
"@sanderling/spec/defaults/properties": abs("../../pkg/spec/src/defaults/properties.ts"),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bundle: %v", err)
|
||||
}
|
||||
return string(bundle.JavaScript)
|
||||
}
|
||||
|
||||
func loadBundled(t *testing.T, source, treeJSON string) *Verifier {
|
||||
t.Helper()
|
||||
v, err := New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := v.Load(bundleInlineSpec(t, source)); err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
tree, err := hierarchy.Parse(treeJSON)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := v.PushSnapshot(SnapshotInput{Tree: tree}); err != nil {
|
||||
t.Fatalf("push snapshot: %v", err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func TestSetupActionWalksSetupOnly(t *testing.T) {
|
||||
spec := `
|
||||
import { Tap, actions, taps } from "@sanderling/spec";
|
||||
export const setup = actions(() => [Tap({ on: "id:SignIn" })]);
|
||||
export const actionsRoot = taps;
|
||||
`
|
||||
v := loadBundled(t, spec, enumTreeJSON)
|
||||
action, err := v.SetupAction()
|
||||
if err != nil {
|
||||
t.Fatalf("SetupAction: %v", err)
|
||||
}
|
||||
if action.Kind != ActionKindTap || action.On != "id:SignIn" {
|
||||
t.Errorf("SetupAction = %+v, want Tap on id:SignIn from setup", action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupActionIgnoresActionsRoot(t *testing.T) {
|
||||
// No setup, but a live actionsRoot that NextAction would happily draw from.
|
||||
spec := `
|
||||
import { taps } from "@sanderling/spec";
|
||||
export const actionsRoot = taps;
|
||||
`
|
||||
v := loadBundled(t, spec, enumTreeJSON)
|
||||
if _, err := v.SetupAction(); !errors.Is(err, ErrNoAction) {
|
||||
t.Fatalf("SetupAction err = %v, want ErrNoAction when no setup is declared", err)
|
||||
}
|
||||
// Sanity: the seeded root would have produced an action, proving SetupAction
|
||||
// deliberately skips it.
|
||||
if _, err := v.NextAction(); err != nil {
|
||||
t.Fatalf("NextAction should draw from actionsRoot: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,17 @@ type Verifier struct {
|
||||
// the shared picker (pick.ts) over the shared Pcg.
|
||||
nextActionFn goja.Callable
|
||||
|
||||
// setupActionFn is the bundle-installed __sanderlingSetupAction__, which
|
||||
// walks ONLY the setup generator. The LLM action generator runs it for setup
|
||||
// precedence (e.g. login) without triggering the seeded action root.
|
||||
setupActionFn goja.Callable
|
||||
|
||||
// sampleInputFn is the bundle-installed __sanderlingSampleInput__, which
|
||||
// draws one value from the shared INPUT_CORPUS. The LLM action backend uses
|
||||
// it to fill InputText values, reusing the exact corpus draw rather than
|
||||
// reimplementing the corpus on the Go side.
|
||||
sampleInputFn goja.Callable
|
||||
|
||||
evaluators map[string]*ltl.Evaluator
|
||||
|
||||
priorVerdicts map[string]ltl.Verdict
|
||||
@@ -35,6 +46,7 @@ type Verifier struct {
|
||||
witnesses map[string]Witness
|
||||
|
||||
lastTree *hierarchy.Tree
|
||||
lastScreenshot []byte
|
||||
scopeCache map[*hierarchy.Element]bool
|
||||
scopeCacheTree *hierarchy.Tree
|
||||
lastAction *Action
|
||||
@@ -151,6 +163,20 @@ func (v *Verifier) Load(source string) error {
|
||||
v.nextActionFn = callable
|
||||
}
|
||||
}
|
||||
if fn := v.runtime.GlobalObject().Get("__sanderlingSetupAction__"); fn != nil {
|
||||
if callable, ok := goja.AssertFunction(fn); ok {
|
||||
v.setupActionFn = callable
|
||||
}
|
||||
}
|
||||
|
||||
// __sanderlingSampleInput__ draws an InputText value from the shared corpus.
|
||||
// The LLM action backend uses it; a raw-JS fixture without the runtime entry
|
||||
// leaves it nil and SampleInput reports an error.
|
||||
if fn := v.runtime.GlobalObject().Get("__sanderlingSampleInput__"); fn != nil {
|
||||
if callable, ok := goja.AssertFunction(fn); ok {
|
||||
v.sampleInputFn = callable
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -258,6 +284,7 @@ func (v *Verifier) buildFormulaNode(index int) (ltl.Formula, error) {
|
||||
// allowed and yields an empty ax scope.
|
||||
func (v *Verifier) PushSnapshot(input SnapshotInput) error {
|
||||
v.lastTree = input.Tree
|
||||
v.lastScreenshot = input.ScreenshotPNG
|
||||
v.scopeCache = nil
|
||||
v.lastAction = input.LastAction
|
||||
v.lastLogs = input.Logs
|
||||
@@ -386,9 +413,13 @@ func (v *Verifier) OverrideExtractorValues(overrides map[int]json.RawMessage) (s
|
||||
// other than Snapshots are optional; callers that only have snapshots can
|
||||
// populate Snapshots alone and leave the rest zero.
|
||||
type SnapshotInput struct {
|
||||
Snapshots Snapshots
|
||||
Tree *hierarchy.Tree
|
||||
LastAction *Action
|
||||
Snapshots Snapshots
|
||||
Tree *hierarchy.Tree
|
||||
// ScreenshotPNG is the step's screenshot, captured alongside Tree. The LLM
|
||||
// action backend reads it via Screenshot() to select a candidate; other
|
||||
// callers may leave it nil.
|
||||
ScreenshotPNG []byte
|
||||
LastAction *Action
|
||||
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
|
||||
@@ -575,6 +606,28 @@ func (v *Verifier) NextAction() (Action, error) {
|
||||
return DecodeAction(raw)
|
||||
}
|
||||
|
||||
// SetupAction walks ONLY the setup generator (globalThis.setup), returning its
|
||||
// action or ErrNoAction. The LLM action generator runs this for setup
|
||||
// precedence (e.g. login) without triggering the seeded action root, which it
|
||||
// replaces entirely. Mirrors NextAction's decode.
|
||||
func (v *Verifier) SetupAction() (Action, error) {
|
||||
if v.setupActionFn == nil {
|
||||
return Action{}, ErrNoAction
|
||||
}
|
||||
value, err := v.setupActionFn(goja.Undefined())
|
||||
if err != nil {
|
||||
return Action{}, fmt.Errorf("setup action: %w", err)
|
||||
}
|
||||
if value == nil || goja.IsNull(value) || goja.IsUndefined(value) {
|
||||
return Action{}, ErrNoAction
|
||||
}
|
||||
raw, err := json.Marshal(value.Export())
|
||||
if err != nil {
|
||||
return Action{}, fmt.Errorf("marshal action: %w", err)
|
||||
}
|
||||
return DecodeAction(raw)
|
||||
}
|
||||
|
||||
var ErrNoAction = errors.New("verifier: no action available")
|
||||
|
||||
func (v *Verifier) formulaThunk(index int) func() (bool, error) {
|
||||
|
||||
Reference in new issue
Block a user