package version import ( "encoding/json" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "sync/atomic" "testing" "time" "uuid" "gitgud.io/mike/mpv-manager/pkg/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func authenticateTestUpdateSelection(check *VersionCheckResult) { check.authenticated = &authenticatedUpdateSelection{ fingerprint: fingerprintUpdateSelection(check), manifestHash: "test-manifest", channel: "stable", publishedAt: "2026-08-31T12:00:00Z", manifestKeyID: check.ManifestKeyID, } } func prepareTransactionFixture(t *testing.T, withSecondary bool) (*PreparedSelfUpdate, string, string, []byte, *atomic.Int32) { t.Helper() if runtime.GOOS == "windows" { t.Skip("shell-script transaction fixtures are not executable on Windows") } home := t.TempDir() t.Setenv("HOME", home) t.Setenv("XDG_CONFIG_HOME", home) require.NoError(t, config.SetManagerBinPath("")) t.Cleanup(func() { _ = config.SetManagerBinPath("") }) directory := t.TempDir() primary := filepath.Join(directory, "mpv-manager") oldContent := []byte("#!/bin/sh\nprintf '%s\\n' '{\"product\":\"mpv-manager\",\"component\":\"manager-portable\",\"version\":\"1.2.0\",\"goos\":\"" + runtime.GOOS + "\",\"goarch\":\"" + runtime.GOARCH + "\"}'\n") require.NoError(t, os.WriteFile(primary, oldContent, 0755)) // Production recovery receives the canonical executable path. macOS /var // temp paths alias /private/var and must follow the same contract here. primary, err := canonicalExecutablePath(primary) require.NoError(t, err) directory = filepath.Dir(primary) secondary := "" if withSecondary { secondary = filepath.Join(directory, "bin", "mpv-manager") require.NoError(t, os.MkdirAll(filepath.Dir(secondary), 0755)) require.NoError(t, os.WriteFile(secondary, oldContent, 0755)) require.NoError(t, config.SetManagerBinPath(secondary)) } newContent := scriptBinary(0) requests := &atomic.Int32{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { requests.Add(1) w.Header().Set("Content-Length", strconv.Itoa(len(newContent))) _, _ = w.Write(newContent) })) t.Cleanup(server.Close) check := &VersionCheckResult{ UpdateAvailable: true, LatestVersion: "9.9.9", URL: server.URL, BLAKE3: blake3HashOf(newContent), AssetSize: int64(len(newContent)), ManifestKeyID: "test-release-key", } authenticateTestUpdateSelection(check) prepared, err := PrepareSelfUpdateFromCheck(primary, check, nil) require.NoError(t, err) t.Cleanup(func() { _ = prepared.abort() }) return prepared, primary, secondary, oldContent, requests } func TestUpdateLockRejectsConcurrentAttempt(t *testing.T) { path := filepath.Join(t.TempDir(), "manager.update.lock") first, err := tryAcquireUpdateLock(path) require.NoError(t, err) t.Cleanup(func() { _ = first.release() }) _, err = tryAcquireUpdateLock(path) require.Error(t, err) assert.ErrorIs(t, err, ErrUpdateInProgress) } func TestUpdatePayloadUsesNativeExecutableName(t *testing.T) { want := "manager-payload" if runtime.GOOS == "windows" { want += ".exe" } assert.Equal(t, want, updatePayloadFileName()) } func TestPrepareSelfUpdateStagesWithoutReplacing(t *testing.T) { prepared, primary, _, oldContent, requests := prepareTransactionFixture(t, false) assert.Equal(t, int32(1), requests.Load(), "the selected artifact must be downloaded once") current, err := os.ReadFile(primary) require.NoError(t, err) assert.Equal(t, oldContent, current, "preparation must not replace the running binary") journal, err := loadUpdateJournal(prepared.JournalPath) require.NoError(t, err) assert.Equal(t, UpdateTransactionPrepared, journal.State) require.Len(t, journal.Targets, 1) assert.FileExists(t, journal.Targets[0].StagedPath) assert.NoFileExists(t, journal.Targets[0].BackupPath) _, err = tryAcquireUpdateLock(journal.LockPath) assert.ErrorIs(t, err, ErrUpdateInProgress) } func TestPrepareSelfUpdateStagesPrimaryAndSecondaryFromOneDownload(t *testing.T) { prepared, _, secondary, _, requests := prepareTransactionFixture(t, true) assert.NotEmpty(t, secondary) assert.Equal(t, int32(1), requests.Load()) journal, err := loadUpdateJournal(prepared.JournalPath) require.NoError(t, err) require.Len(t, journal.Targets, 2) assert.Equal(t, updateTransactionLockPath(), journal.LockPath) for _, target := range journal.Targets { assert.FileExists(t, target.StagedPath) assert.Empty(t, target.Outcome) } } func TestPrepareWritesInitializingJournalBeforeDownload(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("shell-script transaction fixtures are not executable on Windows") } home := t.TempDir() t.Setenv("HOME", home) t.Setenv("XDG_CONFIG_HOME", home) require.NoError(t, config.SetManagerBinPath("")) directory := t.TempDir() primary := filepath.Join(directory, "mpv-manager") oldContent := []byte("#!/bin/sh\nprintf '%s\\n' '{\"product\":\"mpv-manager\",\"component\":\"manager-portable\",\"version\":\"1.2.0\",\"goos\":\"" + runtime.GOOS + "\",\"goarch\":\"" + runtime.GOARCH + "\"}'\n") require.NoError(t, os.WriteFile(primary, oldContent, 0o755)) primary, err := canonicalExecutablePath(primary) require.NoError(t, err) directory = filepath.Dir(primary) newContent := scriptBinary(0) observed := make(chan UpdateTransactionState, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { matches, globErr := filepath.Glob(filepath.Join(directory, updateTransactionPrefix+"*", updateJournalName)) if globErr != nil || len(matches) != 1 { observed <- "missing" } else if journal, loadErr := loadUpdateJournal(matches[0]); loadErr != nil { t.Logf("inspect initializing journal: %v", loadErr) observed <- "invalid" } else { observed <- journal.State } _, _ = w.Write(newContent) })) t.Cleanup(server.Close) check := &VersionCheckResult{ UpdateAvailable: true, LatestVersion: "9.9.9", URL: server.URL, BLAKE3: blake3HashOf(newContent), AssetSize: int64(len(newContent)), ManifestKeyID: "test-release-key", } authenticateTestUpdateSelection(check) prepared, err := PrepareSelfUpdateFromCheck(primary, check, nil) require.NoError(t, err) t.Cleanup(func() { _ = prepared.Abort() }) assert.Equal(t, UpdateTransactionInitializing, <-observed) } func TestRunUpdateHelperCommitsAllTargetsWithoutRelaunch(t *testing.T) { prepared, primary, secondary, _, _ := prepareTransactionFixture(t, true) journal, err := loadUpdateJournal(prepared.JournalPath) require.NoError(t, err) journal.State = UpdateTransactionHelperStarted journal.ParentPID = 1 << 30 journal.HelperPID = 0 journal.RelaunchMode = "none" require.NoError(t, writeUpdateJournal(prepared.JournalPath, journal)) require.NoError(t, prepared.lock.release()) prepared.lock = nil require.NoError(t, runUpdateHelper(prepared.JournalPath, false)) committed, err := loadUpdateJournal(prepared.JournalPath) require.NoError(t, err) assert.Equal(t, UpdateTransactionCommitted, committed.State) assert.True(t, committed.Finalized) for _, target := range committed.Targets { assert.Equal(t, "committed", target.Outcome) assert.NoFileExists(t, target.BackupPath) assert.NoFileExists(t, target.StagedPath) } for _, path := range []string{primary, secondary} { content, readErr := os.ReadFile(path) require.NoError(t, readErr) assert.Equal(t, scriptBinary(0), content) } } func TestRecoveryReplaysRollbackAfterBackupWasConsumed(t *testing.T) { for _, multiTarget := range []bool{false, true} { t.Run(strconv.FormatBool(multiTarget), func(t *testing.T) { prepared, primary, secondary, original, _ := prepareTransactionFixture(t, multiTarget) journal, err := loadUpdateJournal(prepared.JournalPath) require.NoError(t, err) require.NoError(t, applyUpdateTargets(prepared.JournalPath, journal)) journal.State = UpdateTransactionApplying require.NoError(t, writeUpdateJournal(prepared.JournalPath, journal)) // Reproduce a crash after a reverse-order restore consumed its backup, // before the changed Applied bit was published to the signed journal. last := journal.Targets[len(journal.Targets)-1] require.NoError(t, restoreUpdateFile(last.Path, last.BackupPath)) require.NoError(t, prepared.lock.release()) prepared.lock = nil require.NoError(t, recoverTransactionsLocked(primary)) require.NoError(t, recoverTransactionsLocked(primary), "recovery must remain idempotent") for _, path := range []string{primary, secondary} { if path == "" { continue } content, err := os.ReadFile(path) require.NoError(t, err) require.Equal(t, original, content) } }) } } func TestRollbackMissingBackupRejectsUnauthenticatedLiveBytesWithoutExecution(t *testing.T) { prepared, primary, _, _, _ := prepareTransactionFixture(t, false) journal, err := loadUpdateJournal(prepared.JournalPath) require.NoError(t, err) journal.Targets[0].Applied = true marker := filepath.Join(filepath.Dir(primary), "executed") require.NoError(t, os.WriteFile(primary, []byte("#!/bin/sh\ntouch '"+marker+"'\n"), 0o755)) require.Error(t, rollbackUpdateTargets(journal)) require.NoFileExists(t, marker) require.Equal(t, "rollback_failed", journal.Targets[0].Outcome) } func TestFinalizedRecoveryRetiresObsoleteExecutableExpectations(t *testing.T) { prepared, primary, _, original, _ := prepareTransactionFixture(t, false) journal, err := loadUpdateJournal(prepared.JournalPath) require.NoError(t, err) journal.State = UpdateTransactionHelperStarted journal.ParentPID = 1 << 30 journal.HelperPID = 0 journal.RelaunchMode = "none" require.NoError(t, writeUpdateJournal(prepared.JournalPath, journal)) require.NoError(t, prepared.lock.release()) prepared.lock = nil require.NoError(t, runUpdateHelper(prepared.JournalPath, false)) // A later, deliberate replacement of the already finalized manager is // outside the retired transaction's rollback authority. require.NoError(t, os.WriteFile(primary, original, 0o755)) require.NoError(t, recoverTransactionsLocked(primary)) content, err := os.ReadFile(primary) require.NoError(t, err) require.Equal(t, original, content) require.NoDirExists(t, filepath.Dir(prepared.JournalPath)) } func TestCommittedOutcomeFailureRetainsRecoveryEvidence(t *testing.T) { prepared, primary, _, _, _ := prepareTransactionFixture(t, false) journal, err := loadUpdateJournal(prepared.JournalPath) require.NoError(t, err) journal.State = UpdateTransactionHelperStarted journal.ParentPID = 1 << 30 journal.HelperPID = 0 journal.RelaunchMode = "none" require.NoError(t, writeUpdateJournal(prepared.JournalPath, journal)) require.NoError(t, os.Mkdir(selfUpdateOutcomePath(), 0o700)) require.NoError(t, prepared.lock.release()) prepared.lock = nil require.NoError(t, runUpdateHelper(prepared.JournalPath, false)) committed, err := loadUpdateJournal(prepared.JournalPath) require.NoError(t, err) require.Equal(t, UpdateTransactionCommitted, committed.State) require.False(t, committed.Finalized) require.FileExists(t, committed.Targets[0].BackupPath) require.ErrorContains(t, recoverTransactionsLocked(primary), "finalize transaction") require.FileExists(t, committed.Targets[0].BackupPath) require.NoError(t, os.Remove(selfUpdateOutcomePath())) require.NoError(t, recoverTransactionsLocked(primary)) outcome, err := ReadSelfUpdateOutcome() require.NoError(t, err) require.NotNil(t, outcome) require.Equal(t, UpdateTransactionCommitted, outcome.State) require.NoDirExists(t, filepath.Dir(prepared.JournalPath)) } func TestRunUpdateHelperRollsBackPartialMultiTargetFailure(t *testing.T) { prepared, primary, secondary, oldContent, _ := prepareTransactionFixture(t, true) journal, err := loadUpdateJournal(prepared.JournalPath) require.NoError(t, err) require.Len(t, journal.Targets, 2) require.NoError(t, os.Remove(journal.Targets[1].StagedPath)) journal.State = UpdateTransactionHelperStarted journal.ParentPID = 1 << 30 journal.HelperPID = 0 journal.RelaunchMode = "none" require.NoError(t, writeUpdateJournal(prepared.JournalPath, journal)) require.NoError(t, prepared.lock.release()) prepared.lock = nil err = runUpdateHelper(prepared.JournalPath, false) require.Error(t, err) assert.Contains(t, err.Error(), "previous version was restored") rolledBack, loadErr := loadUpdateJournal(prepared.JournalPath) require.NoError(t, loadErr) assert.Equal(t, UpdateTransactionRolledBack, rolledBack.State) assert.Equal(t, "rolled_back", rolledBack.Targets[0].Outcome) assert.Equal(t, "failed", rolledBack.Targets[1].Outcome) assert.Contains(t, rolledBack.Targets[1].Error, "unavailable") for _, path := range []string{primary, secondary} { content, readErr := os.ReadFile(path) require.NoError(t, readErr) assert.Equal(t, oldContent, content) } } func TestRecoverSelfUpdateTransactionRestoresInterruptedApply(t *testing.T) { prepared, primary, _, oldContent, _ := prepareTransactionFixture(t, false) journal, err := loadUpdateJournal(prepared.JournalPath) require.NoError(t, err) require.NoError(t, os.Rename(primary, journal.Targets[0].BackupPath)) require.NoError(t, os.Rename(journal.Targets[0].StagedPath, primary)) journal.Targets[0].Applied = true journal.State = UpdateTransactionAwaitingHealth journal.ParentPID = 1 << 30 journal.HelperPID = 0 require.NoError(t, writeUpdateJournal(prepared.JournalPath, journal)) require.NoError(t, prepared.lock.release()) prepared.lock = nil RecoverSelfUpdateTransactions(primary) content, err := os.ReadFile(primary) require.NoError(t, err) assert.Equal(t, oldContent, content) assert.NoDirExists(t, filepath.Dir(prepared.JournalPath)) } func TestRecoveryRemovesPreJournalOrphanAndContinues(t *testing.T) { prepared, primary, _, _, _ := prepareTransactionFixture(t, false) journal, err := loadUpdateJournal(prepared.JournalPath) require.NoError(t, err) orphanID := "00000000-0000-7000-8000-000000000000" orphanDirectory := updateTransactionDir(primary, orphanID) require.NoError(t, os.Mkdir(orphanDirectory, 0o700)) require.NoError(t, os.WriteFile(filepath.Join(orphanDirectory, updatePayloadFileName()), []byte("partial"), 0o600)) require.NoError(t, os.WriteFile(stagedTargetPath(primary, orphanID), []byte("partial"), 0o600)) require.NoError(t, prepared.lock.release()) prepared.lock = nil require.NoError(t, recoverTransactionsLocked(primary)) assert.NoDirExists(t, orphanDirectory) assert.NoFileExists(t, stagedTargetPath(primary, orphanID)) assert.NoDirExists(t, filepath.Dir(prepared.JournalPath), "recovery must continue to the valid stale journal") assert.Equal(t, UpdateTransactionPrepared, journal.State) } func TestRecoveryRevalidatesCommittedTargetsBeforeCleanup(t *testing.T) { prepared, primary, _, oldContent, _ := prepareTransactionFixture(t, false) journal, err := loadUpdateJournal(prepared.JournalPath) require.NoError(t, err) target := &journal.Targets[0] require.NoError(t, replaceUpdateFile(target.Path, target.StagedPath, target.BackupPath)) target.Applied = true target.Outcome = "committed" journal.State = UpdateTransactionCommitted require.NoError(t, writeUpdateJournal(prepared.JournalPath, journal)) require.NoError(t, os.WriteFile(primary, []byte("tampered committed target"), 0o755)) require.NoError(t, prepared.lock.release()) prepared.lock = nil require.NoError(t, recoverTransactionsLocked(primary)) content, err := os.ReadFile(primary) require.NoError(t, err) assert.Equal(t, oldContent, content) assert.NoDirExists(t, filepath.Dir(prepared.JournalPath)) } func TestRollbackRejectsCorruptedBackupEvidence(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("shell-script transaction fixtures are not executable on Windows") } prepared, primary, _, _, _ := prepareTransactionFixture(t, false) journal, err := loadUpdateJournal(prepared.JournalPath) require.NoError(t, err) target := &journal.Targets[0] require.NoError(t, replaceUpdateFile(target.Path, target.StagedPath, target.BackupPath)) target.Applied = true target.Outcome = "applied" require.NoError(t, os.WriteFile(target.BackupPath, []byte("tampered backup"), 0o755)) err = rollbackUpdateTargets(journal) require.Error(t, err) assert.Contains(t, err.Error(), "backup integrity") assert.Equal(t, "rollback_failed", target.Outcome) content, readErr := os.ReadFile(primary) require.NoError(t, readErr) assert.Equal(t, scriptBinary(0), content, "a rejected backup must not remove the remaining executable") } func TestPreparedUpdateRejectsInvalidRelaunchMode(t *testing.T) { prepared, _, _, _, _ := prepareTransactionFixture(t, false) err := prepared.LaunchUpdateHelper("desktop") require.Error(t, err) assert.Contains(t, err.Error(), "unsupported") } func TestPreparedUpdatePersistsOriginJobID(t *testing.T) { prepared, _, _, _, _ := prepareTransactionFixture(t, false) jobID := uuid.New().String() require.NoError(t, prepared.SetOriginJobID(jobID)) journal, err := loadUpdateJournal(prepared.JournalPath) require.NoError(t, err) assert.Equal(t, jobID, journal.OriginJobID) require.Error(t, prepared.SetOriginJobID("not-a-job-id")) } func TestPrepareSelfUpdateRequiresAuthenticatedSizeAndDigest(t *testing.T) { check := &VersionCheckResult{ UpdateAvailable: true, LatestVersion: "9.9.9", URL: "https://example.invalid/manager", BLAKE3: "blake3:" + strings.Repeat("a", 64), ManifestKeyID: "test-release-key", } authenticateTestUpdateSelection(check) _, err := PrepareSelfUpdateFromCheck(filepath.Join(t.TempDir(), "mpv-manager"), check, nil) require.Error(t, err) assert.Contains(t, err.Error(), "authenticated size") check.AssetSize = 10 check.BLAKE3 = "blake3:not-a-digest" authenticateTestUpdateSelection(check) _, err = PrepareSelfUpdateFromCheck(filepath.Join(t.TempDir(), "mpv-manager"), check, nil) require.Error(t, err) assert.Contains(t, err.Error(), "invalid BLAKE3") } func TestPrepareSelfUpdateRejectsUnverifiedOrMutatedSelection(t *testing.T) { check := &VersionCheckResult{ UpdateAvailable: true, LatestVersion: "9.9.9", URL: "https://example.invalid/manager", BLAKE3: "blake3:" + strings.Repeat("a", 64), AssetSize: 10, ManifestKeyID: "test-release-key", } _, err := PrepareSelfUpdateFromCheck(filepath.Join(t.TempDir(), "mpv-manager"), check, nil) require.Error(t, err) assert.Contains(t, err.Error(), "verified release manifest") authenticateTestUpdateSelection(check) check.URL = "https://attacker.invalid/manager" _, err = PrepareSelfUpdateFromCheck(filepath.Join(t.TempDir(), "mpv-manager"), check, nil) require.Error(t, err) assert.Contains(t, err.Error(), "modified after verification") } func TestRunUpdateHelperRejectsDirectInvocation(t *testing.T) { prepared, _, _, _, _ := prepareTransactionFixture(t, false) journal, err := loadUpdateJournal(prepared.JournalPath) require.NoError(t, err) journal.State = UpdateTransactionHelperStarted journal.ParentPID = 1 << 30 require.NoError(t, writeUpdateJournal(prepared.JournalPath, journal)) require.NoError(t, prepared.lock.release()) prepared.lock = nil err = RunUpdateHelper(prepared.JournalPath) require.Error(t, err) assert.Contains(t, err.Error(), "prepared transaction directory") } func TestApplyChecksDigestBeforeExecutingCandidate(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("uses a POSIX shell candidate") } directory := t.TempDir() marker := filepath.Join(directory, "candidate-executed") target := filepath.Join(directory, "manager") staged := filepath.Join(directory, "manager.new") backup := filepath.Join(directory, "manager.backup") require.NoError(t, os.WriteFile(target, []byte("known-good"), 0o755)) candidate := []byte("#!/bin/sh\ntouch '" + marker + "'\nprintf '%s\\n' '{\"product\":\"mpv-manager\",\"component\":\"manager-portable\",\"version\":\"9.9.9\",\"goos\":\"" + runtime.GOOS + "\",\"goarch\":\"" + runtime.GOARCH + "\"}'\n") require.NoError(t, os.WriteFile(staged, candidate, 0o755)) journal := &UpdateJournal{ ExpectedSize: int64(len(candidate)), ExpectedHash: "blake3:" + strings.Repeat("0", 64), Expected: BinaryIdentity{ Product: ProductID, Component: ComponentID, Version: "9.9.9", GOOS: runtime.GOOS, GOARCH: runtime.GOARCH, }, Targets: []UpdateTarget{{Role: "primary", Path: target, StagedPath: staged, BackupPath: backup}}, } err := applyUpdateTargets(filepath.Join(directory, "unused-journal.json"), journal) require.Error(t, err) assert.Contains(t, err.Error(), "integrity check failed") assert.NoFileExists(t, marker, "candidate ran before its helper-side digest check") assert.NoFileExists(t, backup) content, readErr := os.ReadFile(target) require.NoError(t, readErr) assert.Equal(t, "known-good", string(content)) } func TestWaitForProcessExitAlreadyGone(t *testing.T) { assert.NoError(t, waitForProcessExit(1<<30, "", 0)) } func TestProcessIdentityTokenRejectsPIDReuseEvidence(t *testing.T) { token, err := processIdentityToken(os.Getpid()) require.NoError(t, err) assert.True(t, processMatches(os.Getpid(), token)) assert.False(t, processMatches(os.Getpid(), token+"-different")) } func TestWaitForHealthAcknowledgementRequiresStableChild(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("uses a POSIX shell sleep fixture") } directory := t.TempDir() healthPath := filepath.Join(directory, "health.json") expected := BinaryIdentity{ Product: ProductID, Component: ComponentID, Version: "9.9.9", GOOS: runtime.GOOS, GOARCH: runtime.GOARCH, } journal := &UpdateJournal{ID: "test-health", HealthPath: healthPath, Expected: expected} acknowledgement := updateHealthAcknowledgement{ TransactionID: journal.ID, Identity: expected, AcknowledgedAt: time.Now().UTC(), } data, err := json.Marshal(acknowledgement) require.NoError(t, err) require.NoError(t, os.WriteFile(healthPath, data, 0600)) cmd := exec.Command("sh", "-c", "sleep 3") require.NoError(t, cmd.Start()) t.Cleanup(func() { _ = cmd.Process.Kill() }) startedAt := time.Now() require.NoError(t, waitForHealthAcknowledgement(journal, cmd, 2*time.Second)) assert.GreaterOrEqual(t, time.Since(startedAt), updateHealthStabilize) } func TestWaitForHealthAcknowledgementRejectsImmediateExit(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("uses a POSIX shell fixture") } directory := t.TempDir() expected := BinaryIdentity{ Product: ProductID, Component: ComponentID, Version: "9.9.9", GOOS: runtime.GOOS, GOARCH: runtime.GOARCH, } journal := &UpdateJournal{ID: "test-health", HealthPath: filepath.Join(directory, "health.json"), Expected: expected} data, err := json.Marshal(updateHealthAcknowledgement{TransactionID: journal.ID, Identity: expected}) require.NoError(t, err) require.NoError(t, os.WriteFile(journal.HealthPath, data, 0600)) cmd := exec.Command("sh", "-c", "exit 0") require.NoError(t, cmd.Start()) err = waitForHealthAcknowledgement(journal, cmd, time.Second) require.Error(t, err) assert.Contains(t, err.Error(), "exited") }