diff --git a/go/internal/e2e/agent_and_compact_rpc_e2e_test.go b/go/internal/e2e/agent_and_compact_rpc_e2e_test.go index c02a8571d..9d1daafdc 100644 --- a/go/internal/e2e/agent_and_compact_rpc_e2e_test.go +++ b/go/internal/e2e/agent_and_compact_rpc_e2e_test.go @@ -12,10 +12,7 @@ import ( ) func TestAgentSelectionRPCE2E(t *testing.T) { - cliPath := testharness.CLIPath() - if cliPath == "" { - t.Fatal("CLI not found. Run 'npm install' in the nodejs directory first.") - } + cliPath := testharness.CLIPath(t) t.Run("should list available custom agents", func(t *testing.T) { client := copilot.NewClient(&copilot.ClientOptions{ diff --git a/go/internal/e2e/client_e2e_test.go b/go/internal/e2e/client_e2e_test.go index d7fc3f06a..95b583fe8 100644 --- a/go/internal/e2e/client_e2e_test.go +++ b/go/internal/e2e/client_e2e_test.go @@ -9,10 +9,7 @@ import ( ) func TestClientE2E(t *testing.T) { - cliPath := testharness.CLIPath() - if cliPath == "" { - t.Fatal("CLI not found. Run 'npm install' in the nodejs directory first.") - } + cliPath := testharness.CLIPath(t) t.Run("should start and connect to server using stdio", func(t *testing.T) { client := copilot.NewClient(&copilot.ClientOptions{ diff --git a/go/internal/e2e/inprocess_ffi_e2e_test.go b/go/internal/e2e/inprocess_ffi_e2e_test.go index 6923384a2..1620a9885 100644 --- a/go/internal/e2e/inprocess_ffi_e2e_test.go +++ b/go/internal/e2e/inprocess_ffi_e2e_test.go @@ -26,10 +26,7 @@ func TestInProcessFfiE2E(t *testing.T) { t.Skip("in-process FFI smoke test runs only under the inprocess transport cell") } - cliPath := testharness.CLIPath() - if cliPath == "" { - t.Fatal("CLI not found. Run 'npm install' in the nodejs directory first.") - } + cliPath := testharness.CLIPath(t) t.Setenv("COPILOT_CLI_PATH", cliPath) t.Run("should start and connect over in-process FFI", func(t *testing.T) { diff --git a/go/internal/e2e/rpc_e2e_test.go b/go/internal/e2e/rpc_e2e_test.go index fcf843814..856721def 100644 --- a/go/internal/e2e/rpc_e2e_test.go +++ b/go/internal/e2e/rpc_e2e_test.go @@ -10,10 +10,7 @@ import ( ) func TestRPCE2E(t *testing.T) { - cliPath := testharness.CLIPath() - if cliPath == "" { - t.Fatal("CLI not found. Run 'npm install' in the nodejs directory first.") - } + cliPath := testharness.CLIPath(t) t.Run("should call RPC.Ping with typed params and result", func(t *testing.T) { client := copilot.NewClient(&copilot.ClientOptions{ diff --git a/go/internal/e2e/testharness/context.go b/go/internal/e2e/testharness/context.go index b73153dff..52ba0e084 100644 --- a/go/internal/e2e/testharness/context.go +++ b/go/internal/e2e/testharness/context.go @@ -11,6 +11,7 @@ import ( "time" copilot "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/internal/ffihost" ) const defaultGitHubToken = "fake-token-for-e2e-tests" @@ -21,7 +22,9 @@ var ( ) // CLIPath returns the path to the Copilot CLI, discovering it once and caching. -func CLIPath() string { +func CLIPath(t testing.TB) string { + t.Helper() + cliPathOnce.Do(func() { // Check environment variable first if path := os.Getenv("COPILOT_CLI_PATH"); path != "" { @@ -34,15 +37,86 @@ func CLIPath() string { // index.js ships in the installed platform package // (e.g. @github/copilot-linux-x64). base := RepoPath("nodejs", "node_modules", "@github") - matches, _ := filepath.Glob(filepath.Join(base, "copilot-*", "index.js")) - if len(matches) > 0 { - cliPath = matches[0] - return - } + packageNames := cliPlatformPackageNames(ffihost.PrebuildsFolder()) + cliPath = findCLIInNodeModules(base, packageNames) }) + + if fileExists(cliPath) { + return cliPath + } + + if envPath := os.Getenv("COPILOT_CLI_PATH"); envPath != "" { + t.Fatalf("CLI not found at %q from COPILOT_CLI_PATH", envPath) + } + + base := RepoPath("nodejs", "node_modules", "@github") + packageNames := cliPlatformPackageNames(ffihost.PrebuildsFolder()) + installed := installedCLIPackageNames(base) + triedDescription := strings.Join(packageNames, ", ") + if triedDescription == "" { + triedDescription = "none (unsupported platform)" + } + installedDescription := strings.Join(installed, ", ") + if installedDescription == "" { + installedDescription = "none" + } + t.Fatalf( + "CLI not found for tests under %s (tried: %s; present: %s). Run 'npm install' in the nodejs directory, or set COPILOT_CLI_PATH.", + base, + triedDescription, + installedDescription, + ) return cliPath } +func cliPlatformPackageNames(platform string) []string { + if platform == "" { + return nil + } + + names := []string{"copilot-" + platform} + if !strings.HasPrefix(platform, "linux") { + return names + } + + _, arch, ok := strings.Cut(platform, "-") + if !ok { + return names + } + for _, variant := range []string{"linux-" + arch, "linuxmusl-" + arch} { + name := "copilot-" + variant + if name != names[0] { + names = append(names, name) + } + } + return names +} + +func findCLIInNodeModules(githubModules string, packageNames []string) string { + for _, packageName := range packageNames { + candidate := filepath.Join(githubModules, packageName, "index.js") + if fileExists(candidate) { + return candidate + } + } + return "" +} + +func installedCLIPackageNames(githubModules string) []string { + entries, err := os.ReadDir(githubModules) + if err != nil { + return nil + } + + var names []string + for _, entry := range entries { + if entry.IsDir() && strings.HasPrefix(entry.Name(), "copilot-") { + names = append(names, entry.Name()) + } + } + return names +} + // TestContext holds shared resources for E2E tests. type TestContext struct { CLIPath string @@ -118,10 +192,7 @@ func SkipIfInProcess(t *testing.T, reason string) { func NewTestContext(t *testing.T) *TestContext { t.Helper() - cliPath := CLIPath() - if cliPath == "" || !fileExists(cliPath) { - t.Fatalf("CLI not found at %s. Run 'npm install' in the nodejs directory first.", cliPath) - } + cliPath := CLIPath(t) homeDir, err := os.MkdirTemp("", "copilot-test-config-") if err != nil { diff --git a/go/internal/e2e/testharness/context_test.go b/go/internal/e2e/testharness/context_test.go new file mode 100644 index 000000000..97c48dae9 --- /dev/null +++ b/go/internal/e2e/testharness/context_test.go @@ -0,0 +1,112 @@ +package testharness + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestCLIPlatformPackageNames(t *testing.T) { + tests := []struct { + name string + platform string + want []string + }{ + { + name: "macOS", + platform: "darwin-arm64", + want: []string{"copilot-darwin-arm64"}, + }, + { + name: "Windows", + platform: "win32-x64", + want: []string{"copilot-win32-x64"}, + }, + { + name: "Linux glibc", + platform: "linux-x64", + want: []string{"copilot-linux-x64", "copilot-linuxmusl-x64"}, + }, + { + name: "Linux musl", + platform: "linuxmusl-arm64", + want: []string{"copilot-linuxmusl-arm64", "copilot-linux-arm64"}, + }, + { + name: "unsupported platform", + platform: "", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := cliPlatformPackageNames(tt.platform); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("cliPlatformPackageNames(%q) = %v, want %v", tt.platform, got, tt.want) + } + }) + } +} + +func TestFindCLIInNodeModulesSelectsCurrentPlatform(t *testing.T) { + githubModules := t.TempDir() + makeCLIEntrypoint(t, githubModules, "copilot-darwin-arm64") + makeCLIEntrypoint(t, githubModules, "copilot-language-server") + want := makeCLIEntrypoint(t, githubModules, "copilot-linux-x64") + + got := findCLIInNodeModules(githubModules, []string{"copilot-linux-x64", "copilot-linuxmusl-x64"}) + if got != want { + t.Fatalf("findCLIInNodeModules() = %q, want %q", got, want) + } +} + +func TestFindCLIInNodeModulesUsesCandidateOrder(t *testing.T) { + githubModules := t.TempDir() + makeCLIEntrypoint(t, githubModules, "copilot-linux-x64") + want := makeCLIEntrypoint(t, githubModules, "copilot-linuxmusl-x64") + + got := findCLIInNodeModules(githubModules, []string{"copilot-linuxmusl-x64", "copilot-linux-x64"}) + if got != want { + t.Fatalf("findCLIInNodeModules() = %q, want %q", got, want) + } +} + +func TestFindCLIInNodeModulesReturnsEmptyWithoutCandidate(t *testing.T) { + githubModules := t.TempDir() + makeCLIEntrypoint(t, githubModules, "copilot-win32-x64") + if err := os.Mkdir(filepath.Join(githubModules, "copilot-linux-x64"), 0o755); err != nil { + t.Fatalf("Mkdir(): %v", err) + } + + if got := findCLIInNodeModules(githubModules, []string{"copilot-linux-x64"}); got != "" { + t.Fatalf("findCLIInNodeModules() = %q, want empty path", got) + } +} + +func TestInstalledCLIPackageNames(t *testing.T) { + githubModules := t.TempDir() + makeCLIEntrypoint(t, githubModules, "copilot-win32-x64") + makeCLIEntrypoint(t, githubModules, "copilot-darwin-arm64") + if err := os.Mkdir(filepath.Join(githubModules, "not-copilot"), 0o755); err != nil { + t.Fatalf("Mkdir(): %v", err) + } + + want := []string{"copilot-darwin-arm64", "copilot-win32-x64"} + if got := installedCLIPackageNames(githubModules); !reflect.DeepEqual(got, want) { + t.Fatalf("installedCLIPackageNames() = %v, want %v", got, want) + } +} + +func makeCLIEntrypoint(t *testing.T, githubModules, packageName string) string { + t.Helper() + packageDir := filepath.Join(githubModules, packageName) + if err := os.MkdirAll(packageDir, 0o755); err != nil { + t.Fatalf("MkdirAll(): %v", err) + } + entrypoint := filepath.Join(packageDir, "index.js") + if err := os.WriteFile(entrypoint, []byte("// test CLI entrypoint\n"), 0o600); err != nil { + t.Fatalf("WriteFile(): %v", err) + } + return entrypoint +} diff --git a/go/test.sh b/go/test.sh index dfb7bac1d..e8c34face 100755 --- a/go/test.sh +++ b/go/test.sh @@ -17,14 +17,52 @@ fi if [ -z "$COPILOT_CLI_PATH" ]; then # Try to find it relative to the SDK. As of CLI 1.0.64-1 the @github/copilot # package is a thin loader; the runnable index.js ships in the installed - # platform package (e.g. @github/copilot-linux-x64). Exactly one is installed. + # platform package (e.g. @github/copilot-linux-x64). SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - POTENTIAL_PATH="$(ls "$SCRIPT_DIR"/../nodejs/node_modules/@github/copilot-*/index.js 2>/dev/null | head -n1)" + case "$(go env GOHOSTARCH)" in + amd64) NPM_ARCH="x64" ;; + arm64) NPM_ARCH="arm64" ;; + *) + echo "❌ Unsupported Go architecture: $(go env GOHOSTARCH)" + exit 1 + ;; + esac + + NPM_PLATFORMS=() + case "$(go env GOHOSTOS)" in + darwin) NPM_PLATFORMS=("darwin") ;; + windows) NPM_PLATFORMS=("win32") ;; + linux) + if ldd --version 2>&1 | grep -qi musl; then + NPM_PLATFORMS=("linuxmusl" "linux") + else + NPM_PLATFORMS=("linux" "linuxmusl") + fi + ;; + *) + echo "❌ Unsupported Go platform: $(go env GOHOSTOS)" + exit 1 + ;; + esac + + POTENTIAL_PATH="" + TRIED_PACKAGES=() + for NPM_PLATFORM in "${NPM_PLATFORMS[@]}"; do + PACKAGE_NAME="copilot-${NPM_PLATFORM}-${NPM_ARCH}" + TRIED_PACKAGES+=("@github/${PACKAGE_NAME}") + CANDIDATE="$SCRIPT_DIR/../nodejs/node_modules/@github/${PACKAGE_NAME}/index.js" + if [ -f "$CANDIDATE" ]; then + POTENTIAL_PATH="$CANDIDATE" + break + fi + done + if [ -n "$POTENTIAL_PATH" ] && [ -f "$POTENTIAL_PATH" ]; then export COPILOT_CLI_PATH="$POTENTIAL_PATH" echo "📍 Auto-detected CLI path: $COPILOT_CLI_PATH" else echo "❌ COPILOT_CLI_PATH environment variable not set" + echo " Tried platform packages: ${TRIED_PACKAGES[*]}" echo " Run: export COPILOT_CLI_PATH=/path/to/dist-cli/index.js" exit 1 fi