From 2c41acab4c97f584e8e199b31dc456194b8d7e6c Mon Sep 17 00:00:00 2001 From: tsne Date: Mon, 6 Jul 2026 16:52:20 +0200 Subject: initial --- internal/cli/cmd_push_test.go | 633 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 633 insertions(+) create mode 100644 internal/cli/cmd_push_test.go (limited to 'internal/cli/cmd_push_test.go') diff --git a/internal/cli/cmd_push_test.go b/internal/cli/cmd_push_test.go new file mode 100644 index 0000000..9a199b2 --- /dev/null +++ b/internal/cli/cmd_push_test.go @@ -0,0 +1,633 @@ +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) + } + } +} -- cgit v1.3