diff --git a/cmd/root.go b/cmd/root.go index 72c60d1..2fcda39 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -12,7 +12,7 @@ import ( "github.com/mattn/go-isatty" "github.com/spf13/cobra" - "github.com/decampsrenan/spm/internal/audio" + "github.com/decampsrenan/spm/internal/audio/playback" "github.com/decampsrenan/spm/internal/detector" "github.com/decampsrenan/spm/internal/ecosystem" "github.com/decampsrenan/spm/internal/progress" @@ -142,7 +142,7 @@ var playSoundCmd = &cobra.Command{ Hidden: true, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return audio.PlaySound(args[0]) + return playback.PlaySound(args[0]) }, } @@ -155,7 +155,7 @@ var playMusicCmd = &cobra.Command{ if err != nil { return fmt.Errorf("invalid fade-in duration: %w", err) } - return audio.PlayMusicAndWait(time.Duration(secs) * time.Second) + return playback.PlayMusicAndWait(time.Duration(secs) * time.Second) }, } diff --git a/cmd/upgrade.go b/cmd/upgrade.go index 69d4b5c..f28ced6 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -56,7 +56,7 @@ func runUpgrade() error { ui.Println(ui.Dim(fmt.Sprintf("Downloading %s...", result.LatestVersion))) - if err := updater.Execute(result); err != nil { + if err := updater.Execute(&updater.HTTPDownloader{}, result); err != nil { return err } diff --git a/internal/audio/audio_test.go b/internal/audio/audio_test.go index 6fd78a6..b9d48f0 100644 --- a/internal/audio/audio_test.go +++ b/internal/audio/audio_test.go @@ -5,26 +5,6 @@ import ( "testing" ) -func TestNewPlayer(t *testing.T) { - p := NewPlayer() - if p == nil { - t.Fatal("NewPlayer returned nil") - } -} - -func TestStopWithoutPlay(t *testing.T) { - p := NewPlayer() - // Stop on a player that was never started should not panic. - p.Stop() -} - -func TestDoubleStop(t *testing.T) { - p := NewPlayer() - // Double stop should not panic. - p.Stop() - p.Stop() -} - func TestVibesProcessStopImmediatelyNilCmd(t *testing.T) { // StopImmediately on a VibesProcess with nil cmd should not panic. // This is the case when SPM_DISABLE_AUDIO=1. diff --git a/internal/audio/ding.mp3 b/internal/audio/playback/ding.mp3 similarity index 100% rename from internal/audio/ding.mp3 rename to internal/audio/playback/ding.mp3 diff --git a/internal/audio/error-001.mp3 b/internal/audio/playback/error-001.mp3 similarity index 100% rename from internal/audio/error-001.mp3 rename to internal/audio/playback/error-001.mp3 diff --git a/internal/audio/notification-pop.mp3 b/internal/audio/playback/notification-pop.mp3 similarity index 100% rename from internal/audio/notification-pop.mp3 rename to internal/audio/playback/notification-pop.mp3 diff --git a/internal/audio/audio_playback.go b/internal/audio/playback/playback.go similarity index 96% rename from internal/audio/audio_playback.go rename to internal/audio/playback/playback.go index a6d3b19..37a1bd9 100644 --- a/internal/audio/audio_playback.go +++ b/internal/audio/playback/playback.go @@ -1,6 +1,6 @@ //go:build darwin || cgo -package audio +package playback import ( "bytes" @@ -19,6 +19,8 @@ import ( "github.com/gopxl/beep/v2/effects" "github.com/gopxl/beep/v2/mp3" "github.com/gopxl/beep/v2/speaker" + + "github.com/decampsrenan/spm/internal/audio" ) //go:embed tashkent.mp3 @@ -230,12 +232,12 @@ func PlayMusicAndWait(fadeIn time.Duration) error { // This is intended to be called from the hidden _play-sound subcommand. func PlaySound(name string) error { var data []byte - switch SoundName(name) { - case SoundSuccess: + switch audio.SoundName(name) { + case audio.SoundSuccess: data = successSoundData - case SoundError: + case audio.SoundError: data = errorSoundData - case SoundDing: + case audio.SoundDing: data = dingSoundData default: return fmt.Errorf("unknown sound: %s", name) diff --git a/internal/audio/audio_playback_stub.go b/internal/audio/playback/playback_stub.go similarity index 83% rename from internal/audio/audio_playback_stub.go rename to internal/audio/playback/playback_stub.go index f3246a5..7c0a792 100644 --- a/internal/audio/audio_playback_stub.go +++ b/internal/audio/playback/playback_stub.go @@ -1,10 +1,12 @@ //go:build !darwin && !cgo -package audio +package playback import ( "fmt" "time" + + "github.com/decampsrenan/spm/internal/audio" ) // Player is a no-op stub used when audio playback is not supported. @@ -27,8 +29,8 @@ func PlayMusicAndWait(fadeIn time.Duration) error { return nil } // PlaySound is a no-op on unsupported platforms. func PlaySound(name string) error { - switch SoundName(name) { - case SoundSuccess, SoundError, SoundDing: + switch audio.SoundName(name) { + case audio.SoundSuccess, audio.SoundError, audio.SoundDing: return nil default: return fmt.Errorf("unknown sound: %s", name) diff --git a/internal/audio/audio_playback_test.go b/internal/audio/playback/playback_test.go similarity index 60% rename from internal/audio/audio_playback_test.go rename to internal/audio/playback/playback_test.go index 7f45c3f..4da49ed 100644 --- a/internal/audio/audio_playback_test.go +++ b/internal/audio/playback/playback_test.go @@ -1,11 +1,31 @@ //go:build darwin || cgo -package audio +package playback import ( "testing" ) +func TestNewPlayer(t *testing.T) { + p := NewPlayer() + if p == nil { + t.Fatal("NewPlayer returned nil") + } +} + +func TestStopWithoutPlay(t *testing.T) { + p := NewPlayer() + // Stop on a player that was never started should not panic. + p.Stop() +} + +func TestDoubleStop(t *testing.T) { + p := NewPlayer() + // Double stop should not panic. + p.Stop() + p.Stop() +} + func TestTrackDataEmbedded(t *testing.T) { if len(trackData) == 0 { t.Fatal("embedded track data is empty") diff --git a/internal/audio/tashkent.mp3 b/internal/audio/playback/tashkent.mp3 similarity index 100% rename from internal/audio/tashkent.mp3 rename to internal/audio/playback/tashkent.mp3 diff --git a/internal/progress/model_test.go b/internal/progress/model_test.go new file mode 100644 index 0000000..b372d12 --- /dev/null +++ b/internal/progress/model_test.go @@ -0,0 +1,166 @@ +package progress + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" +) + +func TestAddLine_AppendsBelowLimit(t *testing.T) { + m := model{} + m.addLine("one") + m.addLine("two") + if len(m.lines) != 2 { + t.Fatalf("expected 2 lines, got %d", len(m.lines)) + } + if m.lines[0] != "one" || m.lines[1] != "two" { + t.Errorf("unexpected lines: %v", m.lines) + } +} + +func TestAddLine_RingBufferDropsOldest(t *testing.T) { + m := model{} + for i := 0; i < maxLogLines+3; i++ { + m.addLine("line-" + string(rune('A'+i))) + } + if len(m.lines) != maxLogLines { + t.Fatalf("expected %d lines (ring buffer), got %d", maxLogLines, len(m.lines)) + } + // First line should be the (maxLogLines+3)-th added, i.e. index 3. + want := "line-" + string(rune('A'+3)) + if m.lines[0] != want { + t.Errorf("expected oldest line %q, got %q", want, m.lines[0]) + } +} + +func TestUpdate_OutputLine_AppendsAndReturnsCmd(t *testing.T) { + ch := make(chan tea.Msg, 1) + m := newProgressModel(ch) + next, cmd := m.Update(outputLineMsg("hello world")) + nm := next.(model) + if len(nm.lines) != 1 || nm.lines[0] != "hello world" { + t.Errorf("expected line appended, got %v", nm.lines) + } + if cmd == nil { + t.Error("expected a follow-up Cmd to continue listening") + } + close(ch) +} + +func TestUpdate_OutputLine_IgnoresEmpty(t *testing.T) { + ch := make(chan tea.Msg, 1) + m := newProgressModel(ch) + next, _ := m.Update(outputLineMsg(" ")) + nm := next.(model) + if len(nm.lines) != 0 { + t.Errorf("expected empty line to be dropped, got %v", nm.lines) + } + close(ch) +} + +func TestUpdate_DoneMsg_SetsFieldsAndQuits(t *testing.T) { + ch := make(chan tea.Msg) + m := newProgressModel(ch) + next, cmd := m.Update(doneMsg{exitCode: 42, err: nil}) + nm := next.(model) + if !nm.done { + t.Error("expected done=true") + } + if nm.exitCode != 42 { + t.Errorf("expected exitCode=42, got %d", nm.exitCode) + } + if cmd == nil { + t.Error("expected tea.Quit cmd") + } +} + +func TestUpdate_WindowSize_UpdatesWidth(t *testing.T) { + m := newProgressModel(make(chan tea.Msg)) + next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + if next.(model).width != 120 { + t.Errorf("expected width=120, got %d", next.(model).width) + } +} + +func TestUpdate_TimerTick_WhileRunning_ReturnsCmd(t *testing.T) { + m := newProgressModel(make(chan tea.Msg)) + _, cmd := m.Update(timerTickMsg(time.Now())) + if cmd == nil { + t.Error("expected timer tick to schedule next tick while running") + } +} + +func TestUpdate_TimerTick_WhenDone_NoCmd(t *testing.T) { + m := newProgressModel(make(chan tea.Msg)) + m.done = true + _, cmd := m.Update(timerTickMsg(time.Now())) + if cmd != nil { + t.Error("expected no follow-up tick when done") + } +} + +func TestView_SuccessShowsInstalled(t *testing.T) { + m := newProgressModel(make(chan tea.Msg)) + m.done = true + m.exitCode = 0 + out := m.View().Content + if !strings.Contains(out, "Installed") { + t.Errorf("expected 'Installed' in success view, got %q", out) + } +} + +func TestView_FailureShowsFailed(t *testing.T) { + m := newProgressModel(make(chan tea.Msg)) + m.done = true + m.exitCode = 1 + out := m.View().Content + if !strings.Contains(out, "Failed") { + t.Errorf("expected 'Failed' in failure view, got %q", out) + } +} + +func TestView_RunningShowsInstalling(t *testing.T) { + m := newProgressModel(make(chan tea.Msg)) + out := m.View().Content + if !strings.Contains(out, "Installing") { + t.Errorf("expected 'Installing' while running, got %q", out) + } +} + +func TestView_TruncatesLongLines(t *testing.T) { + m := newProgressModel(make(chan tea.Msg)) + m.width = 20 + m.addLine(strings.Repeat("x", 100)) + out := m.View().Content + if !strings.Contains(out, "…") { + t.Errorf("expected truncation marker in output, got %q", out) + } +} + +func TestInit_ReturnsBatch(t *testing.T) { + m := newProgressModel(make(chan tea.Msg)) + if cmd := m.Init(); cmd == nil { + t.Error("Init returned nil cmd") + } +} + +func TestListenCh_ReturnsMsg(t *testing.T) { + ch := make(chan tea.Msg, 1) + ch <- outputLineMsg("x") + cmd := listenCh(ch) + msg := cmd() + if _, ok := msg.(outputLineMsg); !ok { + t.Errorf("expected outputLineMsg, got %T", msg) + } +} + +func TestListenCh_ClosedChannelReturnsNil(t *testing.T) { + ch := make(chan tea.Msg) + close(ch) + cmd := listenCh(ch) + if msg := cmd(); msg != nil { + t.Errorf("expected nil from closed channel, got %v", msg) + } +} diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go index d0eb508..ba6388f 100644 --- a/internal/prompt/prompt.go +++ b/internal/prompt/prompt.go @@ -64,13 +64,17 @@ func Select(detections []detector.Detection) (detector.Detection, error) { return detector.Detection{}, err } + return findDetectionByPM(detections, choice) +} + +// findDetectionByPM returns the detection whose PM matches the given name. +func findDetectionByPM(detections []detector.Detection, pm string) (detector.Detection, error) { for _, d := range detections { - if string(d.PM) == choice { + if string(d.PM) == pm { return d, nil } } - - return detector.Detection{}, fmt.Errorf("unexpected selection: %s", choice) + return detector.Detection{}, fmt.Errorf("unexpected selection: %s", pm) } // SelectScript asks the user to pick a script from the available list. @@ -80,16 +84,9 @@ func SelectScript(scriptNames []string, scriptCmds []string) (string, error) { return "", fmt.Errorf("no script specified and stdin is not a TTY — cannot prompt") } - const maxCmdLen = 40 - options := make([]huh.Option[int], len(scriptNames)) for i, name := range scriptNames { - cmd := scriptCmds[i] - if len(cmd) > maxCmdLen { - cmd = cmd[:maxCmdLen-1] + "…" - } - label := fmt.Sprintf("%s — %s", name, ui.Dim(cmd)) - options[i] = huh.NewOption(label, i) + options[i] = huh.NewOption(scriptOptionLabel(name, scriptCmds[i]), i) } var choice int @@ -115,6 +112,22 @@ func SelectScript(scriptNames []string, scriptCmds []string) (string, error) { return scriptNames[choice], nil } +// scriptOptionLabel formats a script entry as "name — cmd", truncating the +// command to scriptCmdMaxLen with an ellipsis if necessary. +func scriptOptionLabel(name, cmd string) string { + return fmt.Sprintf("%s — %s", name, ui.Dim(truncateCmd(cmd, scriptCmdMaxLen))) +} + +const scriptCmdMaxLen = 40 + +// truncateCmd shortens cmd to at most maxLen runes, ending with "…" when cut. +func truncateCmd(cmd string, maxLen int) string { + if len(cmd) <= maxLen { + return cmd + } + return cmd[:maxLen-1] + "…" +} + // SelectPM asks the user to pick a package manager (used by spm init). func SelectPM() (ecosystem.PackageManager, error) { if !isatty.IsTerminal(os.Stdin.Fd()) && !isatty.IsCygwinTerminal(os.Stdin.Fd()) { diff --git a/internal/prompt/prompt_test.go b/internal/prompt/prompt_test.go index 7e238c8..00751db 100644 --- a/internal/prompt/prompt_test.go +++ b/internal/prompt/prompt_test.go @@ -44,3 +44,89 @@ func TestSelectFromAllNonTTY(t *testing.T) { t.Fatal("expected error when stdin is not a TTY") } } + +func TestFindDetectionByPM_Found(t *testing.T) { + detections := []detector.Detection{ + {PM: ecosystem.NPM, Dir: "/a"}, + {PM: ecosystem.Yarn, Dir: "/b"}, + {PM: ecosystem.Pnpm, Dir: "/c"}, + } + got, err := findDetectionByPM(detections, string(ecosystem.Yarn)) + if err != nil { + t.Fatal(err) + } + if got.PM != ecosystem.Yarn || got.Dir != "/b" { + t.Errorf("unexpected detection: %+v", got) + } +} + +func TestFindDetectionByPM_NotFound(t *testing.T) { + detections := []detector.Detection{{PM: ecosystem.NPM, Dir: "/a"}} + _, err := findDetectionByPM(detections, "unknown") + if err == nil { + t.Fatal("expected error for unknown PM") + } +} + +func TestFindDetectionByPM_EmptySlice(t *testing.T) { + _, err := findDetectionByPM(nil, string(ecosystem.NPM)) + if err == nil { + t.Fatal("expected error for empty detections") + } +} + +func TestTruncateCmd(t *testing.T) { + tests := []struct { + name string + cmd string + maxLen int + want string + }{ + {"below limit", "vite", 40, "vite"}, + {"at limit", "1234567890", 10, "1234567890"}, + {"truncated", "12345678901234567890", 10, "123456789…"}, + {"empty", "", 10, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := truncateCmd(tt.cmd, tt.maxLen); got != tt.want { + t.Errorf("truncateCmd(%q, %d) = %q, want %q", tt.cmd, tt.maxLen, got, tt.want) + } + }) + } +} + +func TestScriptOptionLabel_ContainsNameAndCmd(t *testing.T) { + label := scriptOptionLabel("dev", "vite") + if label == "" { + t.Fatal("expected non-empty label") + } + // The label is "name — dim(cmd)"; the name and cmd should both appear as substrings. + if !containsStr(label, "dev") { + t.Errorf("label missing name: %q", label) + } + if !containsStr(label, "vite") { + t.Errorf("label missing cmd: %q", label) + } +} + +func TestScriptOptionLabel_TruncatesLongCmd(t *testing.T) { + long := "this-is-a-very-long-command-that-should-be-truncated-by-the-helper" + label := scriptOptionLabel("build", long) + if !containsStr(label, "…") { + t.Errorf("expected ellipsis in truncated label, got %q", label) + } +} + +func containsStr(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || indexOf(s, sub) >= 0) +} + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/internal/updater/updater.go b/internal/updater/updater.go index d9897db..33801db 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -49,6 +49,32 @@ type ReleaseFetcher interface { FetchReleases() ([]Release, error) } +// Downloader abstracts HTTP downloads of release archives for testing. +type Downloader interface { + Download(url string) (io.ReadCloser, error) +} + +// HTTPDownloader downloads archives via http.Get. +type HTTPDownloader struct { + Client *http.Client +} + +func (d *HTTPDownloader) Download(url string) (io.ReadCloser, error) { + client := d.Client + if client == nil { + client = http.DefaultClient + } + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("downloading release: %w", err) + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, fmt.Errorf("download failed with status %d", resp.StatusCode) + } + return resp.Body, nil +} + // GitHubFetcher fetches releases from the GitHub API. type GitHubFetcher struct { Client *http.Client @@ -151,22 +177,18 @@ func Plan(fetcher ReleaseFetcher, opts Options) (*Result, error) { } // Execute downloads and replaces the current binary. -func Execute(result *Result) error { +func Execute(downloader Downloader, result *Result) error { if isHomebrew(result.TargetPath) { return fmt.Errorf("spm appears to be installed via Homebrew — use `brew upgrade spm` instead") } - resp, err := http.Get(result.DownloadURL) + body, err := downloader.Download(result.DownloadURL) if err != nil { - return fmt.Errorf("downloading release: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("download failed with status %d", resp.StatusCode) + return err } + defer body.Close() - binary, err := extractBinary(resp.Body) + binary, err := extractBinary(body) if err != nil { return err } diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go index 68cd36d..65df88f 100644 --- a/internal/updater/updater_test.go +++ b/internal/updater/updater_test.go @@ -5,12 +5,28 @@ import ( "bytes" "compress/gzip" "fmt" + "io" + "net/http" + "net/http/httptest" "os" "path/filepath" "runtime" "testing" ) +// mockDownloader implements Downloader for testing. +type mockDownloader struct { + body []byte + err error +} + +func (m *mockDownloader) Download(url string) (io.ReadCloser, error) { + if m.err != nil { + return nil, m.err + } + return io.NopCloser(bytes.NewReader(m.body)), nil +} + // mockFetcher implements ReleaseFetcher for testing. type mockFetcher struct { releases []Release @@ -209,6 +225,145 @@ func TestExtractBinary_NotFound(t *testing.T) { } } +func TestExecute_HomebrewRefused(t *testing.T) { + result := &Result{ + TargetPath: "/opt/homebrew/Cellar/spm/0.4.0/bin/spm", + DownloadURL: "https://example.com/spm.tar.gz", + } + err := Execute(&mockDownloader{}, result) + if err == nil { + t.Fatal("expected error for Homebrew install") + } + if !contains(err.Error(), "Homebrew") { + t.Errorf("expected Homebrew error, got: %v", err) + } +} + +func TestExecute_DownloadError(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "spm") + if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + result := &Result{TargetPath: target, DownloadURL: "https://example.com/spm.tar.gz"} + + err := Execute(&mockDownloader{err: fmt.Errorf("connection refused")}, result) + if err == nil { + t.Fatal("expected error for download failure") + } + if !contains(err.Error(), "connection refused") { + t.Errorf("expected download error to propagate, got: %v", err) + } +} + +func TestExecute_InvalidArchive(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "spm") + if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + result := &Result{TargetPath: target, DownloadURL: "https://example.com/spm.tar.gz"} + + err := Execute(&mockDownloader{body: []byte("not a tar.gz")}, result) + if err == nil { + t.Fatal("expected error for invalid archive") + } +} + +func TestExecute_BinaryNotInArchive(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "spm") + if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + archive := createTarGz(t, "other-file", []byte("data")) + result := &Result{TargetPath: target, DownloadURL: "https://example.com/spm.tar.gz"} + + err := Execute(&mockDownloader{body: archive}, result) + if err == nil { + t.Fatal("expected error when binary not in archive") + } +} + +func TestExecute_SuccessfullyReplacesBinary(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "spm") + if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + newContent := []byte("new-spm-binary") + archive := createTarGz(t, "spm", newContent) + result := &Result{TargetPath: target, DownloadURL: "https://example.com/spm.tar.gz"} + + if err := Execute(&mockDownloader{body: archive}, result); err != nil { + t.Fatalf("Execute failed: %v", err) + } + + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, newContent) { + t.Errorf("binary not replaced: got %q, want %q", got, newContent) + } +} + +func TestExecute_MissingTarget(t *testing.T) { + archive := createTarGz(t, "spm", []byte("new")) + result := &Result{ + TargetPath: "/nonexistent/path/spm", + DownloadURL: "https://example.com/spm.tar.gz", + } + err := Execute(&mockDownloader{body: archive}, result) + if err == nil { + t.Fatal("expected error when target doesn't exist") + } +} + +func TestHTTPDownloader_Success(t *testing.T) { + want := []byte("archive-bytes") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(want) + })) + defer srv.Close() + + d := &HTTPDownloader{} + body, err := d.Download(srv.URL) + if err != nil { + t.Fatal(err) + } + defer body.Close() + got, _ := io.ReadAll(body) + if !bytes.Equal(got, want) { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestHTTPDownloader_Non200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + })) + defer srv.Close() + + d := &HTTPDownloader{} + _, err := d.Download(srv.URL) + if err == nil { + t.Fatal("expected error for 404") + } + if !contains(err.Error(), "404") { + t.Errorf("expected status in error, got: %v", err) + } +} + +func TestHTTPDownloader_NetworkError(t *testing.T) { + d := &HTTPDownloader{} + // Use an invalid URL to trigger a transport error. + _, err := d.Download("http://127.0.0.1:1/definitely-not-listening") + if err == nil { + t.Fatal("expected network error") + } +} + func TestExecute_ReplacesFile(t *testing.T) { dir := t.TempDir()