package cli import ( "archive/tar" "context" "errors" "fmt" "strings" "testing" "tsne.dev/hopper/internal/bunny" "tsne.dev/hopper/internal/log" ) // runPush drives the push flow against a fake storage API for the given archive, // using the default concurrency. func runPush(source string, api storageAPI) error { return runPushN(source, api, 8) } func runPushN(source string, api storageAPI, concurrency uint) error { cmd := &pushCommand{concurrency: concurrency} return cmd.push(context.Background(), api, source) } func TestPushAppliesPrefix(t *testing.T) { arc := makeArchive(t, []tarEntry{ {name: "index.html", body: ""}, {name: "assets/app.abc.js", body: "console.log(1)"}, {name: "assets/skip.js", body: "same"}, }) fc := newFakeAPI() // The remote walk starts under the prefix and returns already-prefixed keys. fc.lists["foo"] = []bunny.Object{{Path: "foo/assets", IsDirectory: true}} fc.lists["foo/assets"] = []bunny.Object{ {Path: "foo/assets/skip.js", Size: int(len("same"))}, } out, _ := captureLogs(t) cmd := &pushCommand{concurrency: 8, prefix: "foo"} if err := cmd.push(context.Background(), fc, arc); err != nil { t.Fatalf("push: %v", err) } got := fc.paths() want := []string{"foo/assets/app.abc.js", "foo/index.html"} if strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("paths = %v, want %v", got, want) } if !strings.Contains(out.String(), "skipping foo/assets/skip.js (exists)") { t.Fatalf("skip-check must compare prefixed key: %q", out.String()) } } func TestPushSelectsRootAndStrips(t *testing.T) { arc := makeArchive(t, []tarEntry{ {name: "README.md", body: "ignored"}, {name: "app/index.html", body: ""}, {name: "app/assets/x.js", body: "x"}, {name: "other/y.js", body: "ignored"}, }) fc := newFakeAPI() captureLogs(t) cmd := &pushCommand{concurrency: 8, root: "app"} if err := cmd.push(context.Background(), fc, arc); err != nil { t.Fatalf("push: %v", err) } got := fc.paths() want := []string{"assets/x.js", "index.html"} if strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("paths = %v, want %v", got, want) } } func TestPushSelectsNestedRoot(t *testing.T) { arc := makeArchive(t, []tarEntry{ {name: "dist/public/index.html", body: ""}, {name: "dist/public/assets/x.js", body: "x"}, {name: "dist/private/secret", body: "ignored"}, }) fc := newFakeAPI() captureLogs(t) cmd := &pushCommand{concurrency: 8, root: "dist/public"} if err := cmd.push(context.Background(), fc, arc); err != nil { t.Fatalf("push: %v", err) } got := fc.paths() want := []string{"assets/x.js", "index.html"} if strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("paths = %v, want %v", got, want) } } func TestPushRootWithPrefix(t *testing.T) { arc := makeArchive(t, []tarEntry{ {name: "app/assets/x.js", body: "x"}, }) fc := newFakeAPI() captureLogs(t) cmd := &pushCommand{concurrency: 8, root: "app", prefix: "foo"} if err := cmd.push(context.Background(), fc, arc); err != nil { t.Fatalf("push: %v", err) } got := fc.paths() want := []string{"foo/assets/x.js"} if strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("paths = %v, want %v", got, want) } } func TestPushRootMatchesNothing(t *testing.T) { arc := makeArchive(t, []tarEntry{ {name: "app/index.html", body: ""}, }) fc := newFakeAPI() out, _ := captureLogs(t) cmd := &pushCommand{concurrency: 8, root: "nope"} if err := cmd.push(context.Background(), fc, arc); err != nil { t.Fatalf("push: %v", err) } if got := fc.paths(); len(got) != 0 { t.Fatalf("paths = %v, want none", got) } if !strings.Contains(out.String(), "uploaded: 0") { t.Fatalf("summary missing: %q", out.String()) } } func TestPushUploadsRegularFiles(t *testing.T) { arc := makeArchive(t, []tarEntry{ {name: "index.html", body: ""}, {name: "assets/app.abc.js", body: "console.log(1)"}, {name: "dir/", typeflag: tar.TypeDir}, }) fc := newFakeAPI() out, _ := captureLogs(t) if err := runPush(arc, fc); err != nil { t.Fatalf("push: %v", err) } got := fc.paths() want := []string{"assets/app.abc.js", "index.html"} if strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("paths = %v, want %v", got, want) } if string(fc.puts["index.html"]) != "" { t.Fatalf("bad body for index.html: %q", fc.puts["index.html"]) } if string(fc.puts["assets/app.abc.js"]) != "console.log(1)" { t.Fatalf("bad body for app.abc.js: %q", fc.puts["assets/app.abc.js"]) } if !strings.Contains(out.String(), "uploaded: 2") { t.Fatalf("summary missing: %q", out.String()) } } func TestPushUploadsHTMLAfterAllAssets(t *testing.T) { arc := makeArchive(t, []tarEntry{ {name: "index.html", body: ""}, {name: "a.js", body: "x"}, {name: "nested/page.HTML", body: "

"}, // case-insensitive .html {name: "b.css", body: "y"}, }) fc := newFakeAPI() captureLogs(t) if err := runPush(arc, fc); err != nil { t.Fatalf("push: %v", err) } // Every HTML Put must happen after the last asset Put. lastAsset := -1 firstHTML := len(fc.order) for i, p := range fc.order { if strings.HasSuffix(strings.ToLower(p), ".html") { if i < firstHTML { firstHTML = i } } else if i > lastAsset { lastAsset = i } } if firstHTML <= lastAsset { t.Fatalf("HTML uploaded before an asset; order = %v", fc.order) } } func TestPushDoesNotUploadHTMLWhenAssetFails(t *testing.T) { arc := makeArchive(t, []tarEntry{ {name: "index.html", body: ""}, {name: "a.js", body: "x"}, }) fc := newFakeAPI() fc.failOn = "a.js" captureLogs(t) if err := runPush(arc, fc); err == nil { t.Fatal("expected error") } for _, p := range fc.order { if strings.HasSuffix(strings.ToLower(p), ".html") { t.Fatalf("HTML must not be uploaded when an asset fails; order = %v", fc.order) } } } func TestPushSkipsSymlinkWithWarning(t *testing.T) { arc := makeArchive(t, []tarEntry{ {name: "a.js", body: "x"}, {name: "link", typeflag: tar.TypeSymlink, linkname: "a.js"}, }) fc := newFakeAPI() _, errb := captureLogs(t) if err := runPush(arc, fc); err != nil { t.Fatalf("push: %v", err) } if got := fc.paths(); len(got) != 1 || got[0] != "a.js" { t.Fatalf("paths = %v", got) } if !strings.Contains(errb.String(), "Warning") { t.Fatalf("expected warning on stderr, got %q", errb.String()) } } func TestPushFailsOnUploadError(t *testing.T) { arc := makeArchive(t, []tarEntry{ {name: "a.js", body: "x"}, {name: "b.js", body: "y"}, }) fc := newFakeAPI() fc.failOn = "a.js" captureLogs(t) if err := runPush(arc, fc); err == nil { t.Fatal("expected error") } } func TestPushSkipsUnchanged(t *testing.T) { arc := makeArchive(t, []tarEntry{ {name: "index.html", body: ""}, {name: "assets/app.abc.js", body: "console.log(1)"}, }) fc := newFakeAPI() fc.lists[""] = []bunny.Object{ {Path: "index.html", Size: int(len(""))}, {Path: "assets", IsDirectory: true}, } fc.lists["assets"] = []bunny.Object{ {Path: "assets/app.abc.js", Size: int(len("console.log(1)"))}, } out, _ := captureLogs(t) if err := runPush(arc, fc); err != nil { t.Fatalf("push: %v", err) } // The asset is skipped (matching size); the *.html is the commit point and is // always uploaded even when an identically-sized copy exists remotely. if got := fc.paths(); len(got) != 1 || got[0] != "index.html" { t.Fatalf("expected only index.html uploaded, got %v", got) } s := out.String() if !strings.Contains(s, "skipping assets/app.abc.js (exists)") { t.Fatalf("missing skip line: %q", s) } if strings.Contains(s, "skipping index.html") { t.Fatalf("index.html must never be skipped: %q", s) } if !strings.Contains(s, "uploaded: 1") || !strings.Contains(s, "skipped: 1") { t.Fatalf("summary missing: %q", s) } } func TestPushReuploadsOnSizeMismatch(t *testing.T) { arc := makeArchive(t, []tarEntry{ {name: "a.js", body: "hello"}, // 5 bytes {name: "b.js", body: "world"}, // present, matching size -> skip }) fc := newFakeAPI() fc.lists[""] = []bunny.Object{ {Path: "a.js", Size: 1}, // size mismatch -> upload {Path: "b.js", Size: int(len("world"))}, } captureLogs(t) if err := runPush(arc, fc); err != nil { t.Fatalf("push: %v", err) } if got := fc.paths(); len(got) != 1 || got[0] != "a.js" { t.Fatalf("paths = %v, want [a.js]", got) } } func TestPushErrorsOnDirectoryFileCollision(t *testing.T) { arc := makeArchive(t, []tarEntry{ {name: "data", body: ""}, // 0-byte asset colliding with a remote dir }) fc := newFakeAPI() // Remote has a directory named "data"; a file at the same path is a hard // conflict (and a 0-size dir must never silently shadow a 0-byte file). fc.lists[""] = []bunny.Object{{Path: "data", IsDirectory: true}} captureLogs(t) if err := runPush(arc, fc); err == nil { t.Fatal("expected error on directory/file collision") } if got := fc.paths(); len(got) != 0 { t.Fatalf("expected no uploads, got %v", got) } } func TestPushAbortsOnListFailure(t *testing.T) { arc := makeArchive(t, []tarEntry{{name: "a.js", body: "x"}}) fc := newFakeAPI() fc.listErr = errors.New("boom") captureLogs(t) if err := runPush(arc, fc); err == nil { t.Fatal("expected error") } if got := fc.paths(); len(got) != 0 { t.Fatalf("expected no uploads before abort, got %v", got) } } func TestPushRunRejectsBadSourceArgs(t *testing.T) { captureLogs(t) cmd := newPushCommand(&mainCommand{}) var ue usageError if err := cmd.Run(context.Background(), nil); !errors.As(err, &ue) { t.Fatalf("missing source: expected usageError, got %v", err) } if err := cmd.Run(context.Background(), []string{"a.tgz", "b.tgz"}); !errors.As(err, &ue) { t.Fatalf("multiple sources: expected usageError, got %v", err) } } func TestPushRejectsNegativeConcurrencyAtParse(t *testing.T) { captureLogs(t) // A negative value is not a valid uint, so pflag rejects it at parse time. cmd := newPushCommand(&mainCommand{}) if err := cmd.flags.Parse([]string{"--concurrency", "-1", "a.tgz"}); err == nil { t.Fatal("--concurrency -1: expected a parse error, got nil") } } func TestPushRejectsEscapingPrefix(t *testing.T) { captureLogs(t) cmd := newPushCommand(&mainCommand{}) var ue usageError if err := cmd.flags.Parse([]string{"--prefix", "../x", "a.tgz"}); !errors.As(err, &ue) { t.Fatalf("--prefix ../x: expected usageError, got %v", err) } } func TestPushBoundsInFlightUploads(t *testing.T) { arc := makeArchive(t, []tarEntry{ {name: "a.js", body: "1"}, {name: "b.js", body: "2"}, {name: "c.js", body: "3"}, {name: "d.js", body: "4"}, {name: "e.js", body: "5"}, }) const limit = 2 gate := &gatingAPI{fakeAPI: newFakeAPI(), release: make(chan struct{})} captureLogs(t) done := make(chan error, 1) go func() { done <- runPushN(arc, gate, limit) }() // The reader blocks on the semaphore once the pool is saturated, so no more // uploads can begin until one drains; release them so the run finishes. for i := 0; i < 5; i++ { gate.release <- struct{}{} } if err := <-done; err != nil { t.Fatalf("push: %v", err) } if peak := gate.peak(); peak > limit { t.Fatalf("peak in-flight = %d, want <= %d", peak, limit) } } func TestPushAssetFailureReportsOneFileAndSkipsHTML(t *testing.T) { arc := makeArchive(t, []tarEntry{ {name: "index.html", body: ""}, {name: "a.js", body: "1"}, {name: "b.js", body: "2"}, }) fc := newFakeAPI() // Every asset fails: fail-fast cancels the pool after the first genuine // failure. All genuine failures that landed before cancellation are // aggregated (§8.3); cancellation collateral (context.Canceled) is filtered // out (see TestPushFailFastFiltersCancellationCollateral). HTML is never // written. fc.failAll = true captureLogs(t) err := runPushN(arc, fc, 8) var ee exitError if !errors.As(err, &ee) { t.Fatalf("expected exitError, got %T", err) } for _, p := range fc.order { if strings.HasSuffix(strings.ToLower(p), ".html") { t.Fatalf("HTML must not be written when an asset fails; order = %v", fc.order) } } } // TestPushFailFastFiltersCancellationCollateral verifies that the fail-fast // report names ONLY the genuine upload failure, not the in-flight uploads that // were abandoned by the cancellation. A context.Canceled is a consequence of the // stop decision, not an independent file failure, and is swallowed uniformly so // push (fail-fast) and a user SIGINT behave identically (§8.3). The collateral // errors WRAP context.Canceled. func TestPushFailFastFiltersCancellationCollateral(t *testing.T) { arc := makeArchive(t, []tarEntry{ {name: "a.js", body: "1"}, {name: "b.js", body: "2"}, {name: "c.js", body: "3"}, {name: "index.html", body: ""}, }) api := &ctxCancelAPI{ fakeAPI: newFakeAPI(), triggerPath: "a.js", triggerErr: fileError{path: "a.js", err: errors.New("boom")}, } _, errb := captureLogs(t) // Concurrency covers all three assets so b.js/c.js are genuinely in-flight // (blocked) when a.js triggers the cancellation. err := runPushN(arc, api, 3) var ee exitError if !errors.As(err, &ee) { t.Fatalf("expected exitError, got %T (%v)", err, err) } report := errb.String() if !strings.Contains(report, "a.js") || !strings.Contains(report, "boom") { t.Fatalf("report must name the genuine failure a.js: %q", report) } if strings.Contains(report, "b.js") || strings.Contains(report, "c.js") { t.Fatalf("report must NOT name cancellation collateral b.js/c.js: %q", report) } for _, p := range api.order { if strings.HasSuffix(strings.ToLower(p), ".html") { t.Fatalf("HTML must not be written when an asset fails; order = %v", api.order) } } } func TestPushHTMLFailuresAreAllReported(t *testing.T) { arc := makeArchive(t, []tarEntry{ {name: "index.html", body: ""}, {name: "about.html", body: ""}, {name: "a.js", body: "1"}, }) fc := newFakeAPI() // Assets succeed; both HTML commit uploads fail. The drain phase reports // every failure under the friendly headline. fc.failErr = errors.New("boom") fc.failWhen = func(path string) bool { return strings.HasSuffix(path, ".html") } out, errb := captureLogs(t) err := runPushN(arc, fc, 8) var exit exitError if !errors.As(err, &exit) { t.Fatalf("expected exitError, got %v", err) } // The drain report lists every HTML failure on stderr... if !strings.Contains(errb.String(), "index.html") || !strings.Contains(errb.String(), "about.html") { t.Fatalf("drain report must list every HTML failure: %q", errb.String()) } // ...and the summary is still printed (last) on stdout. if !strings.Contains(out.String(), "failed:") { t.Fatalf("summary must be printed after a commit-phase failure: %q", out.String()) } } func TestShouldHintPrune(t *testing.T) { cases := []struct { name string sourceFiles int remoteFiles int want bool }{ {"below floor", 1, 999, false}, {"at floor within ratio", 1000, 6000, false}, {"at exact multiple", 100, 600, false}, {"one over multiple but below floor", 10, 61, false}, {"one over multiple and at floor", 200, 1201, true}, {"empty source", 0, 100000, false}, } for _, tc := range cases { if got := shouldHintPrune(tc.sourceFiles, tc.remoteFiles); got != tc.want { t.Errorf("%s: shouldHintPrune(%d, %d) = %v, want %v", tc.name, tc.sourceFiles, tc.remoteFiles, got, tc.want) } } } // stalePushSetup builds a one-file source (source_files == 1) and a fake API // whose remote listing holds remoteFiles file objects and remoteDirs directory // objects, so tests can drive the hint decision at chosen boundaries. func stalePushSetup(t *testing.T, remoteFiles, remoteDirs int) (string, *fakeAPI) { t.Helper() arc := makeArchive(t, []tarEntry{ {name: "index.html", body: ""}, }) fc := newFakeAPI() objs := make([]bunny.Object, 0, remoteFiles+remoteDirs) for i := 0; i < remoteFiles; i++ { objs = append(objs, bunny.Object{Path: fmt.Sprintf("stale/%d.js", i), Size: 1}) } for i := 0; i < remoteDirs; i++ { objs = append(objs, bunny.Object{Path: fmt.Sprintf("dir%d", i), IsDirectory: true}) } fc.lists[""] = objs return arc, fc } func TestPushHintFiresWhenStale(t *testing.T) { arc, fc := stalePushSetup(t, 1001, 0) out, _ := captureLogs(t) if err := runPush(arc, fc); err != nil { t.Fatalf("push: %v", err) } s := out.String() if !strings.Contains(s, "remote: 1001") { t.Fatalf("summary missing remote count: %q", s) } if !strings.Contains(s, "\n\nHint:") { t.Fatalf("expected prune hint: %q", s) } // The hint rides on stdout with the summary, not the stderr warning path. if strings.Contains(s, "\n\nHint:") && strings.Index(s, "remote:") > strings.Index(s, "\n\nHint:") { t.Fatalf("hint must follow the summary: %q", s) } } func TestPushNoHintWithinRetentionWindow(t *testing.T) { arc, fc := stalePushSetup(t, 6, 0) out, _ := captureLogs(t) if err := runPush(arc, fc); err != nil { t.Fatalf("push: %v", err) } if strings.Contains(out.String(), "hint:") { t.Fatalf("unexpected hint: %q", out.String()) } } func TestPushNoHintBelowFloor(t *testing.T) { arc, fc := stalePushSetup(t, 999, 0) out, _ := captureLogs(t) if err := runPush(arc, fc); err != nil { t.Fatalf("push: %v", err) } if strings.Contains(out.String(), "hint:") { t.Fatalf("unexpected hint below floor: %q", out.String()) } } func TestPushHintIgnoresRemoteDirectories(t *testing.T) { // Many directory nodes, few files: directories must not inflate remote_files. arc, fc := stalePushSetup(t, 5, 2000) out, _ := captureLogs(t) if err := runPush(arc, fc); err != nil { t.Fatalf("push: %v", err) } s := out.String() if !strings.Contains(s, "remote: 5") { t.Fatalf("directories must not count: %q", s) } if strings.Contains(s, "hint:") { t.Fatalf("unexpected hint from directory nodes: %q", s) } } func TestPushHintFiresUnderQuiet(t *testing.T) { arc, fc := stalePushSetup(t, 1001, 0) out, _ := captureLogs(t) log.SetQuiet(true) if err := runPush(arc, fc); err != nil { t.Fatalf("push: %v", err) } if !strings.Contains(out.String(), "\n\nHint:") { t.Fatalf("hint must appear under --quiet: %q", out.String()) } } func TestPushHintFiresUnderDryRun(t *testing.T) { arc, fc := stalePushSetup(t, 1001, 0) out, _ := captureLogs(t) dryRun = true if err := runPush(arc, dryRunStorageAPI{fc}); err != nil { t.Fatalf("push: %v", err) } if !strings.Contains(out.String(), "\n\nHint:") { t.Fatalf("hint must appear under dry-run: %q", out.String()) } } func TestHumanSize(t *testing.T) { cases := map[int]string{ 0: "0 B", 512: "512 B", 1024: "1.0 KiB", 1536: "1.5 KiB", 1024 * 1024: "1.0 MiB", } for in, want := range cases { if got := humanSize(in); got != want { t.Errorf("humanSize(%d) = %q, want %q", in, got, want) } } }