blob: 8a2613179a0ebaefdf7f7a016fb199d77fbdd5cf [file] [edit]
// 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"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
ffx "go.fuchsia.dev/fuchsia/tools/orchestrate/ffx"
utils "go.fuchsia.dev/fuchsia/tools/orchestrate/utils"
)
// mockFFXClient is a mock implementation of the FFXClient interface.
type mockFFXClient struct {
t *testing.T
// Expected calls and their results.
calls []mockCall
callIdx int
// Store arguments for later inspection if needed
defaultTarget *string
// The environment that was applied by ApplyEnv.
recordedEnv []string
// Path to a fake executable for Cmd() calls
fakeExecPath string
// For specific command outputs if needed
repoServerListOutput string
configGetOutput string
}
type mockCall struct {
method string
args []string // Store args as string slice for simpler comparison
retErr error
retVal any // For methods that return non-error values
}
func (m *mockFFXClient) Close() error {
call := m.recordCall("Close")
return call.retErr
}
func (m *mockFFXClient) ApplyEnv(env []string) ([]string, error) {
// ApplyEnv takes []string, but we record args as flattened strings.
// We can skip checking env content for now or just check it was called.
call := m.recordCall("ApplyEnv")
if call.retErr != nil {
return nil, call.retErr
}
if val := utils.GetOutputsDir(); val != "" {
env = append(env, fmt.Sprintf("FFX_ISOLATE_DIR=%s", val))
}
if m.defaultTarget != nil {
env = append(env, fmt.Sprintf("FUCHSIA_NODENAME=%s", *m.defaultTarget))
}
m.recordedEnv = env
return env, nil
}
func (m *mockFFXClient) SetDefaultTarget(target *string) {
val := "<nil>"
if target != nil {
val = *target
}
m.recordCall("SetDefaultTarget", val)
m.defaultTarget = target
}
func (m *mockFFXClient) SetupFfx(ctx context.Context, repoName string) error {
call := m.recordCall("SetupFfx", repoName)
return call.retErr
}
func (m *mockFFXClient) EmuStop(ctx context.Context) error {
call := m.recordCall("EmuStop")
return call.retErr
}
type mockTargetLogCloser struct{}
func (c *mockTargetLogCloser) Close() error { return nil }
func (m *mockFFXClient) TargetLogStart(ctx context.Context, output io.Writer) (io.Closer, error) {
call := m.recordCall("TargetLogStart")
if call.retErr != nil {
return nil, call.retErr
}
if call.retVal != nil {
if closer, ok := call.retVal.(io.Closer); ok {
return closer, nil
}
m.t.Fatalf("mockFFXClient: TargetLogStart expected io.Closer retVal, got %T", call.retVal)
}
return &mockTargetLogCloser{}, nil
}
func (m *mockFFXClient) Flash(ctx context.Context, fastbootSerial, productDir, pubKeyPath string) error {
call := m.recordCall("Flash", fastbootSerial, productDir, pubKeyPath)
return call.retErr
}
func (m *mockFFXClient) IsPackageServerRunning(ctx context.Context, repoName string) (bool, error) {
call := m.recordCall("IsPackageServerRunning", repoName)
if call.retErr != nil {
return false, call.retErr
}
if call.retVal != nil {
if b, ok := call.retVal.(bool); ok {
return b, nil
}
m.t.Fatalf("mockFFXClient: IsPackageServerRunning expected bool retVal, got %T", call.retVal)
}
return true, nil
}
func (m *mockFFXClient) ProductDownload(ctx context.Context, transferURL, outDir, authPath string) error {
call := m.recordCall("ProductDownload", transferURL, outDir, authPath)
return call.retErr
}
func (m *mockFFXClient) EmuStart(ctx context.Context, productDir, name, engine, device string) error {
call := m.recordCall("EmuStart", productDir, name, engine, device)
return call.retErr
}
func (m *mockFFXClient) RepositoryCreate(ctx context.Context, repoDir string) error {
call := m.recordCall("RepositoryCreate", repoDir)
return call.retErr
}
func (m *mockFFXClient) RepositoryPublish(ctx context.Context, repoDir, productDir string, packageArchives []string) error {
args := append([]string{repoDir, productDir}, packageArchives...)
call := m.recordCall("RepositoryPublish", args...)
return call.retErr
}
func (m *mockFFXClient) SymbolIndexAdd(ctx context.Context, buildID string) error {
call := m.recordCall("SymbolIndexAdd", buildID)
return call.retErr
}
func (m *mockFFXClient) RepositoryServerStart(ctx context.Context, repoName, repoDir, address string) error {
call := m.recordCall("RepositoryServerStart", repoName, repoDir, address)
return call.retErr
}
func (m *mockFFXClient) RepositoryServerStop(ctx context.Context, repoName string) error {
call := m.recordCall("RepositoryServerStop", repoName)
return call.retErr
}
func (m *mockFFXClient) RepositoryServerList(ctx context.Context) (string, error) {
call := m.recordCall("RepositoryServerList")
if call.retErr != nil {
return "", call.retErr
}
if call.retVal != nil {
if str, ok := call.retVal.(string); ok {
return str, nil
}
m.t.Fatalf("mockFFXClient: RepositoryServerList expected string retVal, got %T", call.retVal)
}
return "", nil
}
func (m *mockFFXClient) TargetAdd(ctx context.Context, addr string) error {
call := m.recordCall("TargetAdd", addr)
return call.retErr
}
func (m *mockFFXClient) TargetList(ctx context.Context) (string, error) {
call := m.recordCall("TargetList")
if call.retErr != nil {
return "", call.retErr
}
if call.retVal != nil {
if str, ok := call.retVal.(string); ok {
return str, nil
}
m.t.Fatalf("mockFFXClient: TargetList expected string retVal, got %T", call.retVal)
}
return "", nil
}
func (m *mockFFXClient) TargetWait(ctx context.Context) error {
call := m.recordCall("TargetWait")
return call.retErr
}
func (m *mockFFXClient) TargetShow(ctx context.Context) (string, error) {
call := m.recordCall("TargetShow")
if call.retErr != nil {
return "", call.retErr
}
if call.retVal != nil {
if str, ok := call.retVal.(string); ok {
return str, nil
}
m.t.Fatalf("mockFFXClient: TargetShow expected string retVal, got %T", call.retVal)
}
return "", nil
}
func (m *mockFFXClient) TargetRepositoryRegister(ctx context.Context, repoName string, aliases []string) error {
args := append([]string{repoName}, aliases...)
call := m.recordCall("TargetRepositoryRegister", args...)
return call.retErr
}
func (m *mockFFXClient) TargetSnapshot(ctx context.Context, dir string) error {
call := m.recordCall("TargetSnapshot", dir)
return call.retErr
}
func (m *mockFFXClient) Symbolize(ctx context.Context, input io.Reader, output io.Writer) error {
call := m.recordCall("Symbolize")
return call.retErr
}
// recordCall records a call and returns a predefined error if one exists.
func (m *mockFFXClient) recordCall(method string, args ...string) *mockCall {
m.t.Helper()
if m.callIdx >= len(m.calls) {
m.t.Fatalf("unexpected call %s (args: %v)", method, args)
return nil
}
expected := &m.calls[m.callIdx]
if expected.method != method {
m.t.Fatalf("expected call [%d] to %s, got %s", m.callIdx, expected.method, method)
}
if len(expected.args) > 0 || len(args) > 0 {
if diff := cmp.Diff(expected.args, args); diff != "" {
m.t.Fatalf("args mismatch for %s at call [%d] (-want +got):\n%s", method, m.callIdx, diff)
}
}
m.callIdx++
return expected
}
// expectCall adds an expected call to the mock.
func (m *mockFFXClient) expectCall(method string, args ...string) *mockFFXClient {
m.calls = append(m.calls, mockCall{
method: method,
args: args,
})
return m
}
// Returns sets the return value and error for the last expected call.
func (m *mockFFXClient) Returns(val any, err error) *mockFFXClient {
m.t.Helper()
if len(m.calls) == 0 {
m.t.Fatal("Returns called without a preceding expectCall")
}
lastIdx := len(m.calls) - 1
m.calls[lastIdx].retVal = val
m.calls[lastIdx].retErr = err
return m
}
// NewMockFFXClient creates a new mock for FFXClient.
func NewMockFFXClient(t *testing.T) *mockFFXClient {
return &mockFFXClient{t: t}
}
// runOrchestratorScenario is a helper function to run a common orchestrator test scenario.
func runOrchestratorScenario(t *testing.T, isEmulator bool, runInput *RunInput, deviceConfig *DeviceConfig) {
// Setup temporary directories for artifact paths
tmpDir := t.TempDir()
t.Setenv("TEST_UNDECLARED_OUTPUTS_DIR", tmpDir)
// Also ensure FUCHSIA_PACKAGE_SERVER_PORT is set to something stable if used
t.Setenv("FUCHSIA_PACKAGE_SERVER_PORT", "0")
// Prepare a mock FFXClient
mockFfx := NewMockFFXClient(t)
// Create a fake executable for Cmd calls
fakeFfx := filepath.Join(tmpDir, "fake_ffx")
if err := os.WriteFile(fakeFfx, []byte("#!/bin/bash\nexit 0"), 0755); err != nil {
t.Fatalf("failed to create fake ffx: %v", err)
}
mockFfx.fakeExecPath = fakeFfx
// --- Dynamic values for mock expectations ---
repoName := fmt.Sprintf("repo-%d", os.Getpid())
var emuName string
if isEmulator {
emuName = fmt.Sprintf("fuchsia-emulator-%d", os.Getpid())
}
// --- Set RunInput's FfxPath to the fake executable ---
if isEmulator {
runInput.Emulator.FfxPath = mockFfx.fakeExecPath
} else {
runInput.Hardware.FfxPath = mockFfx.fakeExecPath
}
// --- Build Expected Calls for Mock FFX Client ---
// Common FFX setup expectations
mockFfx.expectCall("SetupFfx", repoName)
wd, _ := os.Getwd()
productBundleDir := ""
targetRunInput := runInput.Target()
if targetRunInput.TransferURL != "" {
productBundleDir = filepath.Join(wd, "ffx-product-bundle")
mockFfx.expectCall("ProductDownload", targetRunInput.TransferURL, productBundleDir, "")
} else if targetRunInput.LocalPB != "" {
productBundleDir = targetRunInput.LocalPB
if !filepath.IsAbs(productBundleDir) {
productBundleDir = filepath.Join(wd, productBundleDir)
}
}
if isEmulator {
// Emulator-specific expectations
mockFfx.expectCall("EmuStart", productBundleDir, emuName, runInput.Emulator.Engine, runInput.Emulator.Device)
mockFfx.expectCall("SetDefaultTarget", emuName) // Pass emuName as actual string, mock will check pointer value.
} else {
// Hardware-specific expectations
mockFfx.expectCall("Flash", deviceConfig.FastbootSerial, productBundleDir, "")
}
// Package serving expectations (common)
repoDir := filepath.Join(wd, "repo")
mockFfx.expectCall("RepositoryCreate", repoDir)
publishArgs := append([]string{repoDir, productBundleDir}, targetRunInput.PackageArchives...)
mockFfx.expectCall("RepositoryPublish", publishArgs...)
for _, buildID := range targetRunInput.BuildIds {
mockFfx.expectCall("SymbolIndexAdd", buildID)
}
mockFfx.expectCall("RepositoryServerStart", repoName, repoDir, "[::]:0")
mockFfx.expectCall("IsPackageServerRunning", repoName)
mockFfx.expectCall("RepositoryServerList").Returns(`{"ok":{"data":[{"name":"mock-repo","address":"[::]:8080"}]}}`, nil)
// Reach device expectations (conditional on deviceConfig presence and not emulator)
if deviceConfig != nil && !isEmulator {
mockFfx.expectCall("TargetAdd", deviceConfig.Network.IPv4)
}
if deviceConfig != nil {
mockFfx.expectCall("SetDefaultTarget", deviceConfig.Network.IPv4)
}
mockFfx.expectCall("TargetList")
mockFfx.expectCall("TargetWait")
mockFfx.expectCall("TargetShow")
mockFfx.expectCall("TargetLogStart")
mockFfx.expectCall("TargetRepositoryRegister", repoName, "fuchsia.com", "chromium.org")
// Test execution environment setup (ApplyEnv)
mockFfx.expectCall("ApplyEnv")
// Snapshot call
mockFfx.expectCall("TargetSnapshot", tmpDir)
// Cleanup calls (LIFO order due to defers)
mockFfx.expectCall("Symbolize") // From stopFfxLog
mockFfx.expectCall("RepositoryServerStop", repoName) // From stopPackageServer
if isEmulator {
mockFfx.expectCall("EmuStop") // From stopEmulator
}
mockFfx.expectCall("Close") // From ffx.Close
// Create the orchestrator and inject mock
orchestrator := NewTestOrchestrator(deviceConfig)
orchestrator.ffx = mockFfx
orchestrator.repoName = repoName // Ensure orchestrator uses the fixed repoName for mock consistency
// Create a fake test command
testCmdPath := filepath.Join(tmpDir, "test_cmd.sh")
if err := os.WriteFile(testCmdPath, []byte("#!/bin/bash\necho 'mock test'"), 0755); err != nil {
t.Fatalf("write test cmd: %v", err)
}
// Run the orchestrator
err := orchestrator.Run(context.Background(), runInput, []string{testCmdPath})
if err != nil {
t.Errorf("orchestrator.Run failed: %v", err)
}
// Assert critical environment variables after ApplyEnv call occurred
if mockFfx.recordedEnv == nil {
t.Fatalf("mockFfx.recordedEnv is nil after ApplyEnv")
}
ffxIsolateDirFound := false
for _, e := range mockFfx.recordedEnv {
if strings.HasPrefix(e, "FFX_ISOLATE_DIR=") && strings.Contains(e, tmpDir) {
ffxIsolateDirFound = true
}
}
if !ffxIsolateDirFound {
t.Errorf("FFX_ISOLATE_DIR not found or incorrect in mockFfx.recordedEnv: %v", mockFfx.recordedEnv)
}
// Check FUCHSIA_NODENAME for emulator scenario
if isEmulator {
nodenameFound := false
expectedNodenameEnv := fmt.Sprintf("FUCHSIA_NODENAME=%s", emuName)
for _, e := range mockFfx.recordedEnv {
if e == expectedNodenameEnv {
nodenameFound = true
break
}
}
if !nodenameFound {
t.Errorf("FUCHSIA_NODENAME not found in env. Expected %q, got %v", expectedNodenameEnv, mockFfx.recordedEnv)
}
}
// Verify all expected calls were made
if mockFfx.callIdx != len(mockFfx.calls) {
t.Errorf("Mock not fully exercised. Stopped at call %d. Next expected: %s", mockFfx.callIdx, mockFfx.calls[mockFfx.callIdx].method)
// Print remaining expected calls for debugging
for i := mockFfx.callIdx; i < len(mockFfx.calls); i++ {
t.Logf("Remaining expected call [%d]: Method: %s, Args: %v", i, mockFfx.calls[i].method, mockFfx.calls[i].args)
}
}
}
func TestOrchestrator_Run_Unit(t *testing.T) {
deviceConfig := &DeviceConfig{
FastbootSerial: "serial123",
Network: DeviceNetworkConfig{IPv4: "192.168.1.10"},
}
runInput := &RunInput{
Hardware: TargetRunInput{
TransferURL: "gs://bucket/product.json",
PackageArchives: []string{"/tmp/pkg1.far"},
BuildIds: []string{"abc1234"},
},
}
runOrchestratorScenario(t, false, runInput, deviceConfig)
}
func TestOrchestrator_Run_EmulatorUnit(t *testing.T) {
runInput := &RunInput{
Emulator: EmulatorRunInput{
TargetRunInput: TargetRunInput{
TransferURL: "gs://bucket/product.json",
BuildIds: []string{"abc1234"},
},
},
}
runOrchestratorScenario(t, true, runInput, nil)
}
func TestOrchestrator_Run_EmulatorLocalPB(t *testing.T) {
runInput := &RunInput{
Emulator: EmulatorRunInput{
TargetRunInput: TargetRunInput{
LocalPB: "relative/path/to/pb",
BuildIds: []string{"abc1234"},
},
},
}
runOrchestratorScenario(t, true, runInput, nil)
}
func TestOrchestrator_Run_HardwareLocalPB(t *testing.T) {
deviceConfig := &DeviceConfig{
FastbootSerial: "serial123",
Network: DeviceNetworkConfig{IPv4: "192.168.1.10"},
}
runInput := &RunInput{
Hardware: TargetRunInput{
LocalPB: "relative/path/to/pb",
PackageArchives: []string{"/tmp/pkg1.far"},
BuildIds: []string{"abc1234"},
},
}
runOrchestratorScenario(t, false, runInput, deviceConfig)
}
// TestInstantiateFfx_PathResolution verifies that instantiateFfx correctly resolves ffx_path.
// Specifically, it ensures that:
// 1. Absolute paths are preserved as-is (not corrupted by joining with CWD).
// 2. Relative paths are correctly resolved to absolute paths relative to CWD.
//
// TODO: This test is only needed for the custom local ffx wrapper.
// Once orchestrate is migrated to use the ffxutil library (which handles absolute
// path resolution internally), this test can be safely deleted.
func TestInstantiateFfx_PathResolution(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("TEST_UNDECLARED_OUTPUTS_DIR", tmpDir)
// Create a fake ffx executable shell script that exits 0.
// ffx.New initialization runs 'config env set' command to set default configs,
// so the ffxPath MUST point to a valid executable that returns 0 when run.
fakeFfxName := "fake_ffx"
fakeFfxPath := filepath.Join(tmpDir, fakeFfxName)
if err := os.WriteFile(fakeFfxPath, []byte("#!/bin/bash\nexit 0"), 0755); err != nil {
t.Fatalf("failed to create fake ffx: %v", err)
}
// Calculate a relative path from the current working directory to our fake ffx binary
wd, err := os.Getwd()
if err != nil {
t.Fatalf("os.Getwd: %v", err)
}
relFfxPath, err := filepath.Rel(wd, fakeFfxPath)
if err != nil {
t.Fatalf("filepath.Rel: %v", err)
}
tests := []struct {
name string
inputFfxPath string
wantAbs bool
wantErr bool
}{
{
name: "Absolute path remains absolute",
inputFfxPath: fakeFfxPath, // This is absolute since tmpDir is absolute
wantAbs: true,
wantErr: false,
},
{
name: "Relative path becomes absolute",
inputFfxPath: relFfxPath, // Use dynamically computed relative path
wantAbs: true,
wantErr: false,
},
{
name: "Empty path returns error",
inputFfxPath: "",
wantAbs: false,
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Instantiate orchestrator without any pre-injected mock FFX client
orchestrator := NewTestOrchestrator(nil)
runInput := &RunInput{
Emulator: EmulatorRunInput{
TargetRunInput: TargetRunInput{
FfxPath: tc.inputFfxPath,
},
},
}
// This will trigger the actual ffxPath resolution and call ffx.New()
err = orchestrator.instantiateFfx(context.Background(), runInput)
if (err != nil) != tc.wantErr {
t.Fatalf("instantiateFfx returned err: %v, wantErr: %v", err, tc.wantErr)
}
if tc.wantErr {
return
}
if orchestrator.ffx == nil {
t.Fatalf("orchestrator.ffx is nil")
}
// Type-assert to the concrete *ffx.Ffx struct to access its Cmd method.
realFfx, ok := orchestrator.ffx.(*ffx.Ffx)
if !ok {
t.Fatalf("expected orchestrator.ffx to be *ffx.Ffx, got %T", orchestrator.ffx)
}
cmd, err := realFfx.CmdContext(context.Background(), "version")
if err != nil {
t.Fatalf("ffx.Cmd failed: %v", err)
}
resolvedPath := cmd.Path
if tc.wantAbs && !filepath.IsAbs(resolvedPath) {
t.Errorf("expected absolute path, got: %s", resolvedPath)
}
// Verify that the resolved path actually exists on disk
if _, err := os.Stat(resolvedPath); err != nil {
t.Errorf("resolved path does not exist: %s, err: %v", resolvedPath, err)
}
})
}
}