Files
zero/internal/installtest/install_scripts_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

326 lines
10 KiB
Go

package installtest
import (
"bytes"
"context"
"crypto/sha256"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
func TestUnixInstallerScriptMatchesReleaseContracts(t *testing.T) {
script := readRepoText(t, "scripts/install.sh")
if !strings.HasPrefix(script, "#!/usr/bin/env bash") {
t.Fatalf("install.sh shebang = %q, want bash", strings.SplitN(script, "\n", 2)[0])
}
containsAll(t, script, []string{
"set -euo pipefail",
`ZERO_REPO="${ZERO_REPO:-Gitlawb/zero}"`,
`ZERO_INSTALL_DIR="${ZERO_INSTALL_DIR:-$HOME/.local/bin}"`,
`archive_name="zero-v${version}-${platform}-${arch}.tar.gz"`,
`checksum_name="${archive_name}.sha256"`,
"curl --fail --location --show-error --silent --header 'Accept: application/vnd.github+json'",
`verify_checksum "$checksum_name"`,
`tar -xzf "$archive_path" -C "$extract_dir"`,
`find_extracted_binary "$extract_dir"`,
`cp "$binary_path" "$ZERO_INSTALL_DIR/zero"`,
`copy_optional_file "zero-linux-sandbox"`,
`copy_optional_file "zero-seccomp"`,
`copy_optional_dir "helpers"`,
})
}
func TestPowerShellInstallerScriptMatchesWindowsReleaseContracts(t *testing.T) {
script := readRepoText(t, "scripts/install.ps1")
containsAll(t, script, []string{
`[string]$Repository = $(if ($env:ZERO_REPO)`,
`Join-Path $env:LOCALAPPDATA "zero\bin"`,
`$archiveName = "zero-v$releaseVersion-windows-$arch.zip"`,
`$checksumName = "$archiveName.sha256"`,
`Get-FileHash -Path $archivePath -Algorithm SHA256`,
`Expand-Archive -Path $archivePath -DestinationPath $extractDir -Force`,
`Find-ZeroExtractedFile -Root $extractDir -FileName $fileName`,
`"zero-windows-command-runner.exe"`,
`"zero-windows-sandbox-setup.exe"`,
`Copy-Item -Path $sourcePath -Destination (Join-Path $InstallDir $fileName) -Force`,
`Find-ZeroOptionalExtractedDirectory -Root $extractDir -DirectoryName "helpers"`,
`Copy-Item -Path $helpersPath -Destination $targetHelpersPath -Recurse -Force`,
})
}
func TestUnixInstallerInstallsFromPrefixedReleaseArchiveWithoutNetwork(t *testing.T) {
fixture := newUnixInstallFixture(t)
stdout, stderr, err := runUnixInstaller(t, fixture)
if err != nil {
t.Fatalf("install.sh failed: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr)
}
if got := strings.TrimSpace(stderr); got != "" {
t.Fatalf("install.sh stderr = %q, want empty", got)
}
if !strings.Contains(stdout, "Installed "+filepath.Join(fixture.installDir, "zero")) {
t.Fatalf("install.sh stdout missing install path:\n%s", stdout)
}
installed := readFile(t, filepath.Join(fixture.installDir, "zero"))
if !strings.Contains(string(installed), "mock-zero") {
t.Fatalf("installed binary = %q, want mock-zero script", string(installed))
}
if _, err := os.Stat(filepath.Join(fixture.installDir, "helpers", "node_modules", ".bin", "agent-browser")); err != nil {
t.Fatalf("installed helper package missing: %v", err)
}
if _, err := os.Stat(filepath.Join(fixture.installDir, "zero-linux-sandbox")); err != nil {
t.Fatalf("installed linux sandbox helper missing: %v", err)
}
}
func TestUnixInstallerRejectsChecksumMismatchWithoutNetwork(t *testing.T) {
fixture := newUnixInstallFixture(t)
writeFile(t, fixture.checksumPath, []byte(strings.Repeat("0", 64)+" "+fixture.archiveName+"\n"), 0o644)
stdout, stderr, err := runUnixInstaller(t, fixture)
if err == nil {
t.Fatalf("install.sh succeeded with a bad checksum\nstdout:\n%s\nstderr:\n%s", stdout, stderr)
}
output := stdout + "\n" + stderr
if !strings.Contains(output, fixture.archiveName) {
t.Fatalf("checksum failure output missing archive name:\n%s", output)
}
if !strings.Contains(output, "FAILED") || !strings.Contains(strings.ToLower(output), "checksum") {
t.Fatalf("checksum failure output missing checksum mismatch detail:\n%s", output)
}
if _, err := os.Stat(filepath.Join(fixture.installDir, "zero")); !os.IsNotExist(err) {
t.Fatalf("installed binary exists after checksum failure: %v", err)
}
}
type unixInstallFixture struct {
bash string
mockBin string
installDir string
archiveName string
archivePath string
checksumPath string
}
func newUnixInstallFixture(t *testing.T) unixInstallFixture {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("Unix installer smoke does not run on Windows")
}
bash := requireCommand(t, "bash")
tar := requireCommand(t, "tar")
releasePlatform := unixReleasePlatform(t)
releaseArch := unixReleaseArch(t)
packageName := fmt.Sprintf("zero-v0.1.0-%s-%s", releasePlatform, releaseArch)
archiveName := packageName + ".tar.gz"
checksumName := archiveName + ".sha256"
root := t.TempDir()
fixture := unixInstallFixture{
bash: bash,
mockBin: filepath.Join(root, "bin"),
installDir: filepath.Join(root, "install"),
archiveName: archiveName,
archivePath: filepath.Join(root, "release", archiveName),
checksumPath: filepath.Join(root, "release", checksumName),
}
packageDir := filepath.Join(root, "package", packageName)
mustMkdirAll(t, fixture.mockBin)
mustMkdirAll(t, packageDir)
mustMkdirAll(t, filepath.Dir(fixture.archivePath))
mustMkdirAll(t, fixture.installDir)
mustMkdirAll(t, filepath.Join(packageDir, "helpers", "node_modules", ".bin"))
writeFile(t, filepath.Join(packageDir, "zero"), []byte("#!/usr/bin/env sh\necho mock-zero\n"), 0o755)
writeFile(t, filepath.Join(packageDir, "zero-linux-sandbox"), []byte("#!/usr/bin/env sh\n"), 0o755)
writeFile(t, filepath.Join(packageDir, "zero-seccomp"), []byte("#!/usr/bin/env sh\n"), 0o755)
mustMkdirAll(t, filepath.Join(packageDir, "helpers", "node_modules", "agent-browser", "bin"))
writeFile(t, filepath.Join(packageDir, "helpers", "node_modules", "agent-browser", "bin", "agent-browser.js"), []byte("#!/usr/bin/env node\n"), 0o755)
if err := os.Symlink("../agent-browser/bin/agent-browser.js", filepath.Join(packageDir, "helpers", "node_modules", ".bin", "agent-browser")); err != nil {
t.Fatalf("Symlink agent-browser helper: %v", err)
}
writeFile(t, filepath.Join(packageDir, "helpers", "node_modules", ".bin", "tuistory"), []byte("#!/usr/bin/env sh\n"), 0o755)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
tarCommand := exec.CommandContext(ctx, tar, "-C", filepath.Join(root, "package"), "-czf", fixture.archivePath, packageName)
if output, err := tarCommand.CombinedOutput(); err != nil {
t.Fatalf("tar failed: %v\n%s", err, output)
}
if err := ctx.Err(); err != nil {
t.Fatalf("tar timed out: %v", err)
}
archiveBytes := readFile(t, fixture.archivePath)
sum := sha256.Sum256(archiveBytes)
writeFile(t, fixture.checksumPath, []byte(fmt.Sprintf("%x %s\n", sum, archiveName)), 0o644)
mockCurl := fmt.Sprintf(`#!/usr/bin/env sh
set -eu
output=""
url=""
while [ "$#" -gt 0 ]; do
case "$1" in
--output)
output="$2"
shift 2
;;
--output=*)
output="${1#--output=}"
shift
;;
--header)
shift 2
;;
--header=*)
shift
;;
--fail|--location|--show-error|--silent)
shift
;;
*)
url="$1"
shift
;;
esac
done
case "$url" in
*/%s)
cp %s "$output"
;;
*/%s)
cp %s "$output"
;;
*)
echo "unexpected url: $url" >&2
exit 2
;;
esac
`, archiveName, shellQuote(fixture.archivePath), checksumName, shellQuote(fixture.checksumPath))
writeFile(t, filepath.Join(fixture.mockBin, "curl"), []byte(mockCurl), 0o755)
return fixture
}
func runUnixInstaller(t *testing.T, fixture unixInstallFixture) (string, string, error) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
command := exec.CommandContext(ctx, fixture.bash, filepath.Join(repoRoot(t), "scripts/install.sh"), "--version", "0.1.0", "--install-dir", fixture.installDir)
command.Dir = repoRoot(t)
command.Env = append(os.Environ(),
"PATH="+fixture.mockBin+string(os.PathListSeparator)+os.Getenv("PATH"),
"ZERO_GITHUB_BASE_URL=https://example.test",
"ZERO_REPO=Gitlawb/zero",
)
var stdout bytes.Buffer
var stderr bytes.Buffer
command.Stdout = &stdout
command.Stderr = &stderr
runErr := command.Run()
if err := ctx.Err(); err != nil {
t.Fatalf("install.sh timed out: %v", err)
}
return stdout.String(), stderr.String(), runErr
}
func unixReleasePlatform(t *testing.T) string {
t.Helper()
switch runtime.GOOS {
case "linux":
return "linux"
case "darwin":
return "macos"
default:
t.Skipf("Unix installer smoke does not support GOOS=%s", runtime.GOOS)
return ""
}
}
func unixReleaseArch(t *testing.T) string {
t.Helper()
switch runtime.GOARCH {
case "amd64", "x86_64":
return "x64"
case "arm64":
return "arm64"
default:
t.Skipf("Unix installer smoke does not support GOARCH=%s", runtime.GOARCH)
return ""
}
}
func containsAll(t *testing.T, value string, wants []string) {
t.Helper()
for _, want := range wants {
if !strings.Contains(value, want) {
t.Fatalf("expected content to contain %q", want)
}
}
}
func repoRoot(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
return filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
}
func readRepoText(t *testing.T, relativePath string) string {
t.Helper()
return string(readFile(t, filepath.Join(repoRoot(t), relativePath)))
}
func readFile(t *testing.T, path string) []byte {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile %s: %v", path, err)
}
return data
}
func mustMkdirAll(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("MkdirAll %s: %v", path, err)
}
}
func writeFile(t *testing.T, path string, content []byte, mode os.FileMode) {
t.Helper()
if err := os.WriteFile(path, content, mode); err != nil {
t.Fatalf("WriteFile %s: %v", path, err)
}
if mode&0o111 != 0 {
if err := os.Chmod(path, mode); err != nil {
t.Fatalf("Chmod %s: %v", path, err)
}
}
}
func requireCommand(t *testing.T, name string) string {
t.Helper()
path, err := exec.LookPath(name)
if err != nil {
t.Skipf("%s is not available", name)
}
return path
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
}