From f2e08cf0d75e030f5791f81f5add84eca3ce676e Mon Sep 17 00:00:00 2001 From: qmuntal Date: Wed, 2 Sep 2026 10:10:41 +0200 Subject: [PATCH 1/3] [go] Add test CLI setup helper --- docs/setup/bundled-cli.md | 2 +- docs/setup/local-cli.md | 2 +- go/README.md | 29 ++- go/cmd/bundler/main.go | 94 +------- go/internal/npmregistry/package.go | 203 +++++++++++++++++ go/internal/npmregistry/package_test.go | 139 ++++++++++++ go/testcli/setup.go | 275 ++++++++++++++++++++++++ go/testcli/setup_external_test.go | 52 +++++ go/testcli/setup_test.go | 46 ++++ 9 files changed, 756 insertions(+), 86 deletions(-) create mode 100644 go/internal/npmregistry/package.go create mode 100644 go/internal/npmregistry/package_test.go create mode 100644 go/testcli/setup.go create mode 100644 go/testcli/setup_external_test.go create mode 100644 go/testcli/setup_test.go diff --git a/docs/setup/bundled-cli.md b/docs/setup/bundled-cli.md index 9d9c80c35b..ad040ecfc4 100644 --- a/docs/setup/bundled-cli.md +++ b/docs/setup/bundled-cli.md @@ -82,7 +82,7 @@ await client.stop() Go > [!NOTE] -> Unlike Node.js, Python, and .NET, the Go SDK does not include a CLI as an automatic dependency. With no explicit path, `NewClient(nil)` uses an embedded CLI when available, then falls back to `copilot` on `PATH`. To embed a CLI, run the [bundler tool](../../go/README.md#distributing-your-application-with-an-embedded-github-copilot-cli) at build time. You can also set `COPILOT_CLI_PATH` or point a `Connection` at an existing binary. See [Local CLI Setup](./local-cli.md) for details. +> Unlike Node.js, Python, and .NET, the Go SDK does not include a CLI as an automatic dependency. `NewClient(nil)` uses `COPILOT_CLI_PATH` when set, then an embedded CLI when available; it does not scan `PATH`. To embed a CLI, run the [bundler tool](../../go/README.md#distributing-your-application-with-an-embedded-github-copilot-cli) at build time. You can also point a `Connection` at an existing binary. See [Local CLI Setup](./local-cli.md) for details. ```go diff --git a/docs/setup/local-cli.md b/docs/setup/local-cli.md index 79a656396e..e62ef8bef2 100644 --- a/docs/setup/local-cli.md +++ b/docs/setup/local-cli.md @@ -78,7 +78,7 @@ await client.stop() Go > [!NOTE] -> The Go SDK does not ship a CLI automatically. Install `copilot` on `PATH`, set the `COPILOT_CLI_PATH` environment variable, embed a CLI with the [bundler tool](../../go/README.md#distributing-your-application-with-an-embedded-github-copilot-cli), or point `StdioConnection.Path` at an installed binary. +> The Go SDK does not ship a CLI automatically or scan `PATH`. Set the `COPILOT_CLI_PATH` environment variable, embed a CLI with the [bundler tool](../../go/README.md#distributing-your-application-with-an-embedded-github-copilot-cli), or point `StdioConnection.Path` at an installed binary. ```go diff --git a/go/README.md b/go/README.md index 6c85f93ae1..350f73c371 100644 --- a/go/README.md +++ b/go/README.md @@ -7,7 +7,8 @@ A Go SDK for programmatic access to the GitHub Copilot CLI. To use the SDK, you'll need: - Go 1.24 or later -- GitHub Copilot CLI installed and in `PATH` (or set `COPILOT_CLI_PATH`) +- A compatible Copilot runtime provided through `COPILOT_CLI_PATH` or embedded + with the bundler described below ## Installation @@ -108,6 +109,32 @@ That's it! When your application calls `copilot.NewClient` without a `Connection The bundler prepares the native runtime library required by the [in-process transport](#in-process-transport-experimental). It is included in the application only when building with the `copilot_inprocess` build tag. +Downstream modules can prepare a compatible CLI for integration tests without +generating embedded assets or invoking npm. Call `testcli.Setup` from the test +package's `TestMain`: + +```go +import ( + "log" + "os" + "testing" + + "github.com/github/copilot-sdk/go/testcli" +) + +func TestMain(m *testing.M) { + if err := testcli.Setup(); err != nil { + log.Fatal(err) + } + os.Exit(m.Run()) +} +``` + +This test-only helper resolves the platform package pinned by the SDK version, +downloads it directly with Go, verifies its lockfile SHA-512 integrity, caches +the extracted runtime, and sets `COPILOT_CLI_PATH`. An existing +`COPILOT_CLI_PATH` is honored. + ## In-process transport (Experimental) > **Experimental:** the in-process API may change in a future release. diff --git a/go/cmd/bundler/main.go b/go/cmd/bundler/main.go index 89f99daf1b..6cafd55383 100644 --- a/go/cmd/bundler/main.go +++ b/go/cmd/bundler/main.go @@ -31,6 +31,7 @@ import ( "runtime" "strings" + "github.com/github/copilot-sdk/go/internal/npmregistry" "github.com/klauspost/compress/zstd" ) @@ -38,7 +39,6 @@ const ( // Keep these URLs centralized so reviewers can verify all outbound calls in one place. sdkModule = "github.com/github/copilot-sdk/go" packageLockURLFmt = "https://raw.githubusercontent.com/github/copilot-sdk/%s/nodejs/package-lock.json" - tarballURLFmt = "https://registry.npmjs.org/@github/copilot-%s/-/copilot-%s-%s.tgz" licenseTarballFmt = "https://registry.npmjs.org/@github/copilot/-/copilot-%s.tgz" defaultPackageName = "main" ) @@ -430,11 +430,10 @@ func buildBundle(info platformInfo, cliVersion, outputPath, goos string) (bundle } rawLibPath := filepath.Join(tempDir, "runtime.node") - if err := extractFileFromTarball( + if err := npmregistry.ExtractFile( tarballPath, - tempDir, "package/prebuilds/"+info.npmPlatform+"/runtime.node", - "runtime.node", + rawLibPath, ); err != nil { return bundleArtifacts{}, fmt.Errorf("runtime package is missing prebuilds/%s/runtime.node: %w", info.npmPlatform, err) } @@ -448,11 +447,10 @@ func buildBundle(info platformInfo, cliVersion, outputPath, goos string) (bundle wrapperName := runtimeWrapperName(info.binaryName) rawWrapperPath := filepath.Join(tempDir, wrapperName) - if err := extractFileFromTarball( + if err := npmregistry.ExtractFile( tarballPath, - tempDir, "package/prebuilds/"+info.npmPlatform+"/"+wrapperName, - wrapperName, + rawWrapperPath, ); err != nil { return bundleArtifacts{}, fmt.Errorf("runtime package is missing prebuilds/%s/%s: %w", info.npmPlatform, wrapperName, err) } @@ -896,19 +894,12 @@ func mustDecodeBase64(s string) []byte { // returns the extracted binary path and the downloaded tarball path (retained so // callers can extract additional files, such as the runtime library). func downloadCLIBinary(npmPlatform, binaryName, cliVersion, destDir string) (string, string, error) { - tarballURL := fmt.Sprintf(tarballURLFmt, npmPlatform, npmPlatform, cliVersion) - - fmt.Printf("Downloading from %s...\n", tarballURL) - - resp, err := http.Get(tarballURL) + tarballURL, err := npmregistry.TarballURL("@github/copilot-"+npmPlatform, cliVersion) if err != nil { - return "", "", fmt.Errorf("failed to download: %w", err) + return "", "", err } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return "", "", fmt.Errorf("failed to download: %s", resp.Status) - } + fmt.Printf("Downloading from %s...\n", tarballURL) // Save tarball to temp file tarballPath := filepath.Join(destDir, fmt.Sprintf("copilot-%s-%s.tgz", npmPlatform, cliVersion)) @@ -917,9 +908,9 @@ func downloadCLIBinary(npmPlatform, binaryName, cliVersion, destDir string) (str return "", "", fmt.Errorf("failed to create tarball file: %w", err) } - if _, err := io.Copy(tarballFile, resp.Body); err != nil { + if err := npmregistry.Download(http.DefaultClient, tarballURL, "", tarballFile); err != nil { tarballFile.Close() - return "", "", fmt.Errorf("failed to save tarball: %w", err) + return "", "", fmt.Errorf("failed to download: %w", err) } if err := tarballFile.Close(); err != nil { return "", "", fmt.Errorf("failed to close tarball file: %w", err) @@ -927,7 +918,7 @@ func downloadCLIBinary(npmPlatform, binaryName, cliVersion, destDir string) (str // Extract only the CLI binary to avoid unpacking the full package tree. binaryPath := filepath.Join(destDir, binaryName) - if err := extractFileFromTarball(tarballPath, destDir, "package/"+binaryName, binaryName); err != nil { + if err := npmregistry.ExtractFile(tarballPath, "package/"+binaryName, binaryPath); err != nil { return "", "", fmt.Errorf("failed to extract binary: %w", err) } @@ -1033,69 +1024,6 @@ func extractFileFromTarballStream(r io.Reader, destDir, outputName string, mode return outFile.Close() } -// extractFileFromTarball extracts a single file from a .tgz into destDir with a new name. -func extractFileFromTarball(tarballPath, destDir, targetPath, outputName string) error { - file, err := os.Open(tarballPath) - if err != nil { - return err - } - defer file.Close() - - gzReader, err := gzip.NewReader(file) - if err != nil { - return fmt.Errorf("failed to create gzip reader: %w", err) - } - defer gzReader.Close() - - tarReader := tar.NewReader(gzReader) - - for { - header, err := tarReader.Next() - if err == io.EOF { - break - } - if err != nil { - return fmt.Errorf("failed to read tar: %w", err) - } - - if header.Name == targetPath { - outPath := filepath.Join(destDir, outputName) - outFile, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode)) - if err != nil { - return fmt.Errorf("failed to create output file: %w", err) - } - - if _, err := io.Copy(outFile, tarReader); err != nil { - if cerr := outFile.Close(); cerr != nil { - return fmt.Errorf("failed to extract binary (copy error: %v, close error: %v)", err, cerr) - } - return fmt.Errorf("failed to extract binary: %w", err) - } - if err := outFile.Close(); err != nil { - return fmt.Errorf("failed to close output file: %w", err) - } - return nil - } - } - - return fmt.Errorf("file %q not found in tarball", targetPath) -} - -// extractOptionalFileFromTarball extracts a single file from a .tgz into destDir -// like extractFileFromTarball, but returns (false, nil) instead of an error when -// the file is absent. Used for the runtime library, which older CLI packages do -// not ship. -func extractOptionalFileFromTarball(tarballPath, destDir, targetPath, outputName string) (bool, error) { - err := extractFileFromTarball(tarballPath, destDir, targetPath, outputName) - if err == nil { - return true, nil - } - if strings.Contains(err.Error(), "not found in tarball") { - return false, nil - } - return false, err -} - // compressZstdFile compresses src into dst using zstd. func compressZstdFile(src, dst string) error { srcFile, err := os.Open(src) diff --git a/go/internal/npmregistry/package.go b/go/internal/npmregistry/package.go new file mode 100644 index 0000000000..c9317f33d7 --- /dev/null +++ b/go/internal/npmregistry/package.go @@ -0,0 +1,203 @@ +package npmregistry + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha512" + "encoding/base64" + "fmt" + "hash" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" +) + +const registryURL = "https://registry.npmjs.org" + +// TarballURL returns the registry tarball URL for an npm package version. +func TarballURL(packageName, version string) (string, error) { + if packageName == "" || version == "" { + return "", fmt.Errorf("npm package name and version are required") + } + baseName := packageName + if scope, name, scoped := strings.Cut(packageName, "/"); scoped { + if !strings.HasPrefix(scope, "@") || len(scope) == 1 || name == "" || strings.Contains(name, "/") { + return "", fmt.Errorf("invalid npm package name %q", packageName) + } + baseName = name + } else if strings.ContainsAny(packageName, `\`) { + return "", fmt.Errorf("invalid npm package name %q", packageName) + } + return fmt.Sprintf( + "%s/%s/-/%s-%s.tgz", + registryURL, + escapePackagePath(packageName), + url.PathEscape(baseName), + url.PathEscape(version), + ), nil +} + +func escapePackagePath(packageName string) string { + parts := strings.Split(packageName, "/") + for index := range parts { + parts[index] = url.PathEscape(parts[index]) + } + return strings.Join(parts, "/") +} + +// Download writes a registry tarball to destination and verifies an optional +// npm SHA-512 integrity value. +func Download(client *http.Client, tarballURL, integrity string, destination io.Writer) error { + if client == nil { + client = http.DefaultClient + } + response, err := client.Get(tarballURL) + if err != nil { + return fmt.Errorf("downloading %s: %w", tarballURL, err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return fmt.Errorf("downloading %s: %s", tarballURL, response.Status) + } + + writer := destination + var expectedHash []byte + var digest hash.Hash + if integrity != "" { + expectedHash, err = sha512Integrity(integrity) + if err != nil { + return err + } + digest = sha512.New() + writer = io.MultiWriter(destination, digest) + } + if _, err := io.Copy(writer, response.Body); err != nil { + return fmt.Errorf("saving %s: %w", tarballURL, err) + } + if digest != nil && !bytes.Equal(digest.Sum(nil), expectedHash) { + return fmt.Errorf("integrity check failed for %s", tarballURL) + } + return nil +} + +func sha512Integrity(integrity string) ([]byte, error) { + for candidate := range strings.FieldsSeq(integrity) { + encoded, ok := strings.CutPrefix(candidate, "sha512-") + if !ok { + continue + } + hash, err := base64.StdEncoding.DecodeString(encoded) + if err == nil && len(hash) == sha512.Size { + return hash, nil + } + } + return nil, fmt.Errorf("invalid sha512 npm integrity %q", integrity) +} + +// ExtractPackage safely extracts package/ entries from an npm tarball. +func ExtractPackage(tarballPath, destination string) error { + return visitTarball(tarballPath, func(header *tar.Header, reader io.Reader) (bool, error) { + if !strings.HasPrefix(header.Name, "package/") { + return false, nil + } + relativePath := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(header.Name, "package/"))) + if relativePath == "." { + return false, nil + } + if !filepath.IsLocal(relativePath) { + return false, fmt.Errorf("unsafe archive path %q", header.Name) + } + path := filepath.Join(destination, relativePath) + switch header.Typeflag { + case tar.TypeDir: + return false, os.MkdirAll(path, 0755) + case tar.TypeReg: + return false, writeFile(path, reader, os.FileMode(header.Mode)) + case tar.TypeSymlink, tar.TypeLink: + return false, fmt.Errorf("unsafe archive link %q", header.Name) + default: + return false, nil + } + }) +} + +// ExtractFile extracts one regular file from a tarball to destination. +func ExtractFile(tarballPath, targetPath, destination string) error { + found := false + err := visitTarball(tarballPath, func(header *tar.Header, reader io.Reader) (bool, error) { + if header.Name != targetPath { + return false, nil + } + if header.Typeflag != tar.TypeReg { + return false, fmt.Errorf("archive entry %q is not a regular file", targetPath) + } + found = true + return true, writeFile(destination, reader, os.FileMode(header.Mode)) + }) + if err != nil { + return err + } + if !found { + return fmt.Errorf("file %q not found in tarball", targetPath) + } + return nil +} + +func visitTarball(tarballPath string, visit func(*tar.Header, io.Reader) (bool, error)) error { + file, err := os.Open(tarballPath) + if err != nil { + return err + } + defer file.Close() + gzipReader, err := gzip.NewReader(file) + if err != nil { + return fmt.Errorf("creating gzip reader: %w", err) + } + defer gzipReader.Close() + tarReader := tar.NewReader(gzipReader) + for { + header, err := tarReader.Next() + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("reading tarball: %w", err) + } + done, err := visit(header, tarReader) + if err != nil { + return err + } + if done { + return nil + } + } +} + +func writeFile(path string, reader io.Reader, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + mode &= 0777 + if mode == 0 { + mode = 0644 + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) + if err != nil { + return err + } + _, copyErr := io.Copy(file, reader) + closeErr := file.Close() + if copyErr != nil { + os.Remove(path) + return copyErr + } + if closeErr != nil { + os.Remove(path) + return closeErr + } + return nil +} diff --git a/go/internal/npmregistry/package_test.go b/go/internal/npmregistry/package_test.go new file mode 100644 index 0000000000..ab497a5ae6 --- /dev/null +++ b/go/internal/npmregistry/package_test.go @@ -0,0 +1,139 @@ +package npmregistry + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha512" + "encoding/base64" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestTarballURL(t *testing.T) { + got, err := TarballURL("@github/copilot-win32-x64", "1.2.3-0") + if err != nil { + t.Fatal(err) + } + want := "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.2.3-0.tgz" + if got != want { + t.Fatalf("TarballURL() = %q, want %q", got, want) + } +} + +func TestDownloadVerifiesIntegrity(t *testing.T) { + content := []byte("package archive") + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Write(content) + })) + defer server.Close() + hash := sha512.Sum512(content) + integrity := "sha512-" + base64.StdEncoding.EncodeToString(hash[:]) + + var destination bytes.Buffer + if err := Download(server.Client(), server.URL, integrity, &destination); err != nil { + t.Fatal(err) + } + if !bytes.Equal(destination.Bytes(), content) { + t.Fatalf("Download() wrote %q", destination.Bytes()) + } + + wrongHash := sha512.Sum512([]byte("different")) + err := Download(server.Client(), server.URL, "sha512-"+base64.StdEncoding.EncodeToString(wrongHash[:]), &bytes.Buffer{}) + if err == nil || !strings.Contains(err.Error(), "integrity check failed") { + t.Fatalf("Download() error = %v", err) + } +} + +func TestExtractPackage(t *testing.T) { + archivePath := writeTestTarball(t, map[string]testTarEntry{ + "package/package.json": {content: "{}"}, + "package/prebuilds/linux-x64/runtime.node": {content: "runtime"}, + "outside": {content: "ignored"}, + }) + destination := t.TempDir() + if err := ExtractPackage(archivePath, destination); err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(filepath.Join(destination, "prebuilds", "linux-x64", "runtime.node")) + if err != nil { + t.Fatal(err) + } + if string(content) != "runtime" { + t.Fatalf("extracted runtime = %q", content) + } + if _, err := os.Stat(filepath.Join(destination, "outside")); !os.IsNotExist(err) { + t.Fatalf("outside entry was extracted: %v", err) + } +} + +func TestExtractPackageRejectsUnsafeEntries(t *testing.T) { + for name, entry := range map[string]testTarEntry{ + "path traversal": {name: "package/../../outside", content: "unsafe"}, + "link": {name: "package/link", entryType: tar.TypeSymlink, linkName: "../outside"}, + } { + t.Run(name, func(t *testing.T) { + archivePath := writeTestTarball(t, map[string]testTarEntry{"entry": entry}) + if err := ExtractPackage(archivePath, t.TempDir()); err == nil { + t.Fatal("ExtractPackage() accepted an unsafe entry") + } + }) + } +} + +type testTarEntry struct { + name string + content string + entryType byte + linkName string +} + +func writeTestTarball(t *testing.T, entries map[string]testTarEntry) string { + t.Helper() + path := filepath.Join(t.TempDir(), "package.tgz") + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + gzipWriter := gzip.NewWriter(file) + tarWriter := tar.NewWriter(gzipWriter) + for defaultName, entry := range entries { + name := entry.name + if name == "" { + name = defaultName + } + entryType := entry.entryType + if entryType == 0 { + entryType = tar.TypeReg + } + header := &tar.Header{ + Name: name, + Mode: 0644, + Size: int64(len(entry.content)), + Typeflag: entryType, + Linkname: entry.linkName, + } + if err := tarWriter.WriteHeader(header); err != nil { + t.Fatal(err) + } + if entry.content != "" { + if _, err := tarWriter.Write([]byte(entry.content)); err != nil { + t.Fatal(err) + } + } + } + if err := tarWriter.Close(); err != nil { + t.Fatal(err) + } + if err := gzipWriter.Close(); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + return path +} diff --git a/go/testcli/setup.go b/go/testcli/setup.go new file mode 100644 index 0000000000..962419d042 --- /dev/null +++ b/go/testcli/setup.go @@ -0,0 +1,275 @@ +// Package testcli installs a compatible Copilot CLI for integration tests. +package testcli + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/github/copilot-sdk/go/internal/ffihost" + "github.com/github/copilot-sdk/go/internal/flock" + "github.com/github/copilot-sdk/go/internal/npmregistry" +) + +const ( + testSDKModule = "github.com/github/copilot-sdk/go" + testPackageLockURLFmt = "https://raw.githubusercontent.com/github/copilot-sdk/%s/nodejs/package-lock.json" +) + +// Setup installs the Copilot runtime package compatible with this SDK into the +// user cache and sets COPILOT_CLI_PATH. It returns any setup failure and panics +// when called outside a test binary. Downstream test packages should call Setup +// from TestMain before running their tests. +func Setup() error { + if !testing.Testing() { + panic("testcli.Setup may only be called from a test binary") + } + return setup() +} + +func setup() error { + // An explicitly provisioned runtime takes precedence and avoids all discovery + // and download work. + if configured := os.Getenv("COPILOT_CLI_PATH"); configured != "" { + if !testRegularFile(configured) { + return fmt.Errorf("COPILOT_CLI_PATH %q is not a file", configured) + } + return nil + } + + // Platform names match the suffixes of the optional Copilot npm packages. + platform := ffihost.PrebuildsFolder() + if platform == "" { + return fmt.Errorf("unsupported Copilot runtime platform %s/%s", runtime.GOOS, runtime.GOARCH) + } + metadata, err := compatibleTestRuntimeMetadata(platform) + if err != nil { + return err + } + cacheDir, err := os.UserCacheDir() + if err != nil { + cacheDir = os.TempDir() + } + // Cache each exact platform package independently so SDK versions can coexist. + installDir := filepath.Join(cacheDir, "copilot-sdk", "test-runtime", metadata.version, platform) + if cliPath, ok := installedTestRuntimePath(installDir, platform, metadata); ok { + return os.Setenv("COPILOT_CLI_PATH", cliPath) + } + if err := os.MkdirAll(filepath.Dir(installDir), 0755); err != nil { + return fmt.Errorf("creating Copilot test runtime cache: %w", err) + } + // Multiple test binaries may start concurrently and target the same cache entry. + release, err := flock.Acquire(installDir + ".lock") + if err != nil { + return fmt.Errorf("locking Copilot test runtime cache: %w", err) + } + defer release() + // Another process may have completed the installation while this process waited. + if cliPath, ok := installedTestRuntimePath(installDir, platform, metadata); ok { + return os.Setenv("COPILOT_CLI_PATH", cliPath) + } + if err := os.RemoveAll(installDir); err != nil { + return fmt.Errorf("removing stale Copilot test runtime: %w", err) + } + // Build the runtime in a sibling directory so Rename publishes only a complete + // installation to readers that do not hold the lock. + stagingDir, err := os.MkdirTemp(filepath.Dir(installDir), "."+platform+"-*") + if err != nil { + return fmt.Errorf("creating Copilot test runtime staging directory: %w", err) + } + defer os.RemoveAll(stagingDir) + archivePath, err := downloadTestRuntime(filepath.Dir(installDir), platform, metadata) + if err != nil { + return err + } + defer os.Remove(archivePath) + if err := npmregistry.ExtractPackage(archivePath, stagingDir); err != nil { + return fmt.Errorf("extracting @github/copilot-%s@%s: %w", platform, metadata.version, err) + } + cliPath := filepath.Join(stagingDir, testCLIBinaryName()) + if runtime.GOOS != "windows" { + if err := os.Chmod(cliPath, 0755); err != nil { + return fmt.Errorf("making Copilot test runtime executable: %w", err) + } + } + // package.json proves the version, while this marker binds the cache entry to + // the exact archive integrity pinned by the SDK lockfile. + if err := os.WriteFile(filepath.Join(stagingDir, ".integrity"), []byte(metadata.integrity+"\n"), 0644); err != nil { + return fmt.Errorf("writing Copilot test runtime integrity: %w", err) + } + cliPath, ok := installedTestRuntimePath(stagingDir, platform, metadata) + if !ok { + return fmt.Errorf("download did not contain a complete @github/copilot-%s@%s package", platform, metadata.version) + } + if err := os.Rename(stagingDir, installDir); err != nil { + return fmt.Errorf("installing Copilot test runtime: %w", err) + } + cliPath = filepath.Join(installDir, testCLIBinaryName()) + if err := os.Setenv("COPILOT_CLI_PATH", cliPath); err != nil { + return fmt.Errorf("configuring Copilot test runtime: %w", err) + } + return nil +} + +type testModuleInfo struct { + Version string `json:"Version"` + Dir string `json:"Dir"` + Replace *testModuleInfo `json:"Replace"` +} + +type testRuntimeMetadata struct { + version string + integrity string +} + +func compatibleTestRuntimeMetadata(platform string) (testRuntimeMetadata, error) { + // Query the effective module so local replace directives are reflected in both + // the source directory and version used below. + output, err := exec.Command("go", "list", "-m", "-json", testSDKModule).CombinedOutput() + if err != nil { + return testRuntimeMetadata{}, fmt.Errorf("locating %s: %w: %s", testSDKModule, err, strings.TrimSpace(string(output))) + } + var module testModuleInfo + if err := json.Unmarshal(output, &module); err != nil { + return testRuntimeMetadata{}, fmt.Errorf("parsing Go module information: %w", err) + } + // A source checkout or local replacement has the monorepo's Node lockfile next + // to the Go module. Prefer it so local SDK changes use their matching runtime. + moduleDir := module.Dir + if module.Replace != nil && module.Replace.Dir != "" { + moduleDir = module.Replace.Dir + } + if moduleDir != "" { + lockPath := filepath.Join(filepath.Dir(moduleDir), "nodejs", "package-lock.json") + if lockFile, err := os.Open(lockPath); err == nil { + defer lockFile.Close() + return parseTestRuntimeMetadata(lockFile, platform) + } + } + + // Published Go module archives do not contain the sibling Node project, so use + // the SDK version to read the same lockfile from the repository. + version := module.Version + if module.Replace != nil && module.Replace.Version != "" { + version = module.Replace.Version + } + if version == "" { + return testRuntimeMetadata{}, fmt.Errorf("could not resolve a published version for %s", testSDKModule) + } + // A pseudo-version ends in the source commit hash, which is the exact Git ref + // corresponding to that module build. + gitRef := version + if index := strings.LastIndexByte(version, '-'); index >= 0 { + suffix := version[index+1:] + if len(suffix) == 12 && testIsHex(suffix) { + gitRef = suffix + } + } + response, err := testSetupHTTPClient.Get(fmt.Sprintf(testPackageLockURLFmt, gitRef)) + if err != nil { + return testRuntimeMetadata{}, fmt.Errorf("fetching compatible Copilot CLI version: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return testRuntimeMetadata{}, fmt.Errorf("fetching compatible Copilot CLI version: %s", response.Status) + } + return parseTestRuntimeMetadata(response.Body, platform) +} + +var testSetupHTTPClient = &http.Client{Timeout: 10 * time.Minute} + +func parseTestRuntimeMetadata(reader io.Reader, platform string) (testRuntimeMetadata, error) { + var packageLock struct { + Packages map[string]struct { + Version string `json:"version"` + Integrity string `json:"integrity"` + } `json:"packages"` + } + if err := json.NewDecoder(reader).Decode(&packageLock); err != nil { + return testRuntimeMetadata{}, fmt.Errorf("parsing package-lock.json: %w", err) + } + // The platform package entry supplies both the downloadable version and npm's + // SHA-512 SRI value used to authenticate the archive. + packageName := "node_modules/@github/copilot-" + platform + entry := packageLock.Packages[packageName] + if entry.Version == "" || entry.Integrity == "" { + return testRuntimeMetadata{}, fmt.Errorf("%s is not pinned with integrity in package-lock.json", packageName) + } + return testRuntimeMetadata{version: entry.Version, integrity: entry.Integrity}, nil +} + +func installedTestRuntimePath(packageDir, platform string, expected testRuntimeMetadata) (string, bool) { + packageJSON, err := os.Open(filepath.Join(packageDir, "package.json")) + if err != nil { + return "", false + } + defer packageJSON.Close() + var metadata struct { + Version string `json:"version"` + } + if json.NewDecoder(packageJSON).Decode(&metadata) != nil || metadata.Version != expected.version { + return "", false + } + integrity, err := os.ReadFile(filepath.Join(packageDir, ".integrity")) + if err != nil || strings.TrimSpace(string(integrity)) != expected.integrity { + return "", false + } + cliPath := filepath.Join(packageDir, testCLIBinaryName()) + if !testRegularFile(cliPath) || !testRegularFile(filepath.Join(packageDir, "prebuilds", platform, "runtime.node")) { + return "", false + } + return cliPath, true +} + +func testCLIBinaryName() string { + if runtime.GOOS == "windows" { + return "copilot.exe" + } + return "copilot" +} + +func testRegularFile(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} + +func testIsHex(value string) bool { + for _, char := range value { + if (char < '0' || char > '9') && (char < 'a' || char > 'f') && (char < 'A' || char > 'F') { + return false + } + } + return true +} + +func downloadTestRuntime(destination, platform string, metadata testRuntimeMetadata) (string, error) { + url, err := npmregistry.TarballURL("@github/copilot-"+platform, metadata.version) + if err != nil { + return "", err + } + // Download verifies the lockfile integrity while streaming to a temporary file; + // extraction never sees an unverified archive. + archive, err := os.CreateTemp(destination, ".copilot-runtime-*.tgz") + if err != nil { + return "", fmt.Errorf("creating Copilot test runtime archive: %w", err) + } + archivePath := archive.Name() + if err := npmregistry.Download(testSetupHTTPClient, url, metadata.integrity, archive); err != nil { + archive.Close() + os.Remove(archivePath) + return "", err + } + if err := archive.Close(); err != nil { + os.Remove(archivePath) + return "", fmt.Errorf("closing Copilot test runtime archive: %w", err) + } + return archivePath, nil +} diff --git a/go/testcli/setup_external_test.go b/go/testcli/setup_external_test.go new file mode 100644 index 0000000000..7258e919d2 --- /dev/null +++ b/go/testcli/setup_external_test.go @@ -0,0 +1,52 @@ +package testcli_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/github/copilot-sdk/go/testcli" +) + +func TestSetupHonorsConfiguredPath(t *testing.T) { + cliPath := filepath.Join(t.TempDir(), "copilot") + if err := os.WriteFile(cliPath, []byte("test"), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("COPILOT_CLI_PATH", cliPath) + + // A valid override keeps setup local; success is a nil error and an unchanged path. + if err := testcli.Setup(); err != nil { + t.Fatal(err) + } + if got := os.Getenv("COPILOT_CLI_PATH"); got != cliPath { + t.Fatalf("COPILOT_CLI_PATH = %q, want %q", got, cliPath) + } +} + +func TestSetupRejectsInvalidConfiguredPath(t *testing.T) { + tests := map[string]string{ + "missing file": filepath.Join(t.TempDir(), "missing-copilot"), + "directory": t.TempDir(), + } + for name, cliPath := range tests { + t.Run(name, func(t *testing.T) { + t.Setenv("COPILOT_CLI_PATH", cliPath) + + requireErrorContaining(t, testcli.Setup(), "COPILOT_CLI_PATH", "is not a file") + }) + } +} + +func requireErrorContaining(t *testing.T, err error, fragments ...string) { + t.Helper() + if err == nil { + t.Fatal("testcli.Setup() returned nil") + } + for _, fragment := range fragments { + if !strings.Contains(err.Error(), fragment) { + t.Fatalf("error = %q, want it to contain %q", err, fragment) + } + } +} diff --git a/go/testcli/setup_test.go b/go/testcli/setup_test.go new file mode 100644 index 0000000000..32853b085c --- /dev/null +++ b/go/testcli/setup_test.go @@ -0,0 +1,46 @@ +package testcli + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseTestRuntimeMetadata(t *testing.T) { + metadata, err := parseTestRuntimeMetadata(strings.NewReader(`{"packages":{"node_modules/@github/copilot-linux-x64":{"version":"1.2.3","integrity":"sha512-test"}}}`), "linux-x64") + if err != nil { + t.Fatal(err) + } + if metadata.version != "1.2.3" || metadata.integrity != "sha512-test" { + t.Fatalf("parseTestRuntimeMetadata() = %#v", metadata) + } +} + +func TestInstalledTestRuntimePathRejectsStaleRuntime(t *testing.T) { + packageDir := t.TempDir() + platform := "linux-x64" + if err := os.MkdirAll(filepath.Join(packageDir, "prebuilds", platform), 0755); err != nil { + t.Fatal(err) + } + for path, content := range map[string]string{ + filepath.Join(packageDir, "package.json"): `{"version":"1.2.3"}`, + filepath.Join(packageDir, testCLIBinaryName()): "cli", + filepath.Join(packageDir, "prebuilds", platform, "runtime.node"): "runtime", + filepath.Join(packageDir, ".integrity"): "sha512-current\n", + } { + if err := os.WriteFile(path, []byte(content), 0755); err != nil { + t.Fatal(err) + } + } + + if _, ok := installedTestRuntimePath(packageDir, platform, testRuntimeMetadata{version: "1.2.3", integrity: "sha512-current"}); !ok { + t.Fatal("installedTestRuntimePath() rejected a matching runtime") + } + if _, ok := installedTestRuntimePath(packageDir, platform, testRuntimeMetadata{version: "2.0.0", integrity: "sha512-current"}); ok { + t.Fatal("installedTestRuntimePath() accepted a stale version") + } + if _, ok := installedTestRuntimePath(packageDir, platform, testRuntimeMetadata{version: "1.2.3", integrity: "sha512-new"}); ok { + t.Fatal("installedTestRuntimePath() accepted stale integrity") + } +} From 54d61cd3c19e61e5d60e12abd31d5539c11e64bd Mon Sep 17 00:00:00 2001 From: qmuntal Date: Wed, 2 Sep 2026 10:16:34 +0200 Subject: [PATCH 2/3] [go] Clean up test CLI path handling --- go/testcli/setup.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/go/testcli/setup.go b/go/testcli/setup.go index 962419d042..76ccb259d3 100644 --- a/go/testcli/setup.go +++ b/go/testcli/setup.go @@ -94,9 +94,9 @@ func setup() error { if err := npmregistry.ExtractPackage(archivePath, stagingDir); err != nil { return fmt.Errorf("extracting @github/copilot-%s@%s: %w", platform, metadata.version, err) } - cliPath := filepath.Join(stagingDir, testCLIBinaryName()) + stagingCLIPath := filepath.Join(stagingDir, testCLIBinaryName()) if runtime.GOOS != "windows" { - if err := os.Chmod(cliPath, 0755); err != nil { + if err := os.Chmod(stagingCLIPath, 0755); err != nil { return fmt.Errorf("making Copilot test runtime executable: %w", err) } } @@ -105,14 +105,13 @@ func setup() error { if err := os.WriteFile(filepath.Join(stagingDir, ".integrity"), []byte(metadata.integrity+"\n"), 0644); err != nil { return fmt.Errorf("writing Copilot test runtime integrity: %w", err) } - cliPath, ok := installedTestRuntimePath(stagingDir, platform, metadata) - if !ok { + if _, ok := installedTestRuntimePath(stagingDir, platform, metadata); !ok { return fmt.Errorf("download did not contain a complete @github/copilot-%s@%s package", platform, metadata.version) } if err := os.Rename(stagingDir, installDir); err != nil { return fmt.Errorf("installing Copilot test runtime: %w", err) } - cliPath = filepath.Join(installDir, testCLIBinaryName()) + cliPath := filepath.Join(installDir, testCLIBinaryName()) if err := os.Setenv("COPILOT_CLI_PATH", cliPath); err != nil { return fmt.Errorf("configuring Copilot test runtime: %w", err) } From 9b784ea57390943f79f9a762a30071d9573baf69 Mon Sep 17 00:00:00 2001 From: qmuntal Date: Wed, 2 Sep 2026 10:50:20 +0200 Subject: [PATCH 3/3] fix review --- go/testcli/setup.go | 43 ++++++-- go/testcli/setup_external_test.go | 26 +++++ go/testcli/setup_test.go | 165 ++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+), 11 deletions(-) diff --git a/go/testcli/setup.go b/go/testcli/setup.go index 76ccb259d3..eb95512ee9 100644 --- a/go/testcli/setup.go +++ b/go/testcli/setup.go @@ -35,12 +35,22 @@ func Setup() error { return setup() } +// Test hooks for overriding default behavior in tests. +var ( + testSetupHTTPClient = &http.Client{Timeout: 10 * time.Minute} + testUserCacheDir = os.UserCacheDir + testListSDKModule = func() ([]byte, error) { + return exec.Command("go", "list", "-m", "-json", testSDKModule).CombinedOutput() + } + testRuntimeTarballURL = npmregistry.TarballURL +) + func setup() error { // An explicitly provisioned runtime takes precedence and avoids all discovery // and download work. if configured := os.Getenv("COPILOT_CLI_PATH"); configured != "" { - if !testRegularFile(configured) { - return fmt.Errorf("COPILOT_CLI_PATH %q is not a file", configured) + if err := validateTestCLIPath(configured); err != nil { + return fmt.Errorf("COPILOT_CLI_PATH %w", err) } return nil } @@ -50,13 +60,13 @@ func setup() error { if platform == "" { return fmt.Errorf("unsupported Copilot runtime platform %s/%s", runtime.GOOS, runtime.GOARCH) } - metadata, err := compatibleTestRuntimeMetadata(platform) + cacheDir, err := testUserCacheDir() if err != nil { - return err + return fmt.Errorf("locating user cache directory: %w", err) } - cacheDir, err := os.UserCacheDir() + metadata, err := compatibleTestRuntimeMetadata(platform) if err != nil { - cacheDir = os.TempDir() + return err } // Cache each exact platform package independently so SDK versions can coexist. installDir := filepath.Join(cacheDir, "copilot-sdk", "test-runtime", metadata.version, platform) @@ -132,7 +142,7 @@ type testRuntimeMetadata struct { func compatibleTestRuntimeMetadata(platform string) (testRuntimeMetadata, error) { // Query the effective module so local replace directives are reflected in both // the source directory and version used below. - output, err := exec.Command("go", "list", "-m", "-json", testSDKModule).CombinedOutput() + output, err := testListSDKModule() if err != nil { return testRuntimeMetadata{}, fmt.Errorf("locating %s: %w: %s", testSDKModule, err, strings.TrimSpace(string(output))) } @@ -183,8 +193,6 @@ func compatibleTestRuntimeMetadata(platform string) (testRuntimeMetadata, error) return parseTestRuntimeMetadata(response.Body, platform) } -var testSetupHTTPClient = &http.Client{Timeout: 10 * time.Minute} - func parseTestRuntimeMetadata(reader io.Reader, platform string) (testRuntimeMetadata, error) { var packageLock struct { Packages map[string]struct { @@ -222,12 +230,25 @@ func installedTestRuntimePath(packageDir, platform string, expected testRuntimeM return "", false } cliPath := filepath.Join(packageDir, testCLIBinaryName()) - if !testRegularFile(cliPath) || !testRegularFile(filepath.Join(packageDir, "prebuilds", platform, "runtime.node")) { + if validateTestCLIPath(cliPath) != nil || !testRegularFile(filepath.Join(packageDir, "prebuilds", platform, "runtime.node")) { return "", false } return cliPath, true } +func validateTestCLIPath(path string) error { + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() { + return fmt.Errorf("%q is not a file", path) + } + // JavaScript entrypoints are passed to Node.js; native entrypoints must be + // directly executable on Unix. + if runtime.GOOS != "windows" && !strings.HasSuffix(path, ".js") && info.Mode().Perm()&0111 == 0 { + return fmt.Errorf("%q is not executable", path) + } + return nil +} + func testCLIBinaryName() string { if runtime.GOOS == "windows" { return "copilot.exe" @@ -250,7 +271,7 @@ func testIsHex(value string) bool { } func downloadTestRuntime(destination, platform string, metadata testRuntimeMetadata) (string, error) { - url, err := npmregistry.TarballURL("@github/copilot-"+platform, metadata.version) + url, err := testRuntimeTarballURL("@github/copilot-"+platform, metadata.version) if err != nil { return "", err } diff --git a/go/testcli/setup_external_test.go b/go/testcli/setup_external_test.go index 7258e919d2..65f72c3c93 100644 --- a/go/testcli/setup_external_test.go +++ b/go/testcli/setup_external_test.go @@ -3,6 +3,7 @@ package testcli_test import ( "os" "path/filepath" + "runtime" "strings" "testing" @@ -25,6 +26,31 @@ func TestSetupHonorsConfiguredPath(t *testing.T) { } } +func TestSetupHonorsJavaScriptConfiguredPath(t *testing.T) { + cliPath := filepath.Join(t.TempDir(), "copilot.js") + if err := os.WriteFile(cliPath, []byte("test"), 0644); err != nil { + t.Fatal(err) + } + t.Setenv("COPILOT_CLI_PATH", cliPath) + + if err := testcli.Setup(); err != nil { + t.Fatal(err) + } +} + +func TestSetupRejectsNonExecutableNativePath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows does not use Unix execute permission bits") + } + cliPath := filepath.Join(t.TempDir(), "copilot") + if err := os.WriteFile(cliPath, []byte("test"), 0644); err != nil { + t.Fatal(err) + } + t.Setenv("COPILOT_CLI_PATH", cliPath) + + requireErrorContaining(t, testcli.Setup(), "COPILOT_CLI_PATH", "is not executable") +} + func TestSetupRejectsInvalidConfiguredPath(t *testing.T) { tests := map[string]string{ "missing file": filepath.Join(t.TempDir(), "missing-copilot"), diff --git a/go/testcli/setup_test.go b/go/testcli/setup_test.go index 32853b085c..b1e8750e38 100644 --- a/go/testcli/setup_test.go +++ b/go/testcli/setup_test.go @@ -1,12 +1,140 @@ package testcli import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha512" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" "os" "path/filepath" + "runtime" "strings" + "sync/atomic" "testing" + + "github.com/github/copilot-sdk/go/internal/ffihost" ) +func TestSetupFailsWhenUserCacheIsUnavailable(t *testing.T) { + previousUserCacheDir := testUserCacheDir + testUserCacheDir = func() (string, error) { return "", errors.New("cache unavailable") } + t.Cleanup(func() { testUserCacheDir = previousUserCacheDir }) + t.Setenv("COPILOT_CLI_PATH", "") + + err := Setup() + if err == nil || !strings.Contains(err.Error(), "locating user cache directory: cache unavailable") { + t.Fatalf("Setup() error = %v", err) + } +} + +func TestSetupInstallsAndReusesRuntime(t *testing.T) { + platform := ffihost.PrebuildsFolder() + if platform == "" { + t.Skipf("unsupported test platform %s/%s", runtime.GOOS, runtime.GOARCH) + } + version := "1.2.3" + archive := testRuntimeArchive(t, platform, version) + digest := sha512.Sum512(archive) + integrity := "sha512-" + base64.StdEncoding.EncodeToString(digest[:]) + + moduleRoot := t.TempDir() + moduleDir := filepath.Join(moduleRoot, "go") + lockDir := filepath.Join(moduleRoot, "nodejs") + if err := os.MkdirAll(moduleDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(lockDir, 0755); err != nil { + t.Fatal(err) + } + lockData, err := json.Marshal(map[string]any{ + "packages": map[string]any{ + "node_modules/@github/copilot-" + platform: map[string]string{ + "version": version, + "integrity": integrity, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(lockDir, "package-lock.json"), lockData, 0644); err != nil { + t.Fatal(err) + } + moduleData, err := json.Marshal(testModuleInfo{Version: "v1.0.0", Dir: moduleDir}) + if err != nil { + t.Fatal(err) + } + + var requestCount atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requestCount.Add(1) + writer.Write(archive) + })) + defer server.Close() + + previousHTTPClient := testSetupHTTPClient + previousUserCacheDir := testUserCacheDir + previousListSDKModule := testListSDKModule + previousRuntimeTarballURL := testRuntimeTarballURL + testSetupHTTPClient = server.Client() + cacheDir := t.TempDir() + testUserCacheDir = func() (string, error) { return cacheDir, nil } + testListSDKModule = func() ([]byte, error) { return moduleData, nil } + testRuntimeTarballURL = func(packageName, packageVersion string) (string, error) { + if packageName != "@github/copilot-"+platform || packageVersion != version { + t.Fatalf("runtime package = %s@%s", packageName, packageVersion) + } + return server.URL + "/runtime.tgz", nil + } + t.Cleanup(func() { + testSetupHTTPClient = previousHTTPClient + testUserCacheDir = previousUserCacheDir + testListSDKModule = previousListSDKModule + testRuntimeTarballURL = previousRuntimeTarballURL + }) + t.Setenv("COPILOT_CLI_PATH", "") + + if err := Setup(); err != nil { + t.Fatal(err) + } + expectedCLIPath := filepath.Join(cacheDir, "copilot-sdk", "test-runtime", version, platform, testCLIBinaryName()) + if got := os.Getenv("COPILOT_CLI_PATH"); got != expectedCLIPath { + t.Fatalf("COPILOT_CLI_PATH = %q, want %q", got, expectedCLIPath) + } + if content, err := os.ReadFile(expectedCLIPath); err != nil || string(content) != "cli" { + t.Fatalf("installed CLI content = %q, err = %v", content, err) + } + runtimePath, err := ffihost.ResolveLibraryPath(expectedCLIPath) + if err != nil { + t.Fatal(err) + } + expectedRuntimePath := filepath.Join(filepath.Dir(expectedCLIPath), "prebuilds", platform, "runtime.node") + if runtimePath != expectedRuntimePath { + t.Fatalf("runtime path = %q, want %q", runtimePath, expectedRuntimePath) + } + if got := requestCount.Load(); got != 1 { + t.Fatalf("download requests = %d, want 1", got) + } + + if err := os.Unsetenv("COPILOT_CLI_PATH"); err != nil { + t.Fatal(err) + } + if err := Setup(); err != nil { + t.Fatal(err) + } + if got := os.Getenv("COPILOT_CLI_PATH"); got != expectedCLIPath { + t.Fatalf("cached COPILOT_CLI_PATH = %q, want %q", got, expectedCLIPath) + } + if got := requestCount.Load(); got != 1 { + t.Fatalf("download requests after cache reuse = %d, want 1", got) + } +} + func TestParseTestRuntimeMetadata(t *testing.T) { metadata, err := parseTestRuntimeMetadata(strings.NewReader(`{"packages":{"node_modules/@github/copilot-linux-x64":{"version":"1.2.3","integrity":"sha512-test"}}}`), "linux-x64") if err != nil { @@ -44,3 +172,40 @@ func TestInstalledTestRuntimePathRejectsStaleRuntime(t *testing.T) { t.Fatal("installedTestRuntimePath() accepted stale integrity") } } + +func testRuntimeArchive(t *testing.T, platform, version string) []byte { + t.Helper() + var buffer bytes.Buffer + gzipWriter := gzip.NewWriter(&buffer) + tarWriter := tar.NewWriter(gzipWriter) + entries := []struct { + name string + content string + mode int64 + }{ + {name: "package/package.json", content: `{"version":"` + version + `"}`, mode: 0644}, + {name: "package/" + testCLIBinaryName(), content: "cli", mode: 0755}, + {name: "package/prebuilds/" + platform + "/runtime.node", content: "runtime", mode: 0644}, + } + for _, entry := range entries { + header := &tar.Header{ + Name: entry.name, + Mode: entry.mode, + Size: int64(len(entry.content)), + Typeflag: tar.TypeReg, + } + if err := tarWriter.WriteHeader(header); err != nil { + t.Fatal(err) + } + if _, err := tarWriter.Write([]byte(entry.content)); err != nil { + t.Fatal(err) + } + } + if err := tarWriter.Close(); err != nil { + t.Fatal(err) + } + if err := gzipWriter.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +}