| 1 | package browser |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "regexp" |
| 8 | "sort" |
| 9 | "strings" |
| 10 | ) |
| 11 | |
| 12 | const operationIDPattern = `^[A-Za-z0-9_-]{1,100}$` |
| 13 | |
| 14 | var operationIDRe = regexp.MustCompile(operationIDPattern) |
| 15 | |
| 16 | // property is one schema entry. Keywords live in a map so json.Marshal emits |
| 17 | // them in sorted order, which is exactly provider.CanonicalizeSchema's form. |
| 18 | type property struct { |
| 19 | name string |
| 20 | schema map[string]any |
| 21 | } |
| 22 | |
| 23 | func str(name, desc string) property { |
| 24 | return property{name, map[string]any{"type": "string", "description": desc}} |
| 25 | } |
| 26 | |
| 27 | func boolean(name, desc string) property { |
| 28 | return property{name, map[string]any{"type": "boolean", "description": desc}} |
| 29 | } |
| 30 | |
| 31 | func integer(name, desc string) property { |
| 32 | return property{name, map[string]any{"type": "integer", "description": desc}} |
| 33 | } |
| 34 | |
| 35 | func bounded(p property, minimum, maximum int) property { |
| 36 | p.schema["minimum"] = minimum |
| 37 | p.schema["maximum"] = maximum |
| 38 | return p |
| 39 | } |
| 40 | |
| 41 | func strList(name, desc string) property { |
| 42 | return property{name, map[string]any{"type": "array", "description": desc, "items": map[string]any{"type": "string"}, "minItems": 1}} |
| 43 | } |
| 44 | |
| 45 | func enum(name, desc string, values ...string) property { |
| 46 | vals := make([]any, len(values)) |
| 47 | for i, v := range values { |
| 48 | vals[i] = v |
| 49 | } |
| 50 | return property{name, map[string]any{"type": "string", "description": desc, "enum": vals}} |
| 51 | } |
| 52 | |
| 53 | func pattern(p property, re string) property { |
| 54 | p.schema["pattern"] = re |
| 55 | return p |
| 56 | } |
| 57 | |
| 58 | // objectSchema marshals a closed object schema whose bytes already equal the |
| 59 | // canonical form: map keys sort under json.Marshal and required sorts here. |
| 60 | func objectSchema(required []string, props ...property) json.RawMessage { |
| 61 | properties := make(map[string]any, len(props)) |
| 62 | for _, p := range props { |
| 63 | properties[p.name] = p.schema |
| 64 | } |
| 65 | req := append([]string{}, required...) |
| 66 | sort.Strings(req) |
| 67 | b, err := json.Marshal(map[string]any{ |
| 68 | "type": "object", |
| 69 | "additionalProperties": false, |
| 70 | "properties": properties, |
| 71 | "required": req, |
| 72 | }) |
| 73 | if err != nil { |
| 74 | panic("browser: static schema failed to marshal: " + err.Error()) |
| 75 | } |
| 76 | return b |
| 77 | } |
| 78 | |
| 79 | func tabIDProp() property { |
| 80 | return str("tabId", "ID of the tab, as returned by browser_tabs or browser_open.") |
| 81 | } |
| 82 | |
| 83 | func operationIDProp() property { |
| 84 | return pattern(str("operationId", "Unique ID for this attempt: letters, digits, '_' or '-', at most 100 characters. Mint a fresh one for every write call and never reuse it. A reused ID is rejected, and an ID whose outcome came back unknown must not be retried."), operationIDPattern) |
| 85 | } |
| 86 | |
| 87 | func documentTokenProp() property { |
| 88 | return str("documentToken", "documentToken from the browser_snapshot this action was planned against. A navigation, page replacement, or user take-over invalidates it; when the call is blocked as stale, take a new snapshot instead of guessing.") |
| 89 | } |
| 90 | |
| 91 | func refProp(desc string) property { return str("ref", desc) } |
| 92 | |
| 93 | // decode parses args into dst, rejecting fields the schema does not declare |
| 94 | // so additionalProperties:false holds at runtime as well as on paper. |
| 95 | func decode(args json.RawMessage, dst any) error { |
| 96 | if len(bytes.TrimSpace(args)) == 0 { |
| 97 | args = json.RawMessage(`{}`) |
| 98 | } |
| 99 | dec := json.NewDecoder(bytes.NewReader(args)) |
| 100 | dec.DisallowUnknownFields() |
| 101 | if err := dec.Decode(dst); err != nil { |
| 102 | return fmt.Errorf("invalid args: %w", err) |
| 103 | } |
| 104 | return nil |
| 105 | } |
| 106 | |
| 107 | func requireTab(id string) error { |
| 108 | if strings.TrimSpace(id) == "" { |
| 109 | return fmt.Errorf("tabId is required; call browser_tabs to find one") |
| 110 | } |
| 111 | return nil |
| 112 | } |
| 113 | |
| 114 | func requireOperationID(id string) error { |
| 115 | if !operationIDRe.MatchString(id) { |
| 116 | return fmt.Errorf("operationId must match %s; mint a fresh one per attempt", operationIDPattern) |
| 117 | } |
| 118 | return nil |
| 119 | } |
| 120 | |
| 121 | func requireDocumentToken(token string) error { |
| 122 | if strings.TrimSpace(token) == "" { |
| 123 | return fmt.Errorf("documentToken is required; take a browser_snapshot and pass its documentToken") |
| 124 | } |
| 125 | return nil |
| 126 | } |
| 127 | |
| 128 | func requireRef(ref string) error { |
| 129 | if strings.TrimSpace(ref) == "" { |
| 130 | return fmt.Errorf("ref is required; use an element ref from browser_snapshot") |
| 131 | } |
| 132 | return nil |
| 133 | } |
| 134 | |
| 135 | func requireStrings(name string, values []string) error { |
| 136 | if len(values) == 0 { |
| 137 | return fmt.Errorf("%s must list at least one entry", name) |
| 138 | } |
| 139 | for _, v := range values { |
| 140 | if strings.TrimSpace(v) == "" { |
| 141 | return fmt.Errorf("%s must not contain empty entries", name) |
| 142 | } |
| 143 | } |
| 144 | return nil |
| 145 | } |
| 146 |