Files
zero/internal/localcontrol/browser_test.go
Anandan f1502f0bfe Add local control browser and terminal tools (#319)
* Add local control browser and terminal tools

Add configurable local browser, desktop, and terminal control helpers with packaged helper discovery for install and release flows.

Improve sandbox scope classification for local control commands and shared temporary paths.

Add structured browser actions, terminal session line input/key normalization/read support, and artifact capture for local-control outputs.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/localcontrol ./internal/tools ./internal/cli ./internal/config -run 'Test.*LocalControl|TestTerminal|TestBrowser|TestDesktop|TestCapture'

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/localcontrol ./internal/tools ./internal/cli ./internal/config ./internal/agent

Tested: tuistory smoke launched bash, sent interactive input, waited for expected output, and captured a no-cursor snapshot

Tested: git diff --cached --check

* Fix npm wrapper helper manifest test on macOS

Canonicalize expected helper paths in the npm wrapper manifest test so macOS /var and /private/var temp path aliases compare correctly.

Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/npmwrapper

Tested: git diff --check

* Harden local control review fixes

Respect resolved local-control config before tool listing and filter validation while preserving stream-json usage error ordering.

Harden browser, desktop, and artifact tool validation; guard Linux-only app launch behavior; clear stale helper manifests from the npm wrapper.

Make helper packaging lockfile-based with npm ci and add installer/package tests for helper staging.

Tested: focused local-control/tool/CLI/release/install/sandbox suite

Tested: isolated full go test ./...

Tested: macOS and Windows compile-only checks for touched packages
2026-06-26 08:22:56 +05:30

238 lines
7.1 KiB
Go

package localcontrol
import (
"context"
"encoding/json"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
"time"
)
type fakeCommandRunner struct {
path string
args []string
env []string
}
func (runner *fakeCommandRunner) Run(_ context.Context, path string, args []string, env []string, _ time.Duration) (CommandResult, error) {
runner.path = path
runner.args = append([]string(nil), args...)
runner.env = append([]string(nil), env...)
return CommandResult{
Path: path,
Args: append([]string(nil), args...),
Stdout: "ok\n",
ExitCode: 0,
}, nil
}
func TestBrowserRunUsesConfiguredHelperPath(t *testing.T) {
dir := t.TempDir()
helper := filepath.Join(dir, "agent-browser")
if err := os.WriteFile(helper, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("write helper: %v", err)
}
runner := &fakeCommandRunner{}
browser := NewBrowser(BrowserOptions{
Enabled: true,
HelperPath: helper,
Runner: runner,
})
result, err := browser.Run(context.Background(), "open", "https://example.com")
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if result.Output() != "ok" {
t.Fatalf("Output = %q, want ok", result.Output())
}
if runner.path != helper {
t.Fatalf("runner path = %q, want %q", runner.path, helper)
}
if want := []string{"open", "https://example.com"}; !reflect.DeepEqual(runner.args, want) {
t.Fatalf("runner args = %#v, want %#v", runner.args, want)
}
}
func TestBrowserRunDisabledFailsBeforeDiscovery(t *testing.T) {
browser := NewBrowser(BrowserOptions{Enabled: false, HelperPath: "/does/not/exist"})
_, err := browser.Run(context.Background(), "snapshot")
if err == nil || !strings.Contains(err.Error(), "disabled") {
t.Fatalf("Run error = %v, want disabled", err)
}
}
func TestBrowserRunMissingHelperPathIsActionable(t *testing.T) {
browser := NewBrowser(BrowserOptions{Enabled: true, HelperPath: filepath.Join(t.TempDir(), "missing")})
_, err := browser.Run(context.Background(), "snapshot")
if err == nil || !strings.Contains(err.Error(), "helper not found") {
t.Fatalf("Run error = %v, want missing helper", err)
}
}
func TestBrowserRunUsesHelperManifest(t *testing.T) {
dir := t.TempDir()
binDir := filepath.Join(dir, "node_modules", ".bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("mkdir bin: %v", err)
}
helper := filepath.Join(binDir, "agent-browser")
if err := os.WriteFile(helper, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("write helper: %v", err)
}
t.Setenv(EnvHelperManifest, `{"version":1,"helpers":{"agent-browser":{"command":`+quoteJSON(helper)+`,"pathPrepend":[`+quoteJSON(binDir)+`],"env":{"ZERO_HELPER_TEST":"1"}}}}`)
runner := &fakeCommandRunner{}
browser := NewBrowser(BrowserOptions{
Enabled: true,
Runner: runner,
})
if _, err := browser.Run(context.Background(), "snapshot"); err != nil {
t.Fatalf("Run returned error: %v", err)
}
if runner.path != helper {
t.Fatalf("runner path = %q, want manifest helper %q", runner.path, helper)
}
if !envContains(runner.env, "ZERO_HELPER_TEST=1") {
t.Fatalf("env = %#v, want ZERO_HELPER_TEST", runner.env)
}
pathValue := envValue(runner.env, "PATH")
if !strings.HasPrefix(pathValue, binDir+string(os.PathListSeparator)) && pathValue != binDir {
t.Fatalf("PATH overlay = %q, want prefix %q", pathValue, binDir)
}
}
func TestBrowserRunUsesManifestPrefixArgs(t *testing.T) {
runner := &fakeCommandRunner{}
t.Setenv(EnvHelperManifest, `{"version":1,"helpers":{"agent-browser":{"command":"cmd.exe","prefixArgs":["/d","/s","/c","C:\\zero\\agent-browser.cmd"]}}}`)
browser := NewBrowser(BrowserOptions{
Enabled: true,
Runner: runner,
})
if _, err := browser.Run(context.Background(), "open", "https://example.com"); err != nil {
t.Fatalf("Run returned error: %v", err)
}
if runner.path != "cmd.exe" {
t.Fatalf("runner path = %q, want cmd.exe", runner.path)
}
want := []string{"/d", "/s", "/c", `C:\zero\agent-browser.cmd`, "open", "https://example.com"}
if !reflect.DeepEqual(runner.args, want) {
t.Fatalf("runner args = %#v, want %#v", runner.args, want)
}
}
func TestMergeEnvReplacesPathCaseInsensitivelyOnWindows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows env keys are case-insensitive")
}
env := mergeEnv([]string{`Path=C:\Windows`, "ZERO=1"}, []string{`PATH=C:\Zero`})
pathCount := 0
for _, item := range env {
key, value, ok := strings.Cut(item, "=")
if !ok || !strings.EqualFold(key, "PATH") {
continue
}
pathCount++
if value != `C:\Zero` {
t.Fatalf("PATH value = %q, want C:\\Zero", value)
}
}
if pathCount != 1 {
t.Fatalf("PATH entries = %d in %#v, want 1", pathCount, env)
}
}
func TestBrowserRunUsesAdjacentPackagedHelper(t *testing.T) {
root := t.TempDir()
native := filepath.Join(root, "zero")
if err := os.WriteFile(native, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("write native: %v", err)
}
helpersDir := filepath.Join(root, "helpers")
if err := os.MkdirAll(helpersDir, 0o755); err != nil {
t.Fatalf("mkdir helpers: %v", err)
}
helper := filepath.Join(helpersDir, "agent-browser")
if err := os.WriteFile(helper, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("write helper: %v", err)
}
oldExecutablePath := executablePath
executablePath = func() (string, error) { return native, nil }
t.Cleanup(func() { executablePath = oldExecutablePath })
runner := &fakeCommandRunner{}
browser := NewBrowser(BrowserOptions{
Enabled: true,
Runner: runner,
})
if _, err := browser.Run(context.Background(), "snapshot"); err != nil {
t.Fatalf("Run returned error: %v", err)
}
if runner.path != helper {
t.Fatalf("runner path = %q, want adjacent helper %q", runner.path, helper)
}
}
func TestBrowserRunUsesAdjacentPackagedNodeBinHelper(t *testing.T) {
root := t.TempDir()
native := filepath.Join(root, "zero")
if err := os.WriteFile(native, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("write native: %v", err)
}
binDir := filepath.Join(root, "helpers", "node_modules", ".bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("mkdir helper bin: %v", err)
}
helper := filepath.Join(binDir, "agent-browser")
if err := os.WriteFile(helper, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("write helper: %v", err)
}
oldExecutablePath := executablePath
executablePath = func() (string, error) { return native, nil }
t.Cleanup(func() { executablePath = oldExecutablePath })
runner := &fakeCommandRunner{}
browser := NewBrowser(BrowserOptions{
Enabled: true,
Runner: runner,
})
if _, err := browser.Run(context.Background(), "snapshot"); err != nil {
t.Fatalf("Run returned error: %v", err)
}
if runner.path != helper {
t.Fatalf("runner path = %q, want packaged node bin helper %q", runner.path, helper)
}
}
func quoteJSON(value string) string {
data, err := json.Marshal(value)
if err != nil {
panic(err)
}
return string(data)
}
func envContains(env []string, want string) bool {
for _, item := range env {
if item == want {
return true
}
}
return false
}
func envValue(env []string, key string) string {
for _, item := range env {
if got, value, ok := strings.Cut(item, "="); ok && got == key {
return value
}
}
return ""
}