feat(verifier): goja runtime hosting the spec API

Installs globalThis.__uatu__ with extract, always, actions,
weighted, tap, inputText, and stub taps/swipes. Load runs the
bundled spec, then pulls properties + actions out of globalThis.
PushSnapshot rebuilds state.snapshots and refreshes every
extractor handle's current/previous in registration order so
chained extractors observe up-to-date values.

Properties are wired through internal/ltl as Always(Thunk(...)),
so verdicts latch to violated as soon as a predicate returns
false. NextAction resolves actions/weighted recursively with a
seedable rand source for reproducible runs.
This commit is contained in:
pj committed 2026-04-17 23:48:58 +07:00
1 parent 29c6ea4842
commit 72175bb13c
5 files changed
+501

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
package verifier
import (
"encoding/json"
"fmt"
"github.com/dop251/goja"
)
// Snapshots is the per-step extractor output forwarded by the SDK.
type Snapshots map[string]json.RawMessage
// stateObject builds a JS-side `{ snapshots, ax }` matching the State type
// from pkg/spec-api. ax is currently a stub returning nothing.
func stateObject(runtime *goja.Runtime, snapshots Snapshots) (*goja.Object, error) {
state := runtime.NewObject()
snapshotsObject := runtime.NewObject()
for key, raw := range snapshots {
value, err := jsonToJSValue(runtime, raw)
if err != nil {
return nil, fmt.Errorf("snapshot %q: %w", key, err)
}
if err := snapshotsObject.Set(key, value); err != nil {
return nil, err
}
}
if err := state.Set("snapshots", snapshotsObject); err != nil {
return nil, err
}
accessibility := runtime.NewObject()
if err := accessibility.Set("find", runtime.ToValue(func(string) goja.Value { return goja.Undefined() })); err != nil {
return nil, err
}
if err := accessibility.Set("findAll", runtime.ToValue(func(string) []goja.Value { return nil })); err != nil {
return nil, err
}
if err := state.Set("ax", accessibility); err != nil {
return nil, err
}
return state, nil
}
func jsonToJSValue(runtime *goja.Runtime, raw json.RawMessage) (goja.Value, error) {
if len(raw) == 0 {
return goja.Undefined(), nil
}
var generic any
if err := json.Unmarshal(raw, &generic); err != nil {
return nil, fmt.Errorf("decode JSON: %w", err)
}
return runtime.ToValue(generic), nil
}
// jsValueToAction converts a JS-side {kind, on?, into?, text?} into a Go Action.
func jsValueToAction(runtime *goja.Runtime, value goja.Value) (Action, error) {
if value == nil || goja.IsNull(value) || goja.IsUndefined(value) {
return Action{}, fmt.Errorf("nil action")
}
object := value.ToObject(runtime)
kindValue := object.Get("kind")
if kindValue == nil {
return Action{}, fmt.Errorf("action missing kind")
}
kind := kindValue.String()
switch kind {
case "Tap":
on := object.Get("on")
return Action{Kind: ActionKindTap, On: stringOf(on)}, nil
case "InputText":
into := object.Get("into")
text := object.Get("text")
return Action{Kind: ActionKindInputText, On: stringOf(into), Text: stringOf(text)}, nil
default:
return Action{}, fmt.Errorf("unknown action kind %q", kind)
}
}
func stringOf(value goja.Value) string {
if value == nil || goja.IsNull(value) || goja.IsUndefined(value) {
return ""
}
return value.String()
}