diff --git a/candy/plugin-cua/params/cue_types_gen.go b/candy/plugin-cua/params/cue_types_gen.go index 60bd851..b73e4cf 100644 --- a/candy/plugin-cua/params/cue_types_gen.go +++ b/candy/plugin-cua/params/cue_types_gen.go @@ -26,111 +26,111 @@ package params type CuaInput struct { // method — the cua method to dispatch (also the scalar-sugar primary: // `cua: `). - Method CuaMethod `yaml:"method,omitempty" json:"method"` + Method CuaMethod `json:"method"` // --- connection ------------------------------------------------------- // host — the deployment/venue address hosting the Cua Driver. Omitted means // "the deploy I am running against": the plugin drives it over the host // reverse channel (the venue executor), which is the normal case. - Host string `yaml:"host,omitempty" json:"host,omitempty"` + Host string `json:"host,omitempty"` // driver — the `cua-driver` binary path inside the venue (default // /usr/local/bin/cua-driver). - Driver string `yaml:"driver,omitempty" json:"driver,omitempty"` + Driver string `json:"driver,omitempty"` // device — the name of a `kind: cua` DEVICE entity whose connection/permission // defaults this step inherits. Authored step fields WIN over the entity's. - Device string `yaml:"device,omitempty" json:"device,omitempty"` + Device string `json:"device,omitempty"` // env — extra session environment the driver needs inside the venue (the // desktop-session variables). Merged over the entity's env. - Env map[string]string `yaml:"env,omitempty" json:"env,omitempty"` + Env map[string]string `json:"env,omitempty"` // allow_control — REQUIRED to be true for every MUTATING method (input, // app control, config writes). Read-only methods ignore it. This is the // bed-safety gate: a live desktop is not a throwaway, and an unattended plan // must not type into it by accident. - AllowControl bool `yaml:"allow_control,omitempty" json:"allow_control,omitempty"` + AllowControl bool `json:"allow_control,omitempty"` // --- input (keyboard/pointer) ---------------------------------------- // text — the text `type` inserts. - Text string `yaml:"text,omitempty" json:"text,omitempty"` + Text string `json:"text,omitempty"` // key — the named key `press-key` presses (return, escape, tab, f5, …). - KeyName string `yaml:"key,omitempty" json:"key,omitempty"` + KeyName string `json:"key,omitempty"` // keys — the chord `hotkey` presses, e.g. ["ctrl","c"]. - Keys []string `yaml:"keys,omitempty" json:"keys,omitempty"` + Keys []string `json:"keys,omitempty"` // x / y — desktop-absolute pixel coordinates for click/move/scroll/drag. - X int `yaml:"x,omitempty" json:"x,omitempty"` + X int `json:"x,omitempty"` - Y int `yaml:"y,omitempty" json:"y,omitempty"` + Y int `json:"y,omitempty"` // from_x / from_y — drag start coordinates. - FromX int `yaml:"from_x,omitempty" json:"from_x,omitempty"` + FromX int `json:"from_x,omitempty"` - FromY int `yaml:"from_y,omitempty" json:"from_y,omitempty"` + FromY int `json:"from_y,omitempty"` // button — pointer button (left/right/middle; default left). - Button string `yaml:"button,omitempty" json:"button,omitempty"` + Button string `json:"button,omitempty"` // direction / amount — scroll direction (up/down/left/right) and notches. - Direction string `yaml:"direction,omitempty" json:"direction,omitempty"` + Direction string `json:"direction,omitempty"` - Amount int `yaml:"amount,omitempty" json:"amount,omitempty"` + Amount int `json:"amount,omitempty"` // pid / window_id — target a specific window for element/typed actions. - PID int `yaml:"pid,omitempty" json:"pid,omitempty"` + PID int `json:"pid,omitempty"` - WindowID int `yaml:"window_id,omitempty" json:"window_id,omitempty"` + WindowID int `json:"window_id,omitempty"` // element_index — the AX element index from a prior get-window-state. - ElementIndex int `yaml:"element_index,omitempty" json:"element_index,omitempty"` + ElementIndex int `json:"element_index,omitempty"` // delivery — the Cua delivery mode for an input action: background (default) // or foreground. Foreground briefly fronts the window; it is the documented // escalation when a background action is refused or unverifiable. - Delivery string `yaml:"delivery,omitempty" json:"delivery,omitempty"` + Delivery string `json:"delivery,omitempty"` // --- app / window ----------------------------------------------------- // app — the app name/bundle/launch-path for launch-app. - App string `yaml:"app,omitempty" json:"app,omitempty"` + App string `json:"app,omitempty"` // window_id / value — the window a set-window-frame targets, and its value. - Value string `yaml:"value,omitempty" json:"value,omitempty"` + Value string `json:"value,omitempty"` // width / height — rectangle size for set-window-frame. - Width int `yaml:"width,omitempty" json:"width,omitempty"` + Width int `json:"width,omitempty"` - Height int `yaml:"height,omitempty" json:"height,omitempty"` + Height int `json:"height,omitempty"` // --- raw escape hatch ------------------------------------------------- // tool — the Cua Driver tool `call` invokes (any tool the plugin has not typed // yet). Mutating by nature, so `call` requires allow_control. - Tool string `yaml:"tool,omitempty" json:"tool,omitempty"` + Tool string `json:"tool,omitempty"` // args — the JSON object of arguments for `call`. - Args string `yaml:"args,omitempty" json:"args,omitempty"` + Args string `json:"args,omitempty"` // --- artifact --------------------------------------------------------- // artifact — the host path `screenshot` writes the PNG to. - Artifact string `yaml:"artifact,omitempty" json:"artifact,omitempty"` + Artifact string `json:"artifact,omitempty"` // artifact_dir — the runner-injected generic evidence-artifact dir. - ArtifactDir string `yaml:"artifact_dir,omitempty" json:"artifact_dir,omitempty"` + ArtifactDir string `json:"artifact_dir,omitempty"` // artifact_min_bytes / artifact_min_dimensions / artifact_not_uniform / // artifact_contains_text — the post-run artifact-reality assertions // (sdk.RunArtifactValidators) this verb can produce. artifact_contains_text is // the OCR wait-for-screen primitive in `screenshot` form (tesseract on the HOST). - ArtifactMinBytes int `yaml:"artifact_min_bytes,omitempty" json:"artifact_min_bytes,omitempty"` + ArtifactMinBytes int `json:"artifact_min_bytes,omitempty"` - ArtifactMinDimensions string `yaml:"artifact_min_dimensions,omitempty" json:"artifact_min_dimensions,omitempty"` + ArtifactMinDimensions string `json:"artifact_min_dimensions,omitempty"` - ArtifactNotUniform bool `yaml:"artifact_not_uniform,omitempty" json:"artifact_not_uniform,omitempty"` + ArtifactNotUniform bool `json:"artifact_not_uniform,omitempty"` - ArtifactContainsText string `yaml:"artifact_contains_text,omitempty" json:"artifact_contains_text,omitempty"` + ArtifactContainsText string `json:"artifact_contains_text,omitempty"` } // #CuaMethod — the method catalog, grouped by intent so the read-only surface is @@ -147,25 +147,25 @@ type CuaMethod string // bed states them once. type CuaDeviceInput struct { // description — human label for the deployment/device. - Description string `yaml:"description,omitempty" json:"description,omitempty"` + Description string `json:"description,omitempty"` // driver — the `cua-driver` binary path inside the venue. - Driver string `yaml:"driver,omitempty" json:"driver,omitempty"` + Driver string `json:"driver,omitempty"` // host — the deployment/venue address (omit for "the deploy I run against"). - Host string `yaml:"host,omitempty" json:"host,omitempty"` + Host string `json:"host,omitempty"` // permission_mode — Cua Driver's own authorization mode. `standard` is the // promptless default; `bounded` admits only a reviewed manifest; `unrestricted` // requires an explicit danger acknowledgement. The plugin passes the mode to the // driver's launch, never overrides a running daemon's policy. - PermissionMode string `yaml:"permission_mode,omitempty" json:"permission_mode,omitempty"` + PermissionMode string `json:"permission_mode,omitempty"` // capability_manifest — the reviewed manifest path for `bounded` mode. - CapabilityManifest string `yaml:"capability_manifest,omitempty" json:"capability_manifest,omitempty"` + CapabilityManifest string `json:"capability_manifest,omitempty"` // env — extra session environment the driver needs inside the venue (the // desktop-session variables: XDG_RUNTIME_DIR, WAYLAND_DISPLAY, // HYPRLAND_INSTANCE_SIGNATURE, DBUS_SESSION_BUS_ADDRESS, YDOTOOL_SOCKET). - Env map[string]string `yaml:"env,omitempty" json:"env,omitempty"` + Env map[string]string `json:"env,omitempty"` } diff --git a/candy/plugin-cua/params/decode_test.go b/candy/plugin-cua/params/decode_test.go new file mode 100644 index 0000000..bed15fc --- /dev/null +++ b/candy/plugin-cua/params/decode_test.go @@ -0,0 +1,133 @@ +package params + +// decode_test.go — pins each generated projection against its SOURCE OF TRUTH, in both directions. +// +// charly hands a plugin its parameters as JSON (`spec.Op`'s params_json) and the plugin decodes +// that JSON into these projections; the sdk's kit.DecodeInput re-marshals to JSON before it +// unmarshals too. No YAML decoder ever sees these types — which is why the projections' `yaml:` +// tags are inert, and why this test pins the JSON side rather than asserting that claim in prose. +// +// The expected keys are copied from the `#…` definitions in `candy/plugin-cua/schema/*.cue` — the +// schema that GENERATES this file. They are hardcoded on purpose: deriving them from a struct's own +// `json` tags (the obvious thing) makes the test vacuous, because a tag that drifts takes its +// derived expectation with it and the decode still lines up. + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" +) + +// cueInputKeys is the field set of #CuaInput in candy/plugin-cua/schema. +var cueInputKeys = []string{ + "allow_control", "amount", "app", "args", "artifact", "artifact_contains_text", + "artifact_dir", "artifact_min_bytes", "artifact_min_dimensions", "artifact_not_uniform", + "button", "delivery", "device", "direction", "driver", "element_index", "env", "from_x", + "from_y", "height", "host", "key", "keys", "method", "pid", "text", "tool", "value", + "width", "window_id", "x", "y", +} + +// cueDeviceKeys is the field set of #CuaDeviceInput — the OTHER generated struct this diff +// touches, so it carries its own coverage rather than riding on CuaInput's. +var cueDeviceKeys = []string{ + "capability_manifest", "description", "driver", "env", "host", "permission_mode", +} + +func TestCuaInputMatchesItsSchema(t *testing.T) { + checkProjection(t, reflect.TypeOf(CuaInput{}), cueInputKeys, "#CuaInput") +} + +func TestCuaDeviceInputMatchesItsSchema(t *testing.T) { + checkProjection(t, reflect.TypeOf(CuaDeviceInput{}), cueDeviceKeys, "#CuaDeviceInput") +} + +// checkProjection asserts BOTH directions between one generated struct and the schema definition +// that produced it, then decodes a wire map through it. +func checkProjection(t *testing.T, rt reflect.Type, schemaKeys []string, defName string) { + t.Helper() + ptr := reflect.New(rt) + + // Direction 1: STRUCT -> SCHEMA. Every exported field must carry a json key the schema + // declares; a drifted, emptied or removed tag is caught here. + wire := map[string]any{} + exported := 0 + tagOf := map[string]string{} // json key -> Go field name + for i := 0; i < rt.NumField(); i++ { + f := rt.Field(i) + if f.PkgPath != "" { + continue // unexported + } + exported++ + key := strings.Split(f.Tag.Get("json"), ",")[0] + if key == "" || key == "-" { + t.Fatalf("%s: field %s carries no usable json tag (%q) — no schema key can reach it", defName, f.Name, key) + } + if !containsKey(schemaKeys, key) { + t.Fatalf("%s: field %s is tagged json:%q, which candy/plugin-cua/schema does NOT declare — the projection has drifted from its source of truth", defName, f.Name, key) + } + tagOf[key] = f.Name + switch f.Type.Kind() { + case reflect.String: + wire[key] = "probe" + case reflect.Bool: + wire[key] = true + case reflect.Int, reflect.Int64: + wire[key] = 7 + case reflect.Float64: + wire[key] = 1.5 + case reflect.Slice: + wire[key] = []any{"probe"} + case reflect.Map: + wire[key] = map[string]any{"k": "v"} + default: + // nested structs/pointers/interfaces are not filled by a flat wire map + continue + } + } + if exported == 0 { + t.Fatalf("%s: the projection has no exported fields — the test would prove nothing", defName) + } + + // Direction 2: SCHEMA -> STRUCT. Every key the schema declares must be carried by a field; a + // key the projection dropped is caught here, checked against schemaKeys directly rather than + // against anything derived from the tags. + for _, key := range schemaKeys { + if _, ok := tagOf[key]; !ok { + t.Fatalf("%s: schema key %q is declared in candy/plugin-cua/schema but NO exported field carries it — the projection is missing part of its schema", defName, key) + } + } + + b, err := json.Marshal(wire) + if err != nil { + t.Fatalf("%s: marshal the wire map: %v", defName, err) + } + // THE PATH: json -> struct, exactly `json.Unmarshal(req.GetParamsJson(), &op)`. + if err := json.Unmarshal(b, ptr.Interface()); err != nil { + t.Fatalf("%s: decode the wire map into the projection: %v", defName, err) + } + elem := ptr.Elem() + missing := []string{} + for key := range wire { + for i := 0; i < rt.NumField(); i++ { + f := rt.Field(i) + if f.PkgPath == "" && strings.Split(f.Tag.Get("json"), ",")[0] == key && elem.Field(i).IsZero() { + missing = append(missing, fmt.Sprintf("%s (field %s)", key, f.Name)) + } + } + } + if len(missing) > 0 { + t.Fatalf("%s: %d wire key(s) were not delivered into the projection: %v", defName, len(missing), missing) + } + t.Logf("%s: both directions hold — %d exported field(s), %d schema key(s), %d decoded", defName, exported, len(schemaKeys), len(wire)) +} + +func containsKey(hay []string, needle string) bool { + for _, h := range hay { + if h == needle { + return true + } + } + return false +}