From 59c6b74ece84cef565a82a563ddc57ee32ad0799 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Tue, 27 Jan 2026 19:49:16 +0000 Subject: [PATCH 1/6] feat: add functions to exclude files from bundling in extensions --- cmd/extensions.go | 121 +++++++++++++++++++- pkg/util/zip_extensions.go | 225 +++++++++++++++++++++++++++++++++++++ 2 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 pkg/util/zip_extensions.go diff --git a/cmd/extensions.go b/cmd/extensions.go index 9b063b5..05264f7 100644 --- a/cmd/extensions.go +++ b/cmd/extensions.go @@ -294,31 +294,94 @@ func (e ExtensionsCmd) Upload(ctx context.Context, in ExtensionsUploadInput) err return fmt.Errorf("directory %s does not exist", absDir) } + // Pre-flight size check + if in.Output != "json" { + pterm.Info.Println("Analyzing extension directory...") + } + + estimatedSize, err := estimateExtensionSize(absDir) + if err != nil { + pterm.Warning.Printf("Could not estimate size: %v\n", err) + } else { + const maxSize = 50 * 1024 * 1024 + if estimatedSize > maxSize { + pterm.Error.Printf("Estimated bundle size (%s) exceeds limit (50MB)\n", + formatBytes(estimatedSize)) + pterm.Info.Println("\nTo reduce size:") + pterm.Info.Println(" 1. Ensure node_modules/ is not needed (already excluded)") + pterm.Info.Println(" 2. Remove large assets or documentation files") + pterm.Info.Println(" 3. Build extension for production (minified)") + return fmt.Errorf("bundle would exceed maximum size") + } + + if in.Output != "json" { + pterm.Info.Printf("Estimated bundle size: %s\n", formatBytes(estimatedSize)) + } + } + tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("kernel_ext_%d.zip", time.Now().UnixNano())) + if in.Output != "json" { pterm.Info.Println("Zipping extension directory...") } - if err := util.ZipDirectory(absDir, tmpFile); err != nil { + + // Use new extension-specific zip function + stats, err := util.ZipExtensionDirectory(absDir, tmpFile, &util.ExtensionZipOptions{ + ExcludeDefaults: false, // Apply defaults + Verbose: false, + }) + if err != nil { pterm.Error.Println("Failed to zip directory") return err } defer os.Remove(tmpFile) + // Show helpful stats + if in.Output != "json" { + pterm.Success.Printf("Created bundle: %s (%d files)\n", + formatBytes(stats.BytesIncluded), stats.FilesIncluded) + } + + // Final size validation + fileInfo, err := os.Stat(tmpFile) + if err != nil { + return fmt.Errorf("failed to stat zip: %w", err) + } + + const maxSize = 50 * 1024 * 1024 + if fileInfo.Size() > maxSize { + pterm.Error.Printf("Extension bundle is too large: %s (max: 50MB)\n", + formatBytes(fileInfo.Size())) + pterm.Info.Println("\nSuggestions to reduce size:") + pterm.Info.Println(" 1. Ensure you're building the extension for production") + pterm.Info.Println(" 2. Remove unnecessary assets (large images, videos)") + pterm.Info.Println(" 3. Check manifest.json references only needed files") + return fmt.Errorf("bundle exceeds maximum size") + } + + // Open file for upload f, err := os.Open(tmpFile) if err != nil { return fmt.Errorf("failed to open temp zip: %w", err) } defer f.Close() + // Upload + if in.Output != "json" { + pterm.Info.Println("Uploading extension...") + } + params := kernel.ExtensionUploadParams{File: f} if in.Name != "" { params.Name = kernel.Opt(in.Name) } + item, err := e.extensions.Upload(ctx, params) if err != nil { return util.CleanedUpSdkError{Err: err} } + // Display results if in.Output == "json" { return util.PrintPrettyJSON(item) } @@ -336,6 +399,62 @@ func (e ExtensionsCmd) Upload(ctx context.Context, in ExtensionsUploadInput) err return nil } +// formatBytes formats bytes in a human-readable format +func formatBytes(bytes int64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} + +// estimateExtensionSize walks the directory and estimates the final zip size +// This provides fast feedback if the bundle will be too large +func estimateExtensionSize(srcDir string) (int64, error) { + var totalSize int64 + excludeMap := make(map[string]bool) + + // Build exclusion map for fast lookup + for _, pattern := range util.DefaultExtensionExclusions.ExcludeDirectory { + excludeMap[pattern] = true + } + + err := filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Check if path should be excluded + if info.IsDir() { + if excludeMap[filepath.Base(path)] { + return filepath.SkipDir + } + return nil + } + + // Check against exact file names + base := filepath.Base(path) + // Check against file patterns + for _, pattern := range util.DefaultExtensionExclusions.ExcludeFilenamePatterns { + if matched, _ := filepath.Match(pattern, base); matched { + return nil + } + } + + totalSize += info.Size() + return nil + }) + + // Zip typically compresses 20-40% for code/text files + // Use conservative 30% compression estimate + return int64(float64(totalSize) * 0.7), err +} + // --- Cobra wiring --- var extensionsCmd = &cobra.Command{ diff --git a/pkg/util/zip_extensions.go b/pkg/util/zip_extensions.go new file mode 100644 index 0000000..ecd0fc3 --- /dev/null +++ b/pkg/util/zip_extensions.go @@ -0,0 +1,225 @@ +package util + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/boyter/gocodewalker" +) + +// DefaultExtensionExclusions contains patterns for files that are not needed +// Focus on large directories and files that provide meaningful size reduction. +// These will be passed to gocodewalker's ExcludeDirectory and ExcludeFilename fields. +var DefaultExtensionExclusions = struct { + // ExcludeDirectory: exact directory names (case-sensitive) + ExcludeDirectory []string + // ExcludeFilenamePatterns: patterns for filename matching (handled manually) + ExcludeFilenamePatterns []string +}{ + ExcludeDirectory: []string{ + // Dependencies + "node_modules", + + // Version control + ".git", + + // Test/Coverage + "__tests__", + "coverage", + }, + + ExcludeFilenamePatterns: []string{ + // Test files + "*.test.js", + "*.test.ts", + "*.spec.js", + "*.spec.ts", + + // Log files + "*.log", + + // Temp files + "*.swp", + }, +} + +// ExtensionZipOptions configures extension-specific zipping behavior +type ExtensionZipOptions struct { + ExcludeDefaults bool // If true, don't apply default exclusions + Verbose bool // Track individual excluded files +} + +// ZipStats tracks statistics about the zipping operation +type ZipStats struct { + mu sync.Mutex + FilesIncluded int + FilesExcluded int + BytesIncluded int64 + BytesExcluded int64 + ExcludedPaths []string +} + +func (s *ZipStats) AddIncluded(bytes int64) { + s.mu.Lock() + defer s.mu.Unlock() + s.FilesIncluded++ + s.BytesIncluded += bytes +} + +func (s *ZipStats) AddExcluded(path string, bytes int64, verbose bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.FilesExcluded++ + s.BytesExcluded += bytes + if verbose { + s.ExcludedPaths = append(s.ExcludedPaths, path) + } +} + +// ZipExtensionDirectory zips a Chrome extension directory with smart defaults +// that automatically exclude development files (node_modules, .git, etc.) +func ZipExtensionDirectory(srcDir, destZip string, opts *ExtensionZipOptions) (*ZipStats, error) { + if opts == nil { + opts = &ExtensionZipOptions{} + } + + stats := &ZipStats{} + + zipFile, err := os.Create(destZip) + if err != nil { + return nil, err + } + defer zipFile.Close() + + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + // Configure file walker with exclusions + fileQueue := make(chan *gocodewalker.File, 256) + walker := gocodewalker.NewFileWalker(srcDir, fileQueue) + walker.IncludeHidden = true + + // Apply default exclusions unless disabled + if !opts.ExcludeDefaults { + walker.ExcludeDirectory = append(walker.ExcludeDirectory, DefaultExtensionExclusions.ExcludeDirectory...) + walker.ExcludeFilename = append(walker.ExcludeFilename, DefaultExtensionExclusions.ExcludeFilenamePatterns...) + } + + // Track walker errors + errChan := make(chan error, 1) + go func() { + errChan <- walker.Start() + }() + + // Track directories we've added to avoid duplicates + dirsAdded := make(map[string]struct{}) + + // Process files from walker + for f := range fileQueue { + relPath, err := filepath.Rel(srcDir, f.Location) + if err != nil { + return stats, err + } + relPath = filepath.ToSlash(relPath) + + // Check against pattern-based exclusions (if defaults are enabled) + shouldExclude := false + if !opts.ExcludeDefaults { + // Check filename against patterns only if defaults are enabled + filename := filepath.Base(f.Location) + for _, pattern := range DefaultExtensionExclusions.ExcludeFilenamePatterns { + matched, err := filepath.Match(pattern, filename) + if err == nil && matched { + shouldExclude = true + break + } + } + } + + if shouldExclude { + continue + } + + // Ensure parent directories exist in archive + if dir := filepath.Dir(relPath); dir != "." && dir != "" { + segments := strings.Split(dir, "/") + var current string + for _, segment := range segments { + if current == "" { + current = segment + } else { + current = current + "/" + segment + } + if _, exists := dirsAdded[current+"/"]; !exists { + if _, err := zipWriter.Create(current + "/"); err != nil { + return stats, err + } + dirsAdded[current+"/"] = struct{}{} + } + } + } + + // Get file info + fileInfo, err := os.Lstat(f.Location) + if err != nil { + return stats, err + } + + // Handle symlinks + if fileInfo.Mode()&os.ModeSymlink != 0 { + linkTarget, err := os.Readlink(f.Location) + if err != nil { + return stats, err + } + + hdr := &zip.FileHeader{ + Name: relPath, + Method: zip.Store, + } + hdr.SetMode(os.ModeSymlink | 0777) + + zipFileWriter, err := zipWriter.CreateHeader(hdr) + if err != nil { + return stats, err + } + if _, err := zipFileWriter.Write([]byte(linkTarget)); err != nil { + return stats, err + } + stats.AddIncluded(int64(len(linkTarget))) + } else { + // Regular file + zipFileWriter, err := zipWriter.Create(relPath) + if err != nil { + return stats, err + } + + file, err := os.Open(f.Location) + if err != nil { + return stats, err + } + + written, err := io.Copy(zipFileWriter, file) + closeErr := file.Close() + if closeErr != nil { + return stats, closeErr + } + if err != nil { + return stats, err + } + + stats.AddIncluded(written) + } + } + + // Check if walker had an error + if err := <-errChan; err != nil { + return stats, fmt.Errorf("directory walk failed: %w", err) + } + + return stats, nil +} From 4feb380e504a9bc2230a6fe787308138be743983 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 28 Jan 2026 13:26:29 +0000 Subject: [PATCH 2/6] review --- cmd/extensions.go | 89 ++++---------------------------------- pkg/util/format.go | 19 +++++++- pkg/util/zip_extensions.go | 7 +-- 3 files changed, 27 insertions(+), 88 deletions(-) diff --git a/cmd/extensions.go b/cmd/extensions.go index 05264f7..5ab2587 100644 --- a/cmd/extensions.go +++ b/cmd/extensions.go @@ -18,6 +18,11 @@ import ( "github.com/spf13/cobra" ) +const ( + // MaxExtensionSize is the maximum allowed size for extension bundles from API (50MB) + MaxExtensionSize = 50 * 1024 * 1024 +) + // ExtensionsService defines the subset of the Kernel SDK extension client that we use. type ExtensionsService interface { List(ctx context.Context, opts ...option.RequestOption) (res *[]kernel.ExtensionListResponse, err error) @@ -299,26 +304,6 @@ func (e ExtensionsCmd) Upload(ctx context.Context, in ExtensionsUploadInput) err pterm.Info.Println("Analyzing extension directory...") } - estimatedSize, err := estimateExtensionSize(absDir) - if err != nil { - pterm.Warning.Printf("Could not estimate size: %v\n", err) - } else { - const maxSize = 50 * 1024 * 1024 - if estimatedSize > maxSize { - pterm.Error.Printf("Estimated bundle size (%s) exceeds limit (50MB)\n", - formatBytes(estimatedSize)) - pterm.Info.Println("\nTo reduce size:") - pterm.Info.Println(" 1. Ensure node_modules/ is not needed (already excluded)") - pterm.Info.Println(" 2. Remove large assets or documentation files") - pterm.Info.Println(" 3. Build extension for production (minified)") - return fmt.Errorf("bundle would exceed maximum size") - } - - if in.Output != "json" { - pterm.Info.Printf("Estimated bundle size: %s\n", formatBytes(estimatedSize)) - } - } - tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("kernel_ext_%d.zip", time.Now().UnixNano())) if in.Output != "json" { @@ -328,7 +313,6 @@ func (e ExtensionsCmd) Upload(ctx context.Context, in ExtensionsUploadInput) err // Use new extension-specific zip function stats, err := util.ZipExtensionDirectory(absDir, tmpFile, &util.ExtensionZipOptions{ ExcludeDefaults: false, // Apply defaults - Verbose: false, }) if err != nil { pterm.Error.Println("Failed to zip directory") @@ -339,7 +323,7 @@ func (e ExtensionsCmd) Upload(ctx context.Context, in ExtensionsUploadInput) err // Show helpful stats if in.Output != "json" { pterm.Success.Printf("Created bundle: %s (%d files)\n", - formatBytes(stats.BytesIncluded), stats.FilesIncluded) + util.FormatBytes(stats.BytesIncluded), stats.FilesIncluded) } // Final size validation @@ -348,10 +332,9 @@ func (e ExtensionsCmd) Upload(ctx context.Context, in ExtensionsUploadInput) err return fmt.Errorf("failed to stat zip: %w", err) } - const maxSize = 50 * 1024 * 1024 - if fileInfo.Size() > maxSize { + if fileInfo.Size() > MaxExtensionSize { pterm.Error.Printf("Extension bundle is too large: %s (max: 50MB)\n", - formatBytes(fileInfo.Size())) + util.FormatBytes(fileInfo.Size())) pterm.Info.Println("\nSuggestions to reduce size:") pterm.Info.Println(" 1. Ensure you're building the extension for production") pterm.Info.Println(" 2. Remove unnecessary assets (large images, videos)") @@ -399,62 +382,6 @@ func (e ExtensionsCmd) Upload(ctx context.Context, in ExtensionsUploadInput) err return nil } -// formatBytes formats bytes in a human-readable format -func formatBytes(bytes int64) string { - const unit = 1024 - if bytes < unit { - return fmt.Sprintf("%d B", bytes) - } - div, exp := int64(unit), 0 - for n := bytes / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) -} - -// estimateExtensionSize walks the directory and estimates the final zip size -// This provides fast feedback if the bundle will be too large -func estimateExtensionSize(srcDir string) (int64, error) { - var totalSize int64 - excludeMap := make(map[string]bool) - - // Build exclusion map for fast lookup - for _, pattern := range util.DefaultExtensionExclusions.ExcludeDirectory { - excludeMap[pattern] = true - } - - err := filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - // Check if path should be excluded - if info.IsDir() { - if excludeMap[filepath.Base(path)] { - return filepath.SkipDir - } - return nil - } - - // Check against exact file names - base := filepath.Base(path) - // Check against file patterns - for _, pattern := range util.DefaultExtensionExclusions.ExcludeFilenamePatterns { - if matched, _ := filepath.Match(pattern, base); matched { - return nil - } - } - - totalSize += info.Size() - return nil - }) - - // Zip typically compresses 20-40% for code/text files - // Use conservative 30% compression estimate - return int64(float64(totalSize) * 0.7), err -} - // --- Cobra wiring --- var extensionsCmd = &cobra.Command{ diff --git a/pkg/util/format.go b/pkg/util/format.go index 7f64590..6682655 100644 --- a/pkg/util/format.go +++ b/pkg/util/format.go @@ -1,6 +1,9 @@ package util -import "strings" +import ( + "fmt" + "strings" +) // OrDash returns the string if non-empty, otherwise returns "-". func OrDash(s string) string { @@ -29,3 +32,17 @@ func JoinOrDash(items ...string) string { } return strings.Join(items, ", ") } + +// FormatBytes formats bytes in a human-readable format +func FormatBytes(bytes int64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} diff --git a/pkg/util/zip_extensions.go b/pkg/util/zip_extensions.go index ecd0fc3..7e050c4 100644 --- a/pkg/util/zip_extensions.go +++ b/pkg/util/zip_extensions.go @@ -51,7 +51,6 @@ var DefaultExtensionExclusions = struct { // ExtensionZipOptions configures extension-specific zipping behavior type ExtensionZipOptions struct { ExcludeDefaults bool // If true, don't apply default exclusions - Verbose bool // Track individual excluded files } // ZipStats tracks statistics about the zipping operation @@ -61,7 +60,6 @@ type ZipStats struct { FilesExcluded int BytesIncluded int64 BytesExcluded int64 - ExcludedPaths []string } func (s *ZipStats) AddIncluded(bytes int64) { @@ -71,14 +69,11 @@ func (s *ZipStats) AddIncluded(bytes int64) { s.BytesIncluded += bytes } -func (s *ZipStats) AddExcluded(path string, bytes int64, verbose bool) { +func (s *ZipStats) AddExcluded(bytes int64) { s.mu.Lock() defer s.mu.Unlock() s.FilesExcluded++ s.BytesExcluded += bytes - if verbose { - s.ExcludedPaths = append(s.ExcludedPaths, path) - } } // ZipExtensionDirectory zips a Chrome extension directory with smart defaults From c1efd7b542342b0bab3daf587a65943a1e0e4148 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 28 Jan 2026 13:40:39 +0000 Subject: [PATCH 3/6] add zip_extensions test --- pkg/util/zip_extensions_test.go | 149 ++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 pkg/util/zip_extensions_test.go diff --git a/pkg/util/zip_extensions_test.go b/pkg/util/zip_extensions_test.go new file mode 100644 index 0000000..a7afdf8 --- /dev/null +++ b/pkg/util/zip_extensions_test.go @@ -0,0 +1,149 @@ +package util + +import ( + "archive/zip" + "os" + "path/filepath" + "testing" +) + +func TestZipExtensionDirectory(t *testing.T) { + // Create a temporary directory structure for testing + tmpDir, err := os.MkdirTemp("", "zip-extension-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create test file structure + files := map[string]string{ + "manifest.json": `{"name": "test", "version": "1.0"}`, + "background.js": "console.log('background');", + "content.js": "console.log('content');", + "icons/icon.png": "fake-png-data", + "node_modules/dep/foo.js": "should be excluded", + "test.test.js": "should be excluded", + } + + for path, content := range files { + fullPath := filepath.Join(tmpDir, path) + dir := filepath.Dir(fullPath) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("Failed to create directory %s: %v", dir, err) + } + if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write file %s: %v", fullPath, err) + } + } + + // Create output zip file + tmpZip, err := os.CreateTemp("", "test-extension-*.zip") + if err != nil { + t.Fatalf("Failed to create temp zip: %v", err) + } + tmpZip.Close() + defer os.Remove(tmpZip.Name()) + + // Test with default exclusions + t.Run("with default exclusions", func(t *testing.T) { + _, err := ZipExtensionDirectory(tmpDir, tmpZip.Name(), &ExtensionZipOptions{ + ExcludeDefaults: false, + }) + if err != nil { + t.Fatalf("ZipExtensionDirectory failed: %v", err) + } + + // Verify the zip contents + r, err := zip.OpenReader(tmpZip.Name()) + if err != nil { + t.Fatalf("Failed to open zip: %v", err) + } + defer r.Close() + + expectedFiles := map[string]bool{ + "manifest.json": false, + "background.js": false, + "content.js": false, + "icons/": false, + "icons/icon.png": false, + } + + for _, f := range r.File { + if f.FileInfo().IsDir() { + expectedFiles[f.Name] = true + } else { + if _, ok := expectedFiles[f.Name]; ok { + expectedFiles[f.Name] = true + } else { + // Check for files that should be excluded + if contains([]string{"node_modules", ".git", "test.test.js", "package-lock.json", "__tests__"}, f.Name) { + t.Errorf("Excluded file found in zip: %s", f.Name) + } + } + } + } + + for name, found := range expectedFiles { + if !found && name != "icons/" { + t.Errorf("Expected file not found in zip: %s", name) + } + } + }) + + // Test without exclusions + t.Run("without default exclusions", func(t *testing.T) { + tmpZip2, err := os.CreateTemp("", "test-extension-no-exclude-*.zip") + if err != nil { + t.Fatalf("Failed to create temp zip: %v", err) + } + tmpZip2.Close() + defer os.Remove(tmpZip2.Name()) + + stats, err := ZipExtensionDirectory(tmpDir, tmpZip2.Name(), &ExtensionZipOptions{ + ExcludeDefaults: true, // Disable default exclusions + }) + if err != nil { + t.Fatalf("ZipExtensionDirectory failed: %v", err) + } + + // Should include all files when exclusions are disabled + if stats.FilesIncluded <= 4 { + t.Errorf("Expected more than 4 files when exclusions are disabled, got %d", stats.FilesIncluded) + } + }) +} + +func TestDefaultExtensionExclusions(t *testing.T) { + // Verify the exclusion lists are not empty + if len(DefaultExtensionExclusions.ExcludeDirectory) == 0 { + t.Error("ExcludeDirectory should not be empty") + } + if len(DefaultExtensionExclusions.ExcludeFilenamePatterns) == 0 { + t.Error("ExcludeFilenamePatterns should not be empty") + } + + // Verify specific important exclusions are present + expectedDirs := []string{"node_modules", ".git", "__tests__", "coverage"} + for _, path := range expectedDirs { + if !contains(DefaultExtensionExclusions.ExcludeDirectory, path) { + t.Errorf("Expected directory exclusion not found: %s", path) + } + } + + expectedPatterns := []string{"*.test.js", "*.test.ts", "*.spec.js", "*.spec.ts", "*.log", "*.swp"} + for _, pattern := range expectedPatterns { + if !contains(DefaultExtensionExclusions.ExcludeFilenamePatterns, pattern) { + t.Errorf("Expected pattern exclusion not found: %s", pattern) + } + } +} + +// Helper function to check if a slice contains a string +func contains(slice []string, str string) bool { + for _, s := range slice { + if s == str { + return true + } + } + return false +} From 5197817d038d5b0480639569994e226260cd8abe Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 28 Jan 2026 14:00:12 +0000 Subject: [PATCH 4/6] review --- cmd/extensions.go | 13 +++++++------ pkg/util/zip_extensions.go | 3 +++ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/cmd/extensions.go b/cmd/extensions.go index 5ab2587..e1a92ba 100644 --- a/cmd/extensions.go +++ b/cmd/extensions.go @@ -320,17 +320,18 @@ func (e ExtensionsCmd) Upload(ctx context.Context, in ExtensionsUploadInput) err } defer os.Remove(tmpFile) - // Show helpful stats + // Get zip file size + fileInfo, err := os.Stat(tmpFile) + if err != nil { + return fmt.Errorf("failed to stat zip: %w", err) + } + if in.Output != "json" { pterm.Success.Printf("Created bundle: %s (%d files)\n", - util.FormatBytes(stats.BytesIncluded), stats.FilesIncluded) + util.FormatBytes(fileInfo.Size()), stats.FilesIncluded) } // Final size validation - fileInfo, err := os.Stat(tmpFile) - if err != nil { - return fmt.Errorf("failed to stat zip: %w", err) - } if fileInfo.Size() > MaxExtensionSize { pterm.Error.Printf("Extension bundle is too large: %s (max: 50MB)\n", diff --git a/pkg/util/zip_extensions.go b/pkg/util/zip_extensions.go index 7e050c4..c753647 100644 --- a/pkg/util/zip_extensions.go +++ b/pkg/util/zip_extensions.go @@ -105,6 +105,9 @@ func ZipExtensionDirectory(srcDir, destZip string, opts *ExtensionZipOptions) (* walker.ExcludeFilename = append(walker.ExcludeFilename, DefaultExtensionExclusions.ExcludeFilenamePatterns...) } + // Ensure walker is terminated on any return path to prevent goroutine leak + defer walker.Terminate() + // Track walker errors errChan := make(chan error, 1) go func() { From 45e073b32637c089c99ea4ad60550a47b83319e0 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 4 Feb 2026 14:42:34 +0000 Subject: [PATCH 5/6] review: make zip extensions more generic --- cmd/browsers.go | 2 +- cmd/deploy.go | 2 +- cmd/extensions.go | 39 +++--- pkg/util/zip.go | 41 +++++- pkg/util/zip_extensions.go | 223 -------------------------------- pkg/util/zip_extensions_test.go | 149 --------------------- pkg/util/zip_test.go | 118 +++++++++++++++++ 7 files changed, 181 insertions(+), 393 deletions(-) delete mode 100644 pkg/util/zip_extensions.go delete mode 100644 pkg/util/zip_extensions_test.go create mode 100644 pkg/util/zip_test.go diff --git a/cmd/browsers.go b/cmd/browsers.go index f3de63c..3539dba 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -1940,7 +1940,7 @@ func (b BrowsersCmd) ExtensionsUpload(ctx context.Context, in BrowsersExtensions tempZipPath := filepath.Join(os.TempDir(), fmt.Sprintf("kernel-ext-%s.zip", extName)) pterm.Info.Printf("Zipping %s as %s...\n", extPath, extName) - if err := util.ZipDirectory(extPath, tempZipPath); err != nil { + if err := util.ZipDirectory(extPath, tempZipPath, nil); err != nil { pterm.Error.Printf("Failed to zip %s: %v\n", extPath, err) return nil } diff --git a/cmd/deploy.go b/cmd/deploy.go index 72301b8..e826eb0 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -236,7 +236,7 @@ func runDeploy(cmd *cobra.Command, args []string) (err error) { } tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("kernel_%d.zip", time.Now().UnixNano())) logger.Debug("compressing files", logger.Args("sourceDir", sourceDir, "tmpFile", tmpFile)) - if err := util.ZipDirectory(sourceDir, tmpFile); err != nil { + if err := util.ZipDirectory(sourceDir, tmpFile, nil); err != nil { if spinner != nil { spinner.Fail("Failed to compress files") } diff --git a/cmd/extensions.go b/cmd/extensions.go index e1a92ba..ca7e211 100644 --- a/cmd/extensions.go +++ b/cmd/extensions.go @@ -19,10 +19,28 @@ import ( ) const ( - // MaxExtensionSize is the maximum allowed size for extension bundles from API (50MB) - MaxExtensionSize = 50 * 1024 * 1024 + MaxExtensionSizeBytes = 50 * 1024 * 1024 // 50MB ) +// defaultExtensionExclusions contains patterns for files that are not needed +// when zipping Chrome extensions +var defaultExtensionExclusions = util.ZipOptions{ + ExcludeDirectories: []string{ + "node_modules", + ".git", + "__tests__", + "coverage", + }, + ExcludeFilenamePatterns: []string{ + "*.test.js", + "*.test.ts", + "*.spec.js", + "*.spec.ts", + "*.log", + "*.swp", + }, +} + // ExtensionsService defines the subset of the Kernel SDK extension client that we use. type ExtensionsService interface { List(ctx context.Context, opts ...option.RequestOption) (res *[]kernel.ExtensionListResponse, err error) @@ -310,30 +328,22 @@ func (e ExtensionsCmd) Upload(ctx context.Context, in ExtensionsUploadInput) err pterm.Info.Println("Zipping extension directory...") } - // Use new extension-specific zip function - stats, err := util.ZipExtensionDirectory(absDir, tmpFile, &util.ExtensionZipOptions{ - ExcludeDefaults: false, // Apply defaults - }) - if err != nil { + if err := util.ZipDirectory(absDir, tmpFile, &defaultExtensionExclusions); err != nil { pterm.Error.Println("Failed to zip directory") return err } defer os.Remove(tmpFile) - // Get zip file size fileInfo, err := os.Stat(tmpFile) if err != nil { return fmt.Errorf("failed to stat zip: %w", err) } if in.Output != "json" { - pterm.Success.Printf("Created bundle: %s (%d files)\n", - util.FormatBytes(fileInfo.Size()), stats.FilesIncluded) + pterm.Success.Printf("Created bundle: %s\n", util.FormatBytes(fileInfo.Size())) } - // Final size validation - - if fileInfo.Size() > MaxExtensionSize { + if fileInfo.Size() > MaxExtensionSizeBytes { pterm.Error.Printf("Extension bundle is too large: %s (max: 50MB)\n", util.FormatBytes(fileInfo.Size())) pterm.Info.Println("\nSuggestions to reduce size:") @@ -343,14 +353,12 @@ func (e ExtensionsCmd) Upload(ctx context.Context, in ExtensionsUploadInput) err return fmt.Errorf("bundle exceeds maximum size") } - // Open file for upload f, err := os.Open(tmpFile) if err != nil { return fmt.Errorf("failed to open temp zip: %w", err) } defer f.Close() - // Upload if in.Output != "json" { pterm.Info.Println("Uploading extension...") } @@ -365,7 +373,6 @@ func (e ExtensionsCmd) Upload(ctx context.Context, in ExtensionsUploadInput) err return util.CleanedUpSdkError{Err: err} } - // Display results if in.Output == "json" { return util.PrintPrettyJSON(item) } diff --git a/pkg/util/zip.go b/pkg/util/zip.go index 468721d..111bca0 100644 --- a/pkg/util/zip.go +++ b/pkg/util/zip.go @@ -11,8 +11,16 @@ import ( "github.com/boyter/gocodewalker" ) +// ZipOptions which directories and files to exclude from the zip +type ZipOptions struct { + // ExcludeDirectories: exact directory names to exclude (case-sensitive) + ExcludeDirectories []string + // ExcludeFilenamePatterns: glob patterns for filename exclusion (e.g., "*.test.js") + ExcludeFilenamePatterns []string +} + // ZipDirectory compresses the given source directory into the destination file path. -func ZipDirectory(srcDir, destZip string) error { +func ZipDirectory(srcDir, destZip string, opts *ZipOptions) error { zipFile, err := os.Create(destZip) if err != nil { return err @@ -28,9 +36,16 @@ func ZipDirectory(srcDir, destZip string) error { // Include hidden files (to match previous behaviour) but still respect .gitignore rules walker.IncludeHidden = true - // Start walking in a separate goroutine so we can process files as they arrive + // Apply directory exclusions to walker + if opts != nil { + walker.ExcludeDirectory = append(walker.ExcludeDirectory, opts.ExcludeDirectories...) + } + + defer walker.Terminate() + + errChan := make(chan error, 1) go func() { - _ = walker.Start() + errChan <- walker.Start() }() // Track directories we've already added to the zip archive so we don't duplicate entries @@ -44,6 +59,22 @@ func ZipDirectory(srcDir, destZip string) error { } relPath = filepath.ToSlash(relPath) + // Check against pattern-based exclusions if provided + if opts != nil && len(opts.ExcludeFilenamePatterns) > 0 { + filename := filepath.Base(f.Location) + shouldExclude := false + for _, pattern := range opts.ExcludeFilenamePatterns { + matched, err := filepath.Match(pattern, filename) + if err == nil && matched { + shouldExclude = true + break + } + } + if shouldExclude { + continue + } + } + // Ensure parent directories exist in the archive if dir := filepath.Dir(relPath); dir != "." && dir != "" { // Walk up the directory tree ensuring each level exists @@ -115,6 +146,10 @@ func ZipDirectory(srcDir, destZip string) error { } } + if err := <-errChan; err != nil { + return fmt.Errorf("directory walk failed: %w", err) + } + return nil } diff --git a/pkg/util/zip_extensions.go b/pkg/util/zip_extensions.go deleted file mode 100644 index c753647..0000000 --- a/pkg/util/zip_extensions.go +++ /dev/null @@ -1,223 +0,0 @@ -package util - -import ( - "archive/zip" - "fmt" - "io" - "os" - "path/filepath" - "strings" - "sync" - - "github.com/boyter/gocodewalker" -) - -// DefaultExtensionExclusions contains patterns for files that are not needed -// Focus on large directories and files that provide meaningful size reduction. -// These will be passed to gocodewalker's ExcludeDirectory and ExcludeFilename fields. -var DefaultExtensionExclusions = struct { - // ExcludeDirectory: exact directory names (case-sensitive) - ExcludeDirectory []string - // ExcludeFilenamePatterns: patterns for filename matching (handled manually) - ExcludeFilenamePatterns []string -}{ - ExcludeDirectory: []string{ - // Dependencies - "node_modules", - - // Version control - ".git", - - // Test/Coverage - "__tests__", - "coverage", - }, - - ExcludeFilenamePatterns: []string{ - // Test files - "*.test.js", - "*.test.ts", - "*.spec.js", - "*.spec.ts", - - // Log files - "*.log", - - // Temp files - "*.swp", - }, -} - -// ExtensionZipOptions configures extension-specific zipping behavior -type ExtensionZipOptions struct { - ExcludeDefaults bool // If true, don't apply default exclusions -} - -// ZipStats tracks statistics about the zipping operation -type ZipStats struct { - mu sync.Mutex - FilesIncluded int - FilesExcluded int - BytesIncluded int64 - BytesExcluded int64 -} - -func (s *ZipStats) AddIncluded(bytes int64) { - s.mu.Lock() - defer s.mu.Unlock() - s.FilesIncluded++ - s.BytesIncluded += bytes -} - -func (s *ZipStats) AddExcluded(bytes int64) { - s.mu.Lock() - defer s.mu.Unlock() - s.FilesExcluded++ - s.BytesExcluded += bytes -} - -// ZipExtensionDirectory zips a Chrome extension directory with smart defaults -// that automatically exclude development files (node_modules, .git, etc.) -func ZipExtensionDirectory(srcDir, destZip string, opts *ExtensionZipOptions) (*ZipStats, error) { - if opts == nil { - opts = &ExtensionZipOptions{} - } - - stats := &ZipStats{} - - zipFile, err := os.Create(destZip) - if err != nil { - return nil, err - } - defer zipFile.Close() - - zipWriter := zip.NewWriter(zipFile) - defer zipWriter.Close() - - // Configure file walker with exclusions - fileQueue := make(chan *gocodewalker.File, 256) - walker := gocodewalker.NewFileWalker(srcDir, fileQueue) - walker.IncludeHidden = true - - // Apply default exclusions unless disabled - if !opts.ExcludeDefaults { - walker.ExcludeDirectory = append(walker.ExcludeDirectory, DefaultExtensionExclusions.ExcludeDirectory...) - walker.ExcludeFilename = append(walker.ExcludeFilename, DefaultExtensionExclusions.ExcludeFilenamePatterns...) - } - - // Ensure walker is terminated on any return path to prevent goroutine leak - defer walker.Terminate() - - // Track walker errors - errChan := make(chan error, 1) - go func() { - errChan <- walker.Start() - }() - - // Track directories we've added to avoid duplicates - dirsAdded := make(map[string]struct{}) - - // Process files from walker - for f := range fileQueue { - relPath, err := filepath.Rel(srcDir, f.Location) - if err != nil { - return stats, err - } - relPath = filepath.ToSlash(relPath) - - // Check against pattern-based exclusions (if defaults are enabled) - shouldExclude := false - if !opts.ExcludeDefaults { - // Check filename against patterns only if defaults are enabled - filename := filepath.Base(f.Location) - for _, pattern := range DefaultExtensionExclusions.ExcludeFilenamePatterns { - matched, err := filepath.Match(pattern, filename) - if err == nil && matched { - shouldExclude = true - break - } - } - } - - if shouldExclude { - continue - } - - // Ensure parent directories exist in archive - if dir := filepath.Dir(relPath); dir != "." && dir != "" { - segments := strings.Split(dir, "/") - var current string - for _, segment := range segments { - if current == "" { - current = segment - } else { - current = current + "/" + segment - } - if _, exists := dirsAdded[current+"/"]; !exists { - if _, err := zipWriter.Create(current + "/"); err != nil { - return stats, err - } - dirsAdded[current+"/"] = struct{}{} - } - } - } - - // Get file info - fileInfo, err := os.Lstat(f.Location) - if err != nil { - return stats, err - } - - // Handle symlinks - if fileInfo.Mode()&os.ModeSymlink != 0 { - linkTarget, err := os.Readlink(f.Location) - if err != nil { - return stats, err - } - - hdr := &zip.FileHeader{ - Name: relPath, - Method: zip.Store, - } - hdr.SetMode(os.ModeSymlink | 0777) - - zipFileWriter, err := zipWriter.CreateHeader(hdr) - if err != nil { - return stats, err - } - if _, err := zipFileWriter.Write([]byte(linkTarget)); err != nil { - return stats, err - } - stats.AddIncluded(int64(len(linkTarget))) - } else { - // Regular file - zipFileWriter, err := zipWriter.Create(relPath) - if err != nil { - return stats, err - } - - file, err := os.Open(f.Location) - if err != nil { - return stats, err - } - - written, err := io.Copy(zipFileWriter, file) - closeErr := file.Close() - if closeErr != nil { - return stats, closeErr - } - if err != nil { - return stats, err - } - - stats.AddIncluded(written) - } - } - - // Check if walker had an error - if err := <-errChan; err != nil { - return stats, fmt.Errorf("directory walk failed: %w", err) - } - - return stats, nil -} diff --git a/pkg/util/zip_extensions_test.go b/pkg/util/zip_extensions_test.go deleted file mode 100644 index a7afdf8..0000000 --- a/pkg/util/zip_extensions_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package util - -import ( - "archive/zip" - "os" - "path/filepath" - "testing" -) - -func TestZipExtensionDirectory(t *testing.T) { - // Create a temporary directory structure for testing - tmpDir, err := os.MkdirTemp("", "zip-extension-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - // Create test file structure - files := map[string]string{ - "manifest.json": `{"name": "test", "version": "1.0"}`, - "background.js": "console.log('background');", - "content.js": "console.log('content');", - "icons/icon.png": "fake-png-data", - "node_modules/dep/foo.js": "should be excluded", - "test.test.js": "should be excluded", - } - - for path, content := range files { - fullPath := filepath.Join(tmpDir, path) - dir := filepath.Dir(fullPath) - if err := os.MkdirAll(dir, 0755); err != nil { - t.Fatalf("Failed to create directory %s: %v", dir, err) - } - if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { - t.Fatalf("Failed to write file %s: %v", fullPath, err) - } - } - - // Create output zip file - tmpZip, err := os.CreateTemp("", "test-extension-*.zip") - if err != nil { - t.Fatalf("Failed to create temp zip: %v", err) - } - tmpZip.Close() - defer os.Remove(tmpZip.Name()) - - // Test with default exclusions - t.Run("with default exclusions", func(t *testing.T) { - _, err := ZipExtensionDirectory(tmpDir, tmpZip.Name(), &ExtensionZipOptions{ - ExcludeDefaults: false, - }) - if err != nil { - t.Fatalf("ZipExtensionDirectory failed: %v", err) - } - - // Verify the zip contents - r, err := zip.OpenReader(tmpZip.Name()) - if err != nil { - t.Fatalf("Failed to open zip: %v", err) - } - defer r.Close() - - expectedFiles := map[string]bool{ - "manifest.json": false, - "background.js": false, - "content.js": false, - "icons/": false, - "icons/icon.png": false, - } - - for _, f := range r.File { - if f.FileInfo().IsDir() { - expectedFiles[f.Name] = true - } else { - if _, ok := expectedFiles[f.Name]; ok { - expectedFiles[f.Name] = true - } else { - // Check for files that should be excluded - if contains([]string{"node_modules", ".git", "test.test.js", "package-lock.json", "__tests__"}, f.Name) { - t.Errorf("Excluded file found in zip: %s", f.Name) - } - } - } - } - - for name, found := range expectedFiles { - if !found && name != "icons/" { - t.Errorf("Expected file not found in zip: %s", name) - } - } - }) - - // Test without exclusions - t.Run("without default exclusions", func(t *testing.T) { - tmpZip2, err := os.CreateTemp("", "test-extension-no-exclude-*.zip") - if err != nil { - t.Fatalf("Failed to create temp zip: %v", err) - } - tmpZip2.Close() - defer os.Remove(tmpZip2.Name()) - - stats, err := ZipExtensionDirectory(tmpDir, tmpZip2.Name(), &ExtensionZipOptions{ - ExcludeDefaults: true, // Disable default exclusions - }) - if err != nil { - t.Fatalf("ZipExtensionDirectory failed: %v", err) - } - - // Should include all files when exclusions are disabled - if stats.FilesIncluded <= 4 { - t.Errorf("Expected more than 4 files when exclusions are disabled, got %d", stats.FilesIncluded) - } - }) -} - -func TestDefaultExtensionExclusions(t *testing.T) { - // Verify the exclusion lists are not empty - if len(DefaultExtensionExclusions.ExcludeDirectory) == 0 { - t.Error("ExcludeDirectory should not be empty") - } - if len(DefaultExtensionExclusions.ExcludeFilenamePatterns) == 0 { - t.Error("ExcludeFilenamePatterns should not be empty") - } - - // Verify specific important exclusions are present - expectedDirs := []string{"node_modules", ".git", "__tests__", "coverage"} - for _, path := range expectedDirs { - if !contains(DefaultExtensionExclusions.ExcludeDirectory, path) { - t.Errorf("Expected directory exclusion not found: %s", path) - } - } - - expectedPatterns := []string{"*.test.js", "*.test.ts", "*.spec.js", "*.spec.ts", "*.log", "*.swp"} - for _, pattern := range expectedPatterns { - if !contains(DefaultExtensionExclusions.ExcludeFilenamePatterns, pattern) { - t.Errorf("Expected pattern exclusion not found: %s", pattern) - } - } -} - -// Helper function to check if a slice contains a string -func contains(slice []string, str string) bool { - for _, s := range slice { - if s == str { - return true - } - } - return false -} diff --git a/pkg/util/zip_test.go b/pkg/util/zip_test.go new file mode 100644 index 0000000..162bac5 --- /dev/null +++ b/pkg/util/zip_test.go @@ -0,0 +1,118 @@ +package util + +import ( + "archive/zip" + "os" + "path/filepath" + "testing" +) + +func TestZipDirectory(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "zip-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + files := map[string]string{ + "manifest.json": `{"name": "test", "version": "1.0"}`, + "background.js": "console.log('background');", + "content.js": "console.log('content');", + "icons/icon.png": "fake-png-data", + "node_modules/dep/foo.js": "should be excluded", + "test.test.js": "should be excluded", + } + + for path, content := range files { + fullPath := filepath.Join(tmpDir, path) + dir := filepath.Dir(fullPath) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("Failed to create directory %s: %v", dir, err) + } + if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { + t.Fatalf("Failed to write file %s: %v", fullPath, err) + } + } + + tmpZip, err := os.CreateTemp("", "test-zip-*.zip") + if err != nil { + t.Fatalf("Failed to create temp zip: %v", err) + } + tmpZip.Close() + defer os.Remove(tmpZip.Name()) + + // Test with exclusions + t.Run("with exclusions", func(t *testing.T) { + opts := &ZipOptions{ + ExcludeDirectories: []string{"node_modules"}, + ExcludeFilenamePatterns: []string{"*.test.js"}, + } + if err := ZipDirectory(tmpDir, tmpZip.Name(), opts); err != nil { + t.Fatalf("ZipDirectory failed: %v", err) + } + + // Verify the zip contents + r, err := zip.OpenReader(tmpZip.Name()) + if err != nil { + t.Fatalf("Failed to open zip: %v", err) + } + defer r.Close() + + expectedFiles := map[string]bool{ + "manifest.json": false, + "background.js": false, + "content.js": false, + "icons/": false, + "icons/icon.png": false, + } + + for _, f := range r.File { + if f.FileInfo().IsDir() { + expectedFiles[f.Name] = true + } else { + if _, ok := expectedFiles[f.Name]; ok { + expectedFiles[f.Name] = true + } else { + t.Errorf("Unexpected file found in zip: %s", f.Name) + } + } + } + + for name, found := range expectedFiles { + if !found && name != "icons/" { + t.Errorf("Expected file not found in zip: %s", name) + } + } + }) + + // Test without exclusions (nil opts) + t.Run("without exclusions", func(t *testing.T) { + tmpZip2, err := os.CreateTemp("", "test-zip-no-exclude-*.zip") + if err != nil { + t.Fatalf("Failed to create temp zip: %v", err) + } + tmpZip2.Close() + defer os.Remove(tmpZip2.Name()) + + if err := ZipDirectory(tmpDir, tmpZip2.Name(), nil); err != nil { + t.Fatalf("ZipDirectory failed: %v", err) + } + + // Verify all files are included (no exclusions) + r, err := zip.OpenReader(tmpZip2.Name()) + if err != nil { + t.Fatalf("Failed to open zip: %v", err) + } + defer r.Close() + + fileCount := 0 + for _, f := range r.File { + if !f.FileInfo().IsDir() { + fileCount++ + } + } + if fileCount <= 4 { + t.Errorf("Expected more than 4 files when exclusions are disabled, got %d", fileCount) + } + }) +} From 27ef5de191977b5f8b318018c4dbd4d70dffc67a Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 4 Feb 2026 15:01:43 +0000 Subject: [PATCH 6/6] review: add test for unzip --- pkg/util/zip_test.go | 60 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/pkg/util/zip_test.go b/pkg/util/zip_test.go index 162bac5..6fbeb37 100644 --- a/pkg/util/zip_test.go +++ b/pkg/util/zip_test.go @@ -116,3 +116,63 @@ func TestZipDirectory(t *testing.T) { } }) } + +func TestUnzip(t *testing.T) { + tmpZip, err := os.CreateTemp("", "test-unzip-*.zip") + if err != nil { + t.Fatalf("Failed to create temp zip: %v", err) + } + tmpZip.Close() + defer os.Remove(tmpZip.Name()) + + zw, err := os.Create(tmpZip.Name()) + if err != nil { + t.Fatalf("Failed to open zip for writing: %v", err) + } + zipWriter := zip.NewWriter(zw) + + testFiles := map[string]string{ + "file1.txt": "content of file 1", + "subdir/file2.txt": "content of file 2", + } + + if _, err := zipWriter.Create("subdir/"); err != nil { + t.Fatalf("Failed to create dir entry: %v", err) + } + + for name, content := range testFiles { + w, err := zipWriter.Create(name) + if err != nil { + t.Fatalf("Failed to create zip entry %s: %v", name, err) + } + if _, err := w.Write([]byte(content)); err != nil { + t.Fatalf("Failed to write zip entry %s: %v", name, err) + } + } + zipWriter.Close() + zw.Close() + + // Unzip to temp directory + destDir, err := os.MkdirTemp("", "test-unzip-dest-*") + if err != nil { + t.Fatalf("Failed to create dest dir: %v", err) + } + defer os.RemoveAll(destDir) + + if err := Unzip(tmpZip.Name(), destDir); err != nil { + t.Fatalf("Unzip failed: %v", err) + } + + // Verify extracted files + for name, expectedContent := range testFiles { + path := filepath.Join(destDir, name) + content, err := os.ReadFile(path) + if err != nil { + t.Errorf("Failed to read extracted file %s: %v", name, err) + continue + } + if string(content) != expectedContent { + t.Errorf("File %s: expected %q, got %q", name, expectedContent, string(content)) + } + } +}