blob: 21a6ea0ffc29e9c91659f127d089e14f4e64547d [file]
// Copyright 2026 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package orchestrate
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func createFakeFfx(t *testing.T, tmpDir string, script string) string {
fakeFfx := filepath.Join(tmpDir, "fake_ffx")
if err := os.WriteFile(fakeFfx, []byte(script), 0755); err != nil {
t.Fatalf("failed to create fake ffx: %v", err)
}
// Prevent "text file busy" (ETXTBSY) by waiting until the kernel actually allows execution
for i := 0; i < 20; i++ {
cmd := exec.Command(fakeFfx)
err := cmd.Run()
if err == nil {
break
}
if strings.Contains(err.Error(), "text file busy") {
time.Sleep(10 * time.Millisecond)
continue
}
t.Fatalf("failed to verify fake ffx execution: %v", err)
}
return fakeFfx
}
func TestFFXStrictClient_Isolation(t *testing.T) {
t.Parallel()
tmpDir1 := t.TempDir()
tmpDir2 := t.TempDir()
// We need a fake ffx executable path to satisfy NewFFXStrictClient
fakeFfx1 := createFakeFfx(t, tmpDir1, "#!/bin/bash\nexit 0")
fakeFfx2 := createFakeFfx(t, tmpDir2, "#!/bin/bash\nexit 0")
ctx := context.Background()
client1, err := NewFFXStrictClient(ctx, fakeFfx1, tmpDir1, "repo-1", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient 1 failed: %v", err)
}
defer client1.Close()
client2, err := NewFFXStrictClient(ctx, fakeFfx2, tmpDir2, "repo-2", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient 2 failed: %v", err)
}
defer client2.Close()
// Verify output directories are different
if client1.outputsDir == client2.outputsDir {
t.Errorf("Both clients use the same outputsDir: %s", client1.outputsDir)
}
// Verify SSH keys are generated in different directories (under their respective outputsDir)
if client1.sshInfo.SshPriv == client2.sshInfo.SshPriv {
t.Errorf("Both clients share the same SSH private key path: %s", client1.sshInfo.SshPriv)
}
if !strings.Contains(client1.sshInfo.SshPriv, tmpDir1) {
t.Errorf("client1 private key %q is not under its tmpDir %q", client1.sshInfo.SshPriv, tmpDir1)
}
if !strings.Contains(client2.sshInfo.SshPriv, tmpDir2) {
t.Errorf("client2 private key %q is not under its tmpDir %q", client2.sshInfo.SshPriv, tmpDir2)
}
}
func TestFFXStrictClient_ApplyEnv(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
fakeFfx := createFakeFfx(t, tmpDir, "#!/bin/bash\nexit 0")
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
inputEnv := []string{
"FOO=BAR",
"FUCHSIA_DEVICE_ADDR=1.2.3.4",
"PATH=/usr/bin:/bin",
}
gotEnv, err := client.ApplyEnv(inputEnv)
if err != nil {
t.Fatalf("ApplyEnv failed: %v", err)
}
// Verify FOO=BAR is preserved
hasFoo := false
for _, val := range gotEnv {
if val == "FOO=BAR" {
hasFoo = true
break
}
}
if !hasFoo {
t.Errorf("Expected FOO=BAR in env, got: %v", gotEnv)
}
// Verify FUCHSIA_DEVICE_ADDR is removed
for _, val := range gotEnv {
if strings.HasPrefix(val, "FUCHSIA_DEVICE_ADDR=") {
t.Errorf("FUCHSIA_DEVICE_ADDR should have been removed, but found: %s", val)
}
}
// Verify XDG variables point to ffxStrictDir
for _, xdgVar := range xdgEnvVars {
expected := xdgVar + "=" + client.ffxStrictDir
found := false
for _, val := range gotEnv {
if val == expected {
found = true
break
}
}
if !found {
t.Errorf("Expected env to contain %q, got: %v", expected, gotEnv)
}
}
// Verify PATH has openssh-portable prepended and no duplicates
pathCount := 0
hasSshPath := false
for _, val := range gotEnv {
if strings.HasPrefix(val, "PATH=") {
pathCount++
if strings.Contains(val, "openssh-portable/bin") && strings.HasSuffix(val, ":/usr/bin:/bin") {
hasSshPath = true
}
}
}
if pathCount != 1 {
t.Errorf("Expected exactly one PATH entry, got %d", pathCount)
}
if !hasSshPath {
t.Errorf("Expected PATH to have openssh-portable prepended, got: %v", gotEnv)
}
}
func TestFFXStrictClient_SetDefaultTarget(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
fakeFfx := createFakeFfx(t, tmpDir, "#!/bin/bash\nexit 0")
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
// Initially target should be empty, and FUCHSIA_NODENAME should not be set
env, err := client.ApplyEnv([]string{})
if err != nil {
t.Fatalf("ApplyEnv failed: %v", err)
}
for _, val := range env {
if strings.HasPrefix(val, "FUCHSIA_NODENAME=") {
t.Errorf("FUCHSIA_NODENAME should not be set initially, but got: %s", val)
}
}
// Set target
target := "my-device"
client.SetDefaultTarget(&target)
env, err = client.ApplyEnv([]string{})
if err != nil {
t.Fatalf("ApplyEnv failed: %v", err)
}
hasTarget := false
for _, val := range env {
if val == "FUCHSIA_NODENAME=my-device" {
hasTarget = true
break
}
}
if !hasTarget {
t.Errorf("Expected FUCHSIA_NODENAME=my-device, got: %v", env)
}
// Unset target
client.SetDefaultTarget(nil)
env, err = client.ApplyEnv([]string{})
if err != nil {
t.Fatalf("ApplyEnv failed: %v", err)
}
for _, val := range env {
if strings.HasPrefix(val, "FUCHSIA_NODENAME=") {
t.Errorf("FUCHSIA_NODENAME should be unset, but got: %s", val)
}
}
}
func TestParsePort(t *testing.T) {
t.Parallel()
tests := []struct {
name string
addrStr string
want int
wantErr bool
}{
{"valid ipv4", "127.0.0.1:8080", 8080, false},
{"valid ipv6", "[::1]:8080", 8080, false},
{"invalid format", "8080", 0, true},
{"not a number", "localhost:abc", 0, true},
{"out of range high", "localhost:65536", 0, true},
{"out of range low", "localhost:0", 0, true},
{"negative port", "localhost:-1", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parsePort(tt.addrStr)
if (err != nil) != tt.wantErr {
t.Errorf("parsePort() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("parsePort() = %v, want %v", got, tt.want)
}
})
}
}
func TestFFXStrictClient_RepositoryServer_Restart(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
fakeFfx := createSmartFakeFfx(t, tmpDir)
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
target := "fake-target"
client.SetDefaultTarget(&target)
repoName := "test-repo"
repoDir := filepath.Join(tmpDir, "repo")
// 1. First Start
err = client.RepositoryServerStart(ctx, repoName, repoDir, "localhost:0")
if err != nil {
t.Fatalf("First RepositoryServerStart failed: %v", err)
}
// 2. Stop
err = client.RepositoryServerStop(ctx, repoName)
if err != nil {
t.Fatalf("RepositoryServerStop failed: %v", err)
}
// 3. Second Start
err = client.RepositoryServerStart(ctx, repoName, repoDir, "localhost:0")
if err != nil {
t.Fatalf("Second RepositoryServerStart failed: %v", err)
}
}
func TestFFXStrictClient_IsPackageServerRunning_CacheError(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
fakeFfx := createSmartFakeFfx(t, tmpDir)
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "fail-later-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
target := "fake-target"
client.SetDefaultTarget(&target)
repoName := "fail-later-repo"
repoDir := filepath.Join(tmpDir, "repo")
err = client.RepositoryServerStart(ctx, repoName, repoDir, "localhost:0")
if err != nil {
t.Fatalf("RepositoryServerStart failed: %v", err)
}
// Trigger the fake server to fail now that startup has completed.
triggerPath := filepath.Join(tmpDir, "ffx_strict", "trigger_fail")
if err := os.WriteFile(triggerPath, []byte{}, 0666); err != nil {
t.Fatalf("Failed to create trigger file: %v", err)
}
// Poll for the server status to fail, with a 5-second timeout to prevent flakiness
var err1 error
start := time.Now()
for time.Since(start) < 5*time.Second {
_, err1 = client.IsPackageServerRunning(ctx, repoName)
if err1 != nil {
break
}
time.Sleep(10 * time.Millisecond)
}
if err1 == nil {
t.Fatalf("First IsPackageServerRunning expected error, got nil")
}
// Second check should return the exact same cached error immediately
_, err2 := client.IsPackageServerRunning(ctx, repoName)
if err2 == nil {
t.Errorf("Second IsPackageServerRunning expected error, got nil")
} else if !errors.Is(err2, err1) {
t.Errorf("Expected identical errors, got: %v vs %v", err1, err2)
}
}
func TestFFXStrictClient_LogStart_DoubleClose(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
fakeFfx := createFakeFfx(t, tmpDir, "#!/bin/bash\nexit 0")
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
target := "fake-target"
client.SetDefaultTarget(&target)
closer, err := client.TargetLogStart(ctx, io.Discard)
if err != nil {
t.Fatalf("TargetLogStart failed: %v", err)
}
// Double close should not panic
if err := closer.Close(); err != nil {
t.Errorf("First Close failed: %v", err)
}
defer func() {
if r := recover(); r != nil {
t.Errorf("Second Close panicked: %v", r)
}
}()
if err := closer.Close(); err != nil {
t.Errorf("Second Close failed: %v", err)
}
}
func TestFFXStrictClient_RepositoryServer_Restart_ExplicitPort(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
fakeFfx := createSmartFakeFfx(t, tmpDir)
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
target := "fake-target"
client.SetDefaultTarget(&target)
repoName := "test-repo"
repoDir := filepath.Join(tmpDir, "repo")
// 1. First Start
err = client.RepositoryServerStart(ctx, repoName, repoDir, "localhost:8084")
if err != nil {
t.Fatalf("First RepositoryServerStart failed: %v", err)
}
// 2. Stop
err = client.RepositoryServerStop(ctx, repoName)
if err != nil {
t.Fatalf("RepositoryServerStop failed: %v", err)
}
// 3. Second Start
err = client.RepositoryServerStart(ctx, repoName, repoDir, "localhost:8084")
if err != nil {
t.Fatalf("Second RepositoryServerStart failed: %v", err)
}
}
func TestFFXStrictClient_RepositoryServer_Timeout(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
fakeFfx := createSmartFakeFfx(t, tmpDir)
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "timeout-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
repoDir := filepath.Join(tmpDir, "repo")
// Start should timeout after 5 seconds because the server hangs
err = client.RepositoryServerStart(ctx, "timeout-repo", repoDir, "localhost:0")
if err == nil {
t.Fatalf("Expected timeout error, got nil")
}
if !strings.Contains(err.Error(), "timed out waiting for repository server to start") {
t.Fatalf("Expected timeout error, got: %v", err)
}
// Verify the process is cleaned up and we can start a normal repo afterwards
err = client.RepositoryServerStart(ctx, "test-repo", repoDir, "localhost:0")
if err != nil {
t.Fatalf("Failed to start normal repo after timeout cleanup: %v", err)
}
client.RepositoryServerStop(ctx, "test-repo")
}
func TestFFXStrictClient_RepositoryServer_StaleServer(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
fakeFfx := createSmartFakeFfx(t, tmpDir)
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "stale-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
repoDir := filepath.Join(tmpDir, "repo")
// is_list will return a stale entry first, but IsPackageServerRunning should ignore it and keep polling
err = client.RepositoryServerStart(ctx, "stale-repo", repoDir, "localhost:0")
if err != nil {
t.Fatalf("RepositoryServerStart failed to ignore stale server: %v", err)
}
client.RepositoryServerStop(ctx, "stale-repo")
}
func TestFFXStrictClient_RepositoryServer_BadJson(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
fakeFfx := createSmartFakeFfx(t, tmpDir)
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "bad-json-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
repoDir := filepath.Join(tmpDir, "repo")
// is_list will return bad json, causing IsPackageServerRunning to fail cleanly
// The polling loop logs the error and continues until the 5 second timeout.
err = client.RepositoryServerStart(ctx, "bad-json-repo", repoDir, "localhost:0")
if err == nil {
t.Fatalf("Expected timeout error due to bad json, got nil")
}
if !strings.Contains(err.Error(), "timed out waiting for repository server to start") {
t.Fatalf("Expected timeout error, got: %v", err)
}
}
func createSmartFakeFfx(t *testing.T, tmpDir string) string {
script := fmt.Sprintf(`#!/bin/bash
# Find log.dir in arguments to know where to write state
LOG_DIR=""
for ((i=1; i<=$#; i++)); do
if [ "${!i}" = "-c" ]; then
next=$((i+1))
if [[ "${!next}" == log.dir=* ]]; then
LOG_DIR="${!next#log.dir=}"
fi
fi
done
STATE_DIR=""
if [ -n "$LOG_DIR" ]; then
STATE_DIR=$(dirname "$LOG_DIR")
else
STATE_DIR="%s"
fi
STATE_FILE="$STATE_DIR/server_state.json"
# Check commands
is_start=false
is_list=false
is_stop=false
for arg in "$@"; do
if [ "$arg" = "start" ]; then
is_start=true
elif [ "$arg" = "list" ]; then
is_list=true
elif [ "$arg" = "stop" ]; then
is_stop=true
fi
done
if $is_start; then
PORT="8083" # default port
PORT_PATH=""
for ((i=1; i<=$#; i++)); do
if [ "${!i}" = "--address" ]; then
next=$((i+1))
PORT=$(echo "${!next}" | cut -d: -f2)
if [ "$PORT" = "0" ]; then
PORT="8083"
fi
elif [ "${!i}" = "--port-path" ]; then
next=$((i+1))
PORT_PATH="${!next}"
fi
done
is_fail_later=false
is_timeout=false
REPO_NAME="test-repo"
for ((i=1; i<=$#; i++)); do
if [ "${!i}" = "--repository" ]; then
next=$((i+1))
REPO_NAME="${!next}"
fi
done
for arg in "$@"; do
if [ "$arg" = "fail-later-repo" ]; then
is_fail_later=true
elif [ "$arg" = "timeout-repo" ]; then
is_timeout=true
fi
done
echo "$REPO_NAME" > "$STATE_DIR/repo_name.txt"
if $is_timeout; then
while true; do sleep 1; done
fi
if [ -n "$PORT_PATH" ]; then
echo "$PORT" > "$PORT_PATH"
fi
if $is_fail_later; then
echo "{\"port\": $PORT}" > "$STATE_FILE"
while [ ! -f "$STATE_DIR/trigger_fail" ]; do
sleep 0.05
done
rm -f "$STATE_DIR/trigger_fail"
exit 1
fi
echo "{\"port\": $PORT}" > "$STATE_FILE"
while true; do
sleep 1
done
elif $is_list; then
if [ -f "$STATE_FILE" ]; then
PORT=$(grep -o '"port": *[0-9]*' "$STATE_FILE" | grep -o '[0-9]*')
REPO_NAME=""
if [ -f "$STATE_DIR/repo_name.txt" ]; then
REPO_NAME=$(cat "$STATE_DIR/repo_name.txt")
fi
if [ "$REPO_NAME" = "bad-json-repo" ]; then
echo "{\"ok\":{\"data\":[invalid json"
exit 0
fi
echo "{\"ok\":{\"data\":[{\"name\":\"test-repo\",\"address\":\"[::]:$PORT\"},{\"name\":\"fail-later-repo\",\"address\":\"[::]:$PORT\"},{\"name\":\"stale-repo\",\"address\":\"[::]:9999\"},{\"name\":\"stale-repo\",\"address\":\"[::]:$PORT\"}]}}"
else
echo '{"ok":{"data":[]}}'
fi
exit 0
elif $is_stop; then
rm -f "$STATE_FILE"
exit 0
fi
exit 0
`, tmpDir)
return createFakeFfx(t, tmpDir, script)
}
func TestFFXStrictClient_Flash(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
argsFile := filepath.Join(tmpDir, "args.txt")
script := fmt.Sprintf(`#!/bin/bash
echo "$@" >> %s
exit 0
`, argsFile)
fakeFfx := createFakeFfx(t, tmpDir, script)
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
err = client.Flash(ctx, "serial123", "path/to/product", "path/to/pubkey")
if err != nil {
t.Fatalf("Flash failed: %v", err)
}
data, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("Failed to read args file: %v", err)
}
args := string(data)
if !strings.Contains(args, "target flash") {
t.Errorf("Expected 'target flash' in args, got: %s", args)
}
if !strings.Contains(args, "path/to/product") {
t.Errorf("Expected 'path/to/product' in args, got: %s", args)
}
if !strings.Contains(args, "path/to/pubkey") {
t.Errorf("Expected 'path/to/pubkey' in args, got: %s", args)
}
}
func TestFFXStrictClient_EmuStart(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
argsFile := filepath.Join(tmpDir, "args.txt")
script := fmt.Sprintf(`#!/bin/bash
echo "$@" >> %s
exit 0
`, argsFile)
fakeFfx := createFakeFfx(t, tmpDir, script)
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
err = client.EmuStart(ctx, "path/to/emu/product", "my-emu-name", "", "")
if err != nil {
t.Fatalf("EmuStart failed: %v", err)
}
data, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("Failed to read args file: %v", err)
}
args := string(data)
if !strings.Contains(args, "emu start") {
t.Errorf("Expected 'emu start' in args, got: %s", args)
}
if !strings.Contains(args, "path/to/emu/product") {
t.Errorf("Expected 'path/to/emu/product' in args, got: %s", args)
}
if !strings.Contains(args, "--name my-emu-name") {
t.Errorf("Expected '--name my-emu-name' in args, got: %s", args)
}
if !strings.Contains(args, "--headless") {
t.Errorf("Expected '--headless' in args, got: %s", args)
}
}
func TestFFXStrictClient_EmuStart_WithEngine(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
argsFile := filepath.Join(tmpDir, "args.txt")
script := fmt.Sprintf(`#!/bin/bash
echo "$@" >> %s
exit 0
`, argsFile)
fakeFfx := createFakeFfx(t, tmpDir, script)
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
err = client.EmuStart(ctx, "path/to/emu/product", "my-emu-name", "qemu", "")
if err != nil {
t.Fatalf("EmuStart failed: %v", err)
}
data, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("Failed to read args file: %v", err)
}
args := string(data)
if !strings.Contains(args, "--engine qemu") {
t.Errorf("Expected '--engine qemu' in args, got: %s", args)
}
}
func TestFFXStrictClient_EmuStart_WithDevice(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
argsFile := filepath.Join(tmpDir, "args.txt")
script := fmt.Sprintf(`#!/bin/bash
echo "$@" >> %s
exit 0
`, argsFile)
fakeFfx := createFakeFfx(t, tmpDir, script)
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
err = client.EmuStart(ctx, "path/to/emu/product", "my-emu-name", "", "x64-emu-large")
if err != nil {
t.Fatalf("EmuStart failed: %v", err)
}
data, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("Failed to read args file: %v", err)
}
args := string(data)
if !strings.Contains(args, "--device x64-emu-large") {
t.Errorf("Expected '--device x64-emu-large' in args, got: %s", args)
}
}
func TestFFXStrictClient_EmuStop(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
argsFile := filepath.Join(tmpDir, "args.txt")
script := fmt.Sprintf(`#!/bin/bash
echo "$@" >> %s
exit 0
`, argsFile)
fakeFfx := createFakeFfx(t, tmpDir, script)
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
err = client.EmuStop(ctx)
if err != nil {
t.Fatalf("EmuStop failed: %v", err)
}
data, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("Failed to read args file: %v", err)
}
args := string(data)
if !strings.Contains(args, "emu stop --all") {
t.Errorf("Expected 'emu stop --all' in args, got: %s", args)
}
}
func TestFFXStrictClient_RepositoryCreate(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
argsFile := filepath.Join(tmpDir, "args.txt")
script := fmt.Sprintf(`#!/bin/bash
echo "$@" >> %s
exit 0
`, argsFile)
fakeFfx := createFakeFfx(t, tmpDir, script)
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
err = client.RepositoryCreate(ctx, "path/to/repo")
if err != nil {
t.Fatalf("RepositoryCreate failed: %v", err)
}
data, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("Failed to read args file: %v", err)
}
args := string(data)
if !strings.Contains(args, "repository create") {
t.Errorf("Expected 'repository create' in args, got: %s", args)
}
if !strings.Contains(args, "path/to/repo") {
t.Errorf("Expected 'path/to/repo' in args, got: %s", args)
}
}
func TestFFXStrictClient_RepositoryPublish(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
argsFile := filepath.Join(tmpDir, "args.txt")
script := fmt.Sprintf(`#!/bin/bash
echo "$@" >> %s
exit 0
`, argsFile)
fakeFfx := createFakeFfx(t, tmpDir, script)
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
archives := []string{"path/to/arch1.far", "path/to/arch2.far"}
err = client.RepositoryPublish(ctx, "path/to/repo", "path/to/product", archives)
if err != nil {
t.Fatalf("RepositoryPublish failed: %v", err)
}
data, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("Failed to read args file: %v", err)
}
args := string(data)
if !strings.Contains(args, "repository publish path/to/repo --product-bundle path/to/product") {
t.Errorf("Expected 'repository publish path/to/repo --product-bundle path/to/product' in args, got: %s", args)
}
if !strings.Contains(args, "repository publish path/to/repo --package-archive path/to/arch1.far --package-archive path/to/arch2.far") {
t.Errorf("Expected 'repository publish path/to/repo --package-archive path/to/arch1.far --package-archive path/to/arch2.far' in args, got: %s", args)
}
}
func TestFFXStrictClient_TargetAdd(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
argsFile := filepath.Join(tmpDir, "args.txt")
script := fmt.Sprintf(`#!/bin/bash
echo "$@" >> %s
exit 0
`, argsFile)
fakeFfx := createFakeFfx(t, tmpDir, script)
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
err = client.TargetAdd(ctx, "192.168.1.1:8022")
if err != nil {
t.Fatalf("TargetAdd failed: %v", err)
}
data, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("Failed to read args file: %v", err)
}
args := string(data)
if !strings.Contains(args, "target add 192.168.1.1:8022 --nowait") {
t.Errorf("Expected 'target add 192.168.1.1:8022 --nowait' in args, got: %s", args)
}
}
func TestFFXStrictClient_TargetList(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
argsFile := filepath.Join(tmpDir, "args.txt")
script := fmt.Sprintf(`#!/bin/bash
echo "$@" >> %s
echo "target-1234"
exit 0
`, argsFile)
fakeFfx := createFakeFfx(t, tmpDir, script)
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
out, err := client.TargetList(ctx)
if err != nil {
t.Fatalf("TargetList failed: %v", err)
}
if !strings.Contains(out, "target-1234") {
t.Errorf("Expected output to contain 'target-1234', got %q", out)
}
data, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("Failed to read args file: %v", err)
}
args := string(data)
if !strings.Contains(args, "target list") {
t.Errorf("Expected 'target list' in args, got: %s", args)
}
}
func TestFFXStrictClient_TargetWait(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
argsFile := filepath.Join(tmpDir, "args.txt")
script := fmt.Sprintf(`#!/bin/bash
echo "$@" >> %s
exit 0
`, argsFile)
fakeFfx := createFakeFfx(t, tmpDir, script)
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
target := "my-target"
client.SetDefaultTarget(&target)
err = client.TargetWait(ctx)
if err != nil {
t.Fatalf("TargetWait failed: %v", err)
}
data, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("Failed to read args file: %v", err)
}
args := string(data)
if !strings.Contains(args, "target wait") {
t.Errorf("Expected 'target wait' in args, got: %s", args)
}
if !strings.Contains(args, "--target my-target") {
t.Errorf("Expected '--target my-target' in args, got: %s", args)
}
}
func TestFFXStrictClient_TargetShow(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
argsFile := filepath.Join(tmpDir, "args.txt")
script := fmt.Sprintf(`#!/bin/bash
echo "$@" >> %s
echo "showing target"
exit 0
`, argsFile)
fakeFfx := createFakeFfx(t, tmpDir, script)
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
// Should fail without a target set
_, err = client.TargetShow(ctx)
if err == nil {
t.Fatalf("TargetShow expected error when no target is set")
}
target := "my-target"
client.SetDefaultTarget(&target)
out, err := client.TargetShow(ctx)
if err != nil {
t.Fatalf("TargetShow failed: %v", err)
}
if !strings.Contains(out, "showing target") {
t.Errorf("Expected output to contain 'showing target', got %q", out)
}
data, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("Failed to read args file: %v", err)
}
args := string(data)
if !strings.Contains(args, "--target my-target target show") {
t.Errorf("Expected '--target my-target target show' in args, got: %s", args)
}
}
func TestFFXStrictClient_TargetSnapshot(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
argsFile := filepath.Join(tmpDir, "args.txt")
script := fmt.Sprintf(`#!/bin/bash
echo "$@" >> %s
exit 0
`, argsFile)
fakeFfx := createFakeFfx(t, tmpDir, script)
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
target := "my-target"
client.SetDefaultTarget(&target)
snapshotDir := filepath.Join(tmpDir, "snapshot")
err = client.TargetSnapshot(ctx, snapshotDir)
if err != nil {
t.Fatalf("TargetSnapshot failed: %v", err)
}
data, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("Failed to read args file: %v", err)
}
args := string(data)
if !strings.Contains(args, "target snapshot --dir") {
t.Errorf("Expected 'target snapshot --dir' in args, got: %s", args)
}
if !strings.Contains(args, snapshotDir) {
t.Errorf("Expected snapshot dir %q in args, got: %s", snapshotDir, args)
}
if !strings.Contains(args, "--target my-target") {
t.Errorf("Expected '--target my-target' in args, got: %s", args)
}
}
func TestFFXStrictClient_ConnectivityDirect(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
argsFile := filepath.Join(tmpDir, "args.txt")
script := fmt.Sprintf(`#!/bin/bash
echo "$@" >> %s
exit 0
`, argsFile)
fakeFfx := createFakeFfx(t, tmpDir, script)
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
if err := client.TargetAdd(ctx, "192.168.1.1:8022"); err != nil {
t.Fatalf("TargetAdd failed: %v", err)
}
data, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("Failed to read args file: %v", err)
}
args := string(data)
if !strings.Contains(args, "-c connectivity.direct=true") {
t.Errorf("Expected '-c connectivity.direct=true' in args, got: %s", args)
}
}
func TestFFXStrictClient_TargetRepositoryRegister(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
argsFile := filepath.Join(tmpDir, "args.txt")
script := fmt.Sprintf(`#!/bin/bash
echo "$@" >> %s
if [[ "$*" == *"repository server list"* ]]; then
echo '{"ok":{"data":[{"name":"test-repo","address":"[::]:8083"}]}}'
fi
exit 0
`, argsFile)
fakeFfx := createFakeFfx(t, tmpDir, script)
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
target := "my-target"
client.SetDefaultTarget(&target)
// Call IsPackageServerRunning to populate the internal c.repoPort
running, err := client.IsPackageServerRunning(ctx, "test-repo")
if err != nil {
t.Fatalf("IsPackageServerRunning failed: %v", err)
}
if !running {
t.Fatalf("Expected package server to be running")
}
err = client.TargetRepositoryRegister(ctx, "test-repo", []string{"fuchsia.com", "chromium.org"})
if err != nil {
t.Fatalf("TargetRepositoryRegister failed: %v", err)
}
data, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("Failed to read args file: %v", err)
}
args := string(data)
if !strings.Contains(args, "--target my-target target repository register --repository test-repo") {
t.Errorf("Expected '--target my-target target repository register --repository test-repo' in args, got: %s", args)
}
if !strings.Contains(args, "--alias fuchsia.com --alias chromium.org") {
t.Errorf("Expected '--alias fuchsia.com --alias chromium.org' in args, got: %s", args)
}
if !strings.Contains(args, "--alias-conflict-mode replace") {
t.Errorf("Expected '--alias-conflict-mode replace' in args, got: %s", args)
}
if !strings.Contains(args, "--port 8083") {
t.Errorf("Expected '--port 8083' in args, got: %s", args)
}
}
func TestFFXStrictClient_Symbolize(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
argsFile := filepath.Join(tmpDir, "args.txt")
script := fmt.Sprintf(`#!/bin/bash
echo "$@" >> %s
cat - # Read stdin and just copy it to stdout to simulate some processing if we wanted
exit 0
`, argsFile)
fakeFfx := createFakeFfx(t, tmpDir, script)
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
inBuf := strings.NewReader("input-data")
var outBuf strings.Builder
err = client.Symbolize(ctx, inBuf, &outBuf)
if err != nil {
t.Fatalf("Symbolize failed: %v", err)
}
data, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("Failed to read args file: %v", err)
}
args := string(data)
if !strings.Contains(args, "debug symbolize") {
t.Errorf("Expected 'debug symbolize' in args, got: %s", args)
}
if outBuf.String() != "input-data" {
t.Errorf("Expected output to be 'input-data', got %q", outBuf.String())
}
}
func TestFFXStrictClient_ProductDownload(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
argsFile := filepath.Join(tmpDir, "args.txt")
script := fmt.Sprintf(`#!/bin/bash
echo "$@" >> %s
exit 0
`, argsFile)
fakeFfx := createFakeFfx(t, tmpDir, script)
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
err = client.ProductDownload(ctx, "transfer.url", "/out/dir", "/auth/path")
if err != nil {
t.Fatalf("ProductDownload failed: %v", err)
}
data, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("Failed to read args file: %v", err)
}
args := string(data)
if !strings.Contains(args, "product download transfer.url /out/dir --auth /auth/path") {
t.Errorf("Expected 'product download transfer.url /out/dir --auth /auth/path' in args, got: %s", args)
}
}
func TestFFXStrictClient_TargetLogStart(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
argsFile := filepath.Join(tmpDir, "args.txt")
logStartedFile := filepath.Join(tmpDir, "log_started.txt")
script := fmt.Sprintf(`#!/bin/bash
if [ "$#" -eq 0 ]; then
exit 0
fi
for arg in "$@"; do
if [ "$arg" = "log" ]; then
echo "$@" >> %s
echo "sample target log message"
touch %s
exit 0
fi
done
exit 0
`, argsFile, logStartedFile)
fakeFfx := createFakeFfx(t, tmpDir, script)
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
target := "my-target"
client.SetDefaultTarget(&target)
var outBuf strings.Builder
closer, err := client.TargetLogStart(ctx, &outBuf)
if err != nil {
t.Fatalf("TargetLogStart failed: %v", err)
}
// Poll until fake_ffx log command has executed
pollCtx, pollCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pollCancel()
for {
if _, err := os.Stat(logStartedFile); err == nil {
break
}
select {
case <-pollCtx.Done():
closer.Close()
t.Fatalf("Timed out waiting for fake_ffx log command to execute")
case <-time.After(10 * time.Millisecond):
}
}
// Guarantee process has completed and output is flushed
if err := closer.Close(); err != nil {
t.Fatalf("closer.Close failed: %v", err)
}
data, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("Failed to read args file: %v", err)
}
args := string(data)
if !strings.Contains(args, "--target my-target log --symbolize off") {
t.Errorf("Expected '--target my-target log --symbolize off' in args, got: %s", args)
}
if !strings.Contains(outBuf.String(), "sample target log message") {
t.Errorf("Expected output to contain 'sample target log message', got: %q", outBuf.String())
}
}
func TestFFXStrictClient_TargetLogStart_NoTarget(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
fakeFfx := createFakeFfx(t, tmpDir, "#!/bin/bash\nexit 0\n")
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
closer, err := client.TargetLogStart(ctx, io.Discard)
if err == nil {
closer.Close()
t.Fatalf("TargetLogStart succeeded, expected error when no target is set")
}
if !strings.Contains(err.Error(), "no target is set") {
t.Errorf("Expected 'no target is set' error, got: %v", err)
}
}
func TestFFXStrictClient_TargetLogStart_LiveProcess_KillPGID(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
startedFile := filepath.Join(tmpDir, "started.txt")
script := fmt.Sprintf(`#!/bin/bash
if [ "$#" -eq 0 ]; then
exit 0
fi
for arg in "$@"; do
if [ "$arg" = "log" ]; then
touch %s
# Spawn background sleep process to form a process group
sleep 60 &
wait
fi
done
exit 0
`, startedFile)
fakeFfx := createFakeFfx(t, tmpDir, script)
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
target := "my-target"
client.SetDefaultTarget(&target)
closer, err := client.TargetLogStart(ctx, io.Discard)
if err != nil {
t.Fatalf("TargetLogStart failed: %v", err)
}
// Poll until fake_ffx is confirmed to be running the log command
pollCtx, pollCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pollCancel()
for {
if _, err := os.Stat(startedFile); err == nil {
break
}
select {
case <-pollCtx.Done():
closer.Close()
t.Fatalf("Timed out waiting for fake_ffx log process to start")
case <-time.After(10 * time.Millisecond):
}
}
// Kill the running process group and verify clean termination
start := time.Now()
if err := closer.Close(); err != nil {
t.Errorf("closer.Close on live process failed: %v", err)
}
if duration := time.Since(start); duration > 10*time.Second {
t.Errorf("closer.Close took too long (%v), process group kill may have failed", duration)
}
}
func TestFFXStrictClient_TargetLogStart_ContextCancel(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
startedFile := filepath.Join(tmpDir, "started.txt")
script := fmt.Sprintf(`#!/bin/bash
if [ "$#" -eq 0 ]; then
exit 0
fi
for arg in "$@"; do
if [ "$arg" = "log" ]; then
touch %s
sleep 60
fi
done
exit 0
`, startedFile)
fakeFfx := createFakeFfx(t, tmpDir, script)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
target := "my-target"
client.SetDefaultTarget(&target)
closer, err := client.TargetLogStart(ctx, io.Discard)
if err != nil {
t.Fatalf("TargetLogStart failed: %v", err)
}
// Wait for fake_ffx log process to start
pollCtx, pollCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pollCancel()
for {
if _, err := os.Stat(startedFile); err == nil {
break
}
select {
case <-pollCtx.Done():
closer.Close()
t.Fatalf("Timed out waiting for fake_ffx log process to start")
case <-time.After(10 * time.Millisecond):
}
}
// Cancel context; background watcher should invoke closer.Close()
cancel()
// Calling closer.Close() afterwards (as orchestrator teardown does) must succeed without error
if err := closer.Close(); err != nil {
t.Errorf("closer.Close() after context cancellation failed: %v", err)
}
}
func TestFFXStrictClient_SymbolIndexAdd(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
argsFile := filepath.Join(tmpDir, "args.txt")
script := fmt.Sprintf(`#!/bin/bash
echo "$@" >> %s
exit 0
`, argsFile)
fakeFfx := createFakeFfx(t, tmpDir, script)
ctx := context.Background()
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", nil)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
err = client.SymbolIndexAdd(ctx, "/path/to/build-id")
if err != nil {
t.Fatalf("SymbolIndexAdd failed: %v", err)
}
data, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("Failed to read args file: %v", err)
}
args := string(data)
if !strings.Contains(args, "debug symbol-index add /path/to/build-id") {
t.Errorf("Expected 'debug symbol-index add /path/to/build-id' in args, got: %s", args)
}
}
func TestFFXStrictClient_FfxConfigOverrides(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
argsFile := filepath.Join(tmpDir, "args.txt")
script := fmt.Sprintf(`#!/bin/bash
echo "$@" >> %s
exit 0
`, argsFile)
fakeFfx := createFakeFfx(t, tmpDir, script)
ctx := context.Background()
ffxConfigs := map[string]string{
"sdk.overrides.qemu_internal": "/path/to/custom_qemu",
}
client, err := NewFFXStrictClient(ctx, fakeFfx, tmpDir, "test-repo", ffxConfigs)
if err != nil {
t.Fatalf("NewFFXStrictClient failed: %v", err)
}
defer client.Close()
if err := client.TargetAdd(ctx, "192.168.1.1:8022"); err != nil {
t.Fatalf("TargetAdd failed: %v", err)
}
data, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("Failed to read args file: %v", err)
}
args := string(data)
if !strings.Contains(args, "-c sdk.overrides.qemu_internal=/path/to/custom_qemu") {
t.Errorf("Expected '-c sdk.overrides.qemu_internal=/path/to/custom_qemu' in args, got: %s", args)
}
}