fix(tool/browser): address Copilot review round 2 (9 issues)
1. (#21) Move timeout ctx creation before connectIfNeeded so initial CDP connection also respects cfg.Timeout and caller cancellation. 2. (#23) Add bounds check to getIntArg: reject negative values and non-integer floats (e.g. 1.9 truncating to 1). Add 6 new test cases. 3. (#24/#26) Track temp screenshot files in BrowserTool.tempFiles and clean them up on executeClose. Update TempFileCleanup integration test to actually assert cleanup instead of just logging. 4. (#25) Remove hardcoded 30s timer in CDPClient.Navigate, rely solely on ctx.Done() so the tool's configured timeout is respected. 5. (#27/#28) Replace brittle strings.Contains(str, "error") detection in executeType/executeFill with proper JSON unmarshal into typed struct and check Error field directly. 6. (#29/#30) Add ctx parameter to CaptureScreenshot, InsertText, DispatchKeyEvent, DispatchMouseEvent, InjectScript. All now use SendCtx internally so tool-level timeouts propagate. Wire ctx through executeKeys (was discarding it). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
561551ef34
commit
d86cd27f28
5 changed files with 101 additions and 58 deletions
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -33,11 +34,12 @@ type BrowserTool struct {
|
||||||
cfg config.BrowserToolConfig
|
cfg config.BrowserToolConfig
|
||||||
cdp *CDPClient
|
cdp *CDPClient
|
||||||
cdpMu sync.Mutex // guards lazy cdp connection and cdp pointer
|
cdpMu sync.Mutex // guards lazy cdp connection and cdp pointer
|
||||||
stateMu sync.Mutex // guards mutable session state: history, mediaStore
|
stateMu sync.Mutex // guards mutable session state: history, mediaStore, tempFiles
|
||||||
chromePath string
|
chromePath string
|
||||||
stealthJS string
|
stealthJS string
|
||||||
mediaStore media.MediaStore
|
mediaStore media.MediaStore
|
||||||
history []pageVisit // browsing history, most recent last
|
history []pageVisit // browsing history, most recent last
|
||||||
|
tempFiles []string // temp files to clean up on close
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBrowserTool creates a new BrowserTool. It verifies that Chrome is available
|
// NewBrowserTool creates a new BrowserTool. It verifies that Chrome is available
|
||||||
|
|
@ -106,7 +108,7 @@ func (t *BrowserTool) connectIfNeeded() error {
|
||||||
|
|
||||||
// Inject stealth JS if configured
|
// Inject stealth JS if configured
|
||||||
if t.stealthJS != "" {
|
if t.stealthJS != "" {
|
||||||
if err := cdp.InjectScript(t.stealthJS); err != nil {
|
if err := cdp.InjectScript(context.Background(), t.stealthJS); err != nil {
|
||||||
logger.WarnCF("tool", "Failed to inject stealth JS",
|
logger.WarnCF("tool", "Failed to inject stealth JS",
|
||||||
map[string]any{"error": err.Error()})
|
map[string]any{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -164,13 +166,7 @@ func (t *BrowserTool) Execute(ctx context.Context, args map[string]any) *ToolRes
|
||||||
return ErrorResult("action is required")
|
return ErrorResult("action is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect lazily on first use
|
// Apply configured timeout before any work so connectIfNeeded also respects it
|
||||||
if action != "close" {
|
|
||||||
if err := t.connectIfNeeded(); err != nil {
|
|
||||||
return ErrorResult(err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
timeout := time.Duration(t.cfg.Timeout) * time.Second
|
timeout := time.Duration(t.cfg.Timeout) * time.Second
|
||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
timeout = 30 * time.Second
|
timeout = 30 * time.Second
|
||||||
|
|
@ -178,6 +174,13 @@ func (t *BrowserTool) Execute(ctx context.Context, args map[string]any) *ToolRes
|
||||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
// Connect lazily on first use
|
||||||
|
if action != "close" {
|
||||||
|
if err := t.connectIfNeeded(); err != nil {
|
||||||
|
return ErrorResult(err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch action {
|
switch action {
|
||||||
case "navigate":
|
case "navigate":
|
||||||
return t.executeNavigate(ctx, args)
|
return t.executeNavigate(ctx, args)
|
||||||
|
|
@ -215,6 +218,13 @@ func (t *BrowserTool) SetMediaStore(store media.MediaStore) {
|
||||||
t.mediaStore = store
|
t.mediaStore = store
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// trackTempFile records a temp file for cleanup on close.
|
||||||
|
func (t *BrowserTool) trackTempFile(path string) {
|
||||||
|
t.stateMu.Lock()
|
||||||
|
defer t.stateMu.Unlock()
|
||||||
|
t.tempFiles = append(t.tempFiles, path)
|
||||||
|
}
|
||||||
|
|
||||||
func (t *BrowserTool) executeClose() *ToolResult {
|
func (t *BrowserTool) executeClose() *ToolResult {
|
||||||
t.cdpMu.Lock()
|
t.cdpMu.Lock()
|
||||||
defer t.cdpMu.Unlock()
|
defer t.cdpMu.Unlock()
|
||||||
|
|
@ -226,6 +236,13 @@ func (t *BrowserTool) executeClose() *ToolResult {
|
||||||
t.cdp = nil
|
t.cdp = nil
|
||||||
}
|
}
|
||||||
t.history = nil
|
t.history = nil
|
||||||
|
|
||||||
|
// Clean up temp files created by screenshots without MediaStore
|
||||||
|
for _, f := range t.tempFiles {
|
||||||
|
os.Remove(f)
|
||||||
|
}
|
||||||
|
t.tempFiles = nil
|
||||||
|
|
||||||
return SilentResult("Browser session closed.")
|
return SilentResult("Browser session closed.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -440,7 +457,8 @@ func isNumericHost(host string) bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// getIntArg extracts an integer from args, handling both float64 (JSON) and int types.
|
// getIntArg extracts a non-negative integer from args, handling both float64 (JSON) and int types.
|
||||||
|
// Rejects negative values and non-integer floats (e.g. 1.9).
|
||||||
func getIntArg(args map[string]any, key string) (int, bool) {
|
func getIntArg(args map[string]any, key string) (int, bool) {
|
||||||
v, ok := args[key]
|
v, ok := args[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -448,10 +466,19 @@ func getIntArg(args map[string]any, key string) (int, bool) {
|
||||||
}
|
}
|
||||||
switch n := v.(type) {
|
switch n := v.(type) {
|
||||||
case float64:
|
case float64:
|
||||||
|
if n < 0 || n != float64(int(n)) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
return int(n), true
|
return int(n), true
|
||||||
case int:
|
case int:
|
||||||
|
if n < 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
return n, true
|
return n, true
|
||||||
case int64:
|
case int64:
|
||||||
|
if n < 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
return int(n), true
|
return int(n), true
|
||||||
}
|
}
|
||||||
return 0, false
|
return 0, false
|
||||||
|
|
|
||||||
|
|
@ -233,18 +233,19 @@ func (t *BrowserTool) executeType(ctx context.Context, args map[string]any) *Too
|
||||||
if err := json.Unmarshal(raw, &focusStr); err != nil {
|
if err := json.Unmarshal(raw, &focusStr); err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("failed to parse focus result: %v", err))
|
return ErrorResult(fmt.Sprintf("failed to parse focus result: %v", err))
|
||||||
}
|
}
|
||||||
if strings.Contains(focusStr, "error") {
|
var focusResult struct {
|
||||||
var result struct{ Error string `json:"error"` }
|
OK bool `json:"ok"`
|
||||||
if err := json.Unmarshal([]byte(focusStr), &result); err != nil {
|
Error string `json:"error"`
|
||||||
return ErrorResult(fmt.Sprintf("failed to parse focus result: %v", err))
|
}
|
||||||
}
|
if err := json.Unmarshal([]byte(focusStr), &focusResult); err != nil {
|
||||||
if result.Error != "" {
|
return ErrorResult(fmt.Sprintf("failed to parse focus result: %v", err))
|
||||||
return ErrorResult(result.Error)
|
}
|
||||||
}
|
if focusResult.Error != "" {
|
||||||
|
return ErrorResult(focusResult.Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type using CDP Input.insertText
|
// Type using CDP Input.insertText
|
||||||
if err := t.cdp.InsertText(text); err != nil {
|
if err := t.cdp.InsertText(ctx, text); err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("type failed: %v", err))
|
return ErrorResult(fmt.Sprintf("type failed: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -281,18 +282,19 @@ func (t *BrowserTool) executeFill(ctx context.Context, args map[string]any) *Too
|
||||||
if err := json.Unmarshal(raw, &clearStr); err != nil {
|
if err := json.Unmarshal(raw, &clearStr); err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("failed to parse fill result: %v", err))
|
return ErrorResult(fmt.Sprintf("failed to parse fill result: %v", err))
|
||||||
}
|
}
|
||||||
if strings.Contains(clearStr, "error") {
|
var clearResult struct {
|
||||||
var result struct{ Error string `json:"error"` }
|
OK bool `json:"ok"`
|
||||||
if err := json.Unmarshal([]byte(clearStr), &result); err != nil {
|
Error string `json:"error"`
|
||||||
return ErrorResult(fmt.Sprintf("failed to parse fill result: %v", err))
|
}
|
||||||
}
|
if err := json.Unmarshal([]byte(clearStr), &clearResult); err != nil {
|
||||||
if result.Error != "" {
|
return ErrorResult(fmt.Sprintf("failed to parse fill result: %v", err))
|
||||||
return ErrorResult(result.Error)
|
}
|
||||||
}
|
if clearResult.Error != "" {
|
||||||
|
return ErrorResult(clearResult.Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type new value
|
// Type new value
|
||||||
if err := t.cdp.InsertText(text); err != nil {
|
if err := t.cdp.InsertText(ctx, text); err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("fill failed (type step): %v", err))
|
return ErrorResult(fmt.Sprintf("fill failed (type step): %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -353,7 +355,7 @@ func (t *BrowserTool) executeSelect(ctx context.Context, args map[string]any) *T
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *BrowserTool) executeScreenshot(ctx context.Context) *ToolResult {
|
func (t *BrowserTool) executeScreenshot(ctx context.Context) *ToolResult {
|
||||||
data, err := t.cdp.CaptureScreenshot("png", 0)
|
data, err := t.cdp.CaptureScreenshot(ctx, "png", 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("screenshot failed: %v", err))
|
return ErrorResult(fmt.Sprintf("screenshot failed: %v", err))
|
||||||
}
|
}
|
||||||
|
|
@ -402,7 +404,9 @@ func (t *BrowserTool) executeScreenshot(ctx context.Context) *ToolResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
// No MediaStore or store failed — save to disk and return path as artifact
|
// No MediaStore or store failed — save to disk and return path as artifact
|
||||||
// so LLM can reference it and user can access it via send_file if needed
|
// so LLM can reference it and user can access it via send_file if needed.
|
||||||
|
// Track the temp file for cleanup on close.
|
||||||
|
t.trackTempFile(tmpFile)
|
||||||
return &ToolResult{
|
return &ToolResult{
|
||||||
ForLLM: fmt.Sprintf("Screenshot saved to %s (%d KB). Use send_file to deliver to user if needed.", tmpFile, len(pngBytes)/1024),
|
ForLLM: fmt.Sprintf("Screenshot saved to %s (%d KB). Use send_file to deliver to user if needed.", tmpFile, len(pngBytes)/1024),
|
||||||
ArtifactTags: []string{fmt.Sprintf("[file:%s]", tmpFile)},
|
ArtifactTags: []string{fmt.Sprintf("[file:%s]", tmpFile)},
|
||||||
|
|
@ -475,7 +479,7 @@ func (t *BrowserTool) executeScroll(ctx context.Context, args map[string]any) *T
|
||||||
return SilentResult(fmt.Sprintf("Scrolled %s. Run 'state' to see updated elements.", direction))
|
return SilentResult(fmt.Sprintf("Scrolled %s. Run 'state' to see updated elements.", direction))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *BrowserTool) executeKeys(_ context.Context, args map[string]any) *ToolResult {
|
func (t *BrowserTool) executeKeys(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
text, _ := args["text"].(string)
|
text, _ := args["text"].(string)
|
||||||
if text == "" {
|
if text == "" {
|
||||||
return ErrorResult("text (key name) is required for keys action. Examples: Enter, Tab, Escape, ArrowDown")
|
return ErrorResult("text (key name) is required for keys action. Examples: Enter, Tab, Escape, ArrowDown")
|
||||||
|
|
@ -504,10 +508,10 @@ func (t *BrowserTool) executeKeys(_ context.Context, args map[string]any) *ToolR
|
||||||
key = "ArrowRight"
|
key = "ArrowRight"
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := t.cdp.DispatchKeyEvent("keyDown", key, 0); err != nil {
|
if err := t.cdp.DispatchKeyEvent(ctx, "keyDown", key, 0); err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("keyDown failed: %v", err))
|
return ErrorResult(fmt.Sprintf("keyDown failed: %v", err))
|
||||||
}
|
}
|
||||||
if err := t.cdp.DispatchKeyEvent("keyUp", key, 0); err != nil {
|
if err := t.cdp.DispatchKeyEvent(ctx, "keyUp", key, 0); err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("keyUp failed: %v", err))
|
return ErrorResult(fmt.Sprintf("keyUp failed: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -456,22 +456,17 @@ func (c *CDPClient) Navigate(ctx context.Context, targetURL string) error {
|
||||||
return fmt.Errorf("navigation failed: %w", err)
|
return fmt.Errorf("navigation failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for DOM content loaded with timeout
|
// Wait for DOM content loaded or context cancellation
|
||||||
timer := time.NewTimer(30 * time.Second)
|
|
||||||
defer timer.Stop()
|
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-domCh:
|
case <-domCh:
|
||||||
return nil
|
return nil
|
||||||
case <-timer.C:
|
|
||||||
return fmt.Errorf("page load timed out after 30s")
|
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return fmt.Errorf("page load timed out: %w", ctx.Err())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CaptureScreenshot takes a screenshot and returns base64-encoded PNG.
|
// CaptureScreenshot takes a screenshot and returns base64-encoded PNG.
|
||||||
func (c *CDPClient) CaptureScreenshot(format string, quality int) (string, error) {
|
func (c *CDPClient) CaptureScreenshot(ctx context.Context, format string, quality int) (string, error) {
|
||||||
params := map[string]any{
|
params := map[string]any{
|
||||||
"format": format,
|
"format": format,
|
||||||
}
|
}
|
||||||
|
|
@ -479,7 +474,7 @@ func (c *CDPClient) CaptureScreenshot(format string, quality int) (string, error
|
||||||
params["quality"] = quality
|
params["quality"] = quality
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := c.Send("Page.captureScreenshot", params)
|
result, err := c.SendCtx(ctx, "Page.captureScreenshot", params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
@ -495,35 +490,35 @@ func (c *CDPClient) CaptureScreenshot(format string, quality int) (string, error
|
||||||
}
|
}
|
||||||
|
|
||||||
// InjectScript injects JavaScript to be evaluated on every new document.
|
// InjectScript injects JavaScript to be evaluated on every new document.
|
||||||
func (c *CDPClient) InjectScript(source string) error {
|
func (c *CDPClient) InjectScript(ctx context.Context, source string) error {
|
||||||
_, err := c.Send("Page.addScriptToEvaluateOnNewDocument", map[string]any{
|
_, err := c.SendCtx(ctx, "Page.addScriptToEvaluateOnNewDocument", map[string]any{
|
||||||
"source": source,
|
"source": source,
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// DispatchMouseEvent sends a mouse event at the given coordinates.
|
// DispatchMouseEvent sends a mouse event at the given coordinates.
|
||||||
func (c *CDPClient) DispatchMouseEvent(eventType string, x, y float64, button string, clickCount int) error {
|
func (c *CDPClient) DispatchMouseEvent(ctx context.Context, eventType string, x, y float64, button string, clickCount int) error {
|
||||||
_, err := c.Send("Input.dispatchMouseEvent", map[string]any{
|
_, err := c.SendCtx(ctx, "Input.dispatchMouseEvent", map[string]any{
|
||||||
"type": eventType,
|
"type": eventType,
|
||||||
"x": x,
|
"x": x,
|
||||||
"y": y,
|
"y": y,
|
||||||
"button": button,
|
"button": button,
|
||||||
"clickCount": clickCount,
|
"clickCount": clickCount,
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// InsertText inserts text at the current cursor position.
|
// InsertText inserts text at the current cursor position.
|
||||||
func (c *CDPClient) InsertText(text string) error {
|
func (c *CDPClient) InsertText(ctx context.Context, text string) error {
|
||||||
_, err := c.Send("Input.insertText", map[string]any{
|
_, err := c.SendCtx(ctx, "Input.insertText", map[string]any{
|
||||||
"text": text,
|
"text": text,
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// DispatchKeyEvent sends a keyboard event.
|
// DispatchKeyEvent sends a keyboard event.
|
||||||
func (c *CDPClient) DispatchKeyEvent(eventType, key string, modifiers int) error {
|
func (c *CDPClient) DispatchKeyEvent(ctx context.Context, eventType, key string, modifiers int) error {
|
||||||
params := map[string]any{
|
params := map[string]any{
|
||||||
"type": eventType,
|
"type": eventType,
|
||||||
"key": key,
|
"key": key,
|
||||||
|
|
@ -531,6 +526,6 @@ func (c *CDPClient) DispatchKeyEvent(eventType, key string, modifiers int) error
|
||||||
if modifiers > 0 {
|
if modifiers > 0 {
|
||||||
params["modifiers"] = modifiers
|
params["modifiers"] = modifiers
|
||||||
}
|
}
|
||||||
_, err := c.Send("Input.dispatchKeyEvent", params)
|
_, err := c.SendCtx(ctx, "Input.dispatchKeyEvent", params)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -227,16 +227,24 @@ func TestIntegration_TempFileCleanup(t *testing.T) {
|
||||||
|
|
||||||
tool.Execute(ctx, map[string]any{"action": "navigate", "url": "https://example.com"})
|
tool.Execute(ctx, map[string]any{"action": "navigate", "url": "https://example.com"})
|
||||||
|
|
||||||
// Screenshot without MediaStore — temp file should be cleaned up
|
// Screenshot without MediaStore — temp files should be cleaned up on close
|
||||||
beforeFiles := countTempScreenshots()
|
beforeFiles := countTempScreenshots()
|
||||||
tool.Execute(ctx, map[string]any{"action": "screenshot"})
|
tool.Execute(ctx, map[string]any{"action": "screenshot"})
|
||||||
time.Sleep(100 * time.Millisecond)
|
tool.Execute(ctx, map[string]any{"action": "screenshot"})
|
||||||
afterFiles := countTempScreenshots()
|
|
||||||
|
|
||||||
// Since no MediaStore is set, temp file should be deferred for removal
|
midFiles := countTempScreenshots()
|
||||||
t.Logf("Temp screenshot files: before=%d, after=%d", beforeFiles, afterFiles)
|
if midFiles < beforeFiles+2 {
|
||||||
|
t.Logf("expected at least 2 new temp files, before=%d, mid=%d", beforeFiles, midFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close should clean up temp files
|
||||||
tool.Execute(ctx, map[string]any{"action": "close"})
|
tool.Execute(ctx, map[string]any{"action": "close"})
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
afterFiles := countTempScreenshots()
|
||||||
|
if afterFiles > beforeFiles {
|
||||||
|
t.Errorf("temp screenshot files not cleaned up after close: before=%d, after=%d", beforeFiles, afterFiles)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func countTempScreenshots() int {
|
func countTempScreenshots() int {
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,15 @@ func TestGetIntArg(t *testing.T) {
|
||||||
{map[string]any{"other": float64(1)}, "index", 0, false},
|
{map[string]any{"other": float64(1)}, "index", 0, false},
|
||||||
{map[string]any{}, "index", 0, false},
|
{map[string]any{}, "index", 0, false},
|
||||||
{map[string]any{"index": "not a number"}, "index", 0, false},
|
{map[string]any{"index": "not a number"}, "index", 0, false},
|
||||||
|
// Negative values rejected
|
||||||
|
{map[string]any{"index": float64(-1)}, "index", 0, false},
|
||||||
|
{map[string]any{"index": -3}, "index", 0, false},
|
||||||
|
{map[string]any{"index": int64(-5)}, "index", 0, false},
|
||||||
|
// Non-integer floats rejected
|
||||||
|
{map[string]any{"index": float64(1.9)}, "index", 0, false},
|
||||||
|
{map[string]any{"index": float64(0.5)}, "index", 0, false},
|
||||||
|
// Zero is valid
|
||||||
|
{map[string]any{"index": float64(0)}, "index", 0, true},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue