| index 708f8f30e..9b83d292b 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -5,6 +5,7 @@ import ( |
| "crypto/ed25519" |
| "encoding/json" |
| "errors" |
| + "fmt" |
| "io" |
| "net/http" |
| "net/http/cookiejar" |
| @@ -242,21 +243,39 @@ func TestConsoleE2E(t *testing.T) { |
| t.Fatal(err) |
| } |
| |
| - // Detail page: routes and channels, with the bake approximation labeled. |
| + // Overview: the ladder, with channel names and versions on it. |
| status, _, body := get(t, c, fx.u("/apps/kass")) |
| if status != 200 { |
| t.Fatalf("/apps/kass = %d", status) |
| } |
| - for _, want := range []string{ |
| - "Domains", "kass.carlosapp.dev", "docs.kass.carlosapp.dev", "custom", |
| - "ready", "v1", "stable", "canary/x", "v2", |
| - "bakes until ~", "<td>live</td>", "approximation", |
| - } { |
| + for _, want := range []string{"v1", "stable", "canary/x", "v2"} { |
| if !strings.Contains(body, want) { |
| t.Fatalf("/apps/kass missing %q in:\n%s", want, body) |
| } |
| } |
| |
| + // Releases tab: the Channels table, with the bake approximation labeled. |
| + status, _, rbody := get(t, c, fx.u("/apps/kass/releases")) |
| + if status != 200 { |
| + t.Fatalf("/apps/kass/releases = %d", status) |
| + } |
| + for _, want := range []string{"canary/x", "v2", "bakes until ~", "<td>live</td>", "approximation"} { |
| + if !strings.Contains(rbody, want) { |
| + t.Fatalf("/apps/kass/releases missing %q in:\n%s", want, rbody) |
| + } |
| + } |
| + |
| + // Settings tab: the app's domains. |
| + status, _, sbody := get(t, c, fx.u("/apps/kass/settings")) |
| + if status != 200 { |
| + t.Fatalf("/apps/kass/settings = %d", status) |
| + } |
| + for _, want := range []string{"Domains", "kass.carlosapp.dev", "docs.kass.carlosapp.dev", "custom", "ready"} { |
| + if !strings.Contains(sbody, want) { |
| + t.Fatalf("/apps/kass/settings missing %q in:\n%s", want, sbody) |
| + } |
| + } |
| + |
| // Apps not owned 404 exactly like apps that don't exist. |
| if status, _, _ := get(t, c, fx.u("/apps/otherapp")); status != http.StatusNotFound { |
| t.Fatalf("/apps/otherapp = %d, want 404", status) |
| @@ -420,14 +439,23 @@ func TestCreateApp(t *testing.T) { |
| if status != 200 { |
| t.Fatalf("/apps/zephyr = %d", status) |
| } |
| + for _, want := range []string{"Created app zephyr", "0 releases"} { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("/apps/zephyr missing %q in:\n%s", want, body) |
| + } |
| + } |
| + status, _, rbody := get(t, c, fx.u("/apps/zephyr/releases")) |
| + if status != 200 { |
| + t.Fatalf("/apps/zephyr/releases = %d", status) |
| + } |
| for _, want := range []string{ |
| - "Created app zephyr", "Nothing shipped yet", |
| + "Nothing shipped yet", |
| "carlos ship -app zephyr -version $(git rev-parse --short HEAD) -target linux-arm64", |
| "carlos ship -app zephyr -kind static -version v1", |
| "promoted to channels from this page", |
| } { |
| - if !strings.Contains(body, want) { |
| - t.Fatalf("/apps/zephyr missing %q in:\n%s", want, body) |
| + if !strings.Contains(rbody, want) { |
| + t.Fatalf("/apps/zephyr/releases missing %q in:\n%s", want, rbody) |
| } |
| } |
| apps, err := fx.st.AppsIn(OpsAccountID) |
| @@ -488,6 +516,22 @@ func TestAppTabBar(t *testing.T) { |
| } |
| } |
| |
| +func TestOverviewIsALadderNotAnEverythingPage(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + _, _, body := get(t, c, fx.u("/apps/kass")) |
| + for _, gone := range []string{"<h2>Channels</h2>", "<h2>Releases</h2>", "<h2>Recent activity</h2>", "<h2>Domains</h2>", "<h2>Resources</h2>"} { |
| + if strings.Contains(body, gone) { |
| + t.Fatalf("overview still renders %q", gone) |
| + } |
| + } |
| + for _, want := range []string{`class="ladder"`, "domain", "release"} { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("overview missing %q", want) |
| + } |
| + } |
| +} |
| + |
| func TestReleasesTab(t *testing.T) { |
| fx := newConsoleFixture(t) |
| c := signInPaul(t, fx) |
| @@ -498,12 +542,17 @@ func TestReleasesTab(t *testing.T) { |
| for _, want := range []string{ |
| `aria-current="page">Releases</a>`, |
| "<h2>Channels</h2>", "<h2>Canaries</h2>", "<h2>Releases</h2>", |
| - `name="back" value="releases"`, |
| } { |
| if !strings.Contains(body, want) { |
| t.Fatalf("releases tab missing %q", want) |
| } |
| } |
| + // Every mutation form on the tab (promote, rollback, new canary, retire |
| + // canary) carries a back=releases hidden field, so its redirect returns |
| + // here rather than to the overview — exactly 4, one per form. |
| + if n := strings.Count(body, `name="back" value="releases"`); n != 4 { |
| + t.Fatalf(`releases tab has %d back=releases fields, want exactly 4`, n) |
| + } |
| assertNonMember404(t, fx, "/apps/kass/releases") |
| } |
| |
| @@ -679,10 +728,10 @@ func TestPromoteFlow(t *testing.T) { |
| c := signInPaul(t, fx) |
| csrf := sessionToken(t, fx, c) |
| |
| - // The app page offers promote forms per shipped version. |
| - _, _, body := get(t, c, fx.u("/apps/kass")) |
| + // The releases tab offers promote forms per shipped version. |
| + _, _, body := get(t, c, fx.u("/apps/kass/releases")) |
| if !strings.Contains(body, `action="/a/bab/apps/kass/promote"`) { |
| - t.Fatal("no promote form on the app page") |
| + t.Fatal("no promote form on the releases tab") |
| } |
| if strings.Contains(body, "Promotions disabled") { |
| t.Fatal("keyed fixture rendered the disabled note") |
| @@ -774,7 +823,7 @@ func TestRollbackFlow(t *testing.T) { |
| |
| // Fixture stable is v1 with no history: no rollback button for it (nor |
| // for the single-promote canary), and a forced POST politely refuses. |
| - _, _, body := get(t, c, fx.u("/apps/kass")) |
| + _, _, body := get(t, c, fx.u("/apps/kass/releases")) |
| if strings.Contains(body, `name="channel" value="stable"`) || strings.Contains(body, `name="channel" value="canary/x"`) { |
| t.Fatal("rollback button rendered for a channel with no history") |
| } |
| @@ -788,7 +837,7 @@ func TestRollbackFlow(t *testing.T) { |
| // primitive rather than promoted through the console, because this test is |
| // about rollback and the ladder has its own (TestLadderRefusesASkip). |
| seedChannel(t, fx, "kass", "stable", "v2") |
| - if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, `name="channel" value="stable"`) { |
| + if _, _, body := get(t, c, fx.u("/apps/kass/releases")); !strings.Contains(body, `name="channel" value="stable"`) { |
| t.Fatal("no rollback button after stable gained history") |
| } |
| status, loc, _ := postForm(t, c, fx.u("/apps/kass/rollback"), |
| @@ -831,14 +880,14 @@ func TestCanaryFlow(t *testing.T) { |
| c := signInPaul(t, fx) |
| csrf := sessionToken(t, fx, c) |
| |
| - // The app page: create form, and the fixture canary listed hostless. |
| - _, _, body := get(t, c, fx.u("/apps/kass")) |
| + // The releases tab: create form, and the fixture canary listed hostless. |
| + _, _, body := get(t, c, fx.u("/apps/kass/releases")) |
| for _, want := range []string{ |
| `action="/a/bab/apps/kass/canary"`, `action="/a/bab/apps/kass/canary/retire"`, |
| "canary/x", "no host yet", "Create canary", |
| } { |
| if !strings.Contains(body, want) { |
| - t.Fatalf("/apps/kass missing %q in:\n%s", want, body) |
| + t.Fatalf("/apps/kass/releases missing %q in:\n%s", want, body) |
| } |
| } |
| |
| @@ -1001,9 +1050,9 @@ func TestAppPageInstances(t *testing.T) { |
| fx := newConsoleFixture(t) |
| c := signInPaul(t, fx) |
| |
| - // Before any reporting route exists: no Instances section — the page is |
| + // Before any reporting route exists: no Instances section — the tab is |
| // exactly the route-level view. |
| - if _, _, body := get(t, c, fx.u("/apps/kass")); strings.Contains(body, "<h2>Instances</h2>") { |
| + if _, _, body := get(t, c, fx.u("/apps/kass/instances")); strings.Contains(body, "<h2>Instances</h2>") { |
| t.Fatal("Instances section rendered with nothing reporting") |
| } |
| |
| @@ -1011,26 +1060,31 @@ func TestAppPageInstances(t *testing.T) { |
| app := newInstanceApp(t, kassReport) |
| fx.addRoute(t, registry.Route{Host: app.host(t), Kind: "instance", |
| Status: "ready", App: "kass"}) |
| - _, _, body := get(t, c, fx.u("/apps/kass")) |
| + _, _, body := get(t, c, fx.u("/apps/kass/instances")) |
| for _, want := range []string{ |
| "<h2>Instances</h2>", "reports 3 internal instances", |
| "riders-2026", "spring-camp", "trial-run", |
| "eu-west-1", "archived", "read-only since June", |
| } { |
| if !strings.Contains(body, want) { |
| - t.Fatalf("/apps/kass missing %q in:\n%s", want, body) |
| + t.Fatalf("/apps/kass/instances missing %q in:\n%s", want, body) |
| } |
| } |
| } |
| |
| +// TestRecentActivity pins the activity tab against real, console-driven |
| +// events (not synthetic RecordEvent calls) — TestActivityTab only checks the |
| +// tab's chrome, so this is where seeded rows actually render. It also pins |
| +// activityCap=50: past the cap, the oldest rows fall off and the newest 50 |
| +// (and no more) are what's on the page. |
| func TestRecentActivity(t *testing.T) { |
| fx := newConsoleFixture(t) |
| c := signInPaul(t, fx) |
| csrf := sessionToken(t, fx, c) |
| |
| - // Fresh app page: no activity yet. |
| - if _, _, body := get(t, c, fx.u("/apps/kass")); strings.Contains(body, "Recent activity") { |
| - t.Fatal("activity section rendered before any action") |
| + // Fresh activity tab: no activity yet. |
| + if _, _, body := get(t, c, fx.u("/apps/kass/activity")); !strings.Contains(body, "Nothing yet") { |
| + t.Fatal("activity tab did not show its empty state before any action") |
| } |
| |
| // A promotion the ladder allows (a fresh build entering at edge), and a |
| @@ -1046,9 +1100,9 @@ func TestRecentActivity(t *testing.T) { |
| postForm(t, c, fx.u("/apps/kass/canary/retire"), |
| url.Values{"csrf": {csrf}, "name": {"rc"}}) |
| |
| - _, _, body := get(t, c, fx.u("/apps/kass")) |
| + _, _, body := get(t, c, fx.u("/apps/kass/activity")) |
| for _, want := range []string{ |
| - "Recent activity", "paul@keymail.dev", |
| + "paul@keymail.dev", |
| "promoted v3 to edge", "rolled back stable to v1", |
| "created canary rc at v1", "retired canary rc", |
| "CLI promotions don", |
| @@ -1061,6 +1115,26 @@ func TestRecentActivity(t *testing.T) { |
| if strings.Index(body, "retired canary rc") > strings.Index(body, "promoted v3 to edge") { |
| t.Fatal("activity is not newest-first") |
| } |
| + |
| + // Push the account's own event count well past the cap. The 4 real |
| + // actions above are now the oldest rows in the account, so this also |
| + // exercises them falling off — not just synthetic filler. |
| + for i := 0; i < 60; i++ { |
| + if err := fx.st.RecordEvent(OpsAccountID, "kass", "paul@keymail.dev", |
| + "marker", fmt.Sprintf("cap-marker-%03d", i), time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + _, _, capped := get(t, c, fx.u("/apps/kass/activity")) |
| + if n := strings.Count(capped, "cap-marker-"); n != 50 { |
| + t.Fatalf("activity tab rendered %d rows past the cap, want exactly 50 (activityCap)", n) |
| + } |
| + if !strings.Contains(capped, "cap-marker-059") { |
| + t.Fatal("newest event fell off the capped activity tab") |
| + } |
| + if strings.Contains(capped, "cap-marker-000") || strings.Contains(capped, "promoted v3 to edge") { |
| + t.Fatal("activity tab kept rows older than its 50-row cap should allow") |
| + } |
| } |
| |
| func TestActivityTab(t *testing.T) { |
| @@ -1105,11 +1179,12 @@ func TestPromotionsDisabledWithoutKey(t *testing.T) { |
| t.Fatal("keyless console is missing the disabled note") |
| } |
| // The Domains section is a read — keyless (read-only) mode still renders it. |
| - if !strings.Contains(body, "Domains") || !strings.Contains(body, "kass.carlosapp.dev") { |
| + if _, _, sbody := get(t, c, fx.u("/apps/kass/settings")); !strings.Contains(sbody, "Domains") || !strings.Contains(sbody, "kass.carlosapp.dev") { |
| t.Fatal("keyless console is missing the Domains section") |
| } |
| - if strings.Contains(body, `action="/a/bab/apps/kass/promote"`) || strings.Contains(body, `action="/a/bab/apps/kass/rollback"`) || |
| - strings.Contains(body, `action="/a/bab/apps/kass/canary"`) || strings.Contains(body, `action="/a/bab/apps/kass/canary/retire"`) { |
| + _, _, rbody := get(t, c, fx.u("/apps/kass/releases")) |
| + if strings.Contains(rbody, `action="/a/bab/apps/kass/promote"`) || strings.Contains(rbody, `action="/a/bab/apps/kass/rollback"`) || |
| + strings.Contains(rbody, `action="/a/bab/apps/kass/canary"`) || strings.Contains(rbody, `action="/a/bab/apps/kass/canary/retire"`) { |
| t.Fatal("keyless console rendered mutation forms") |
| } |
| if status, _, _ := postForm(t, c, fx.u("/apps/kass/promote"), |
| @@ -1285,7 +1360,7 @@ func TestAppPageRendersDomains(t *testing.T) { |
| fx.addRoute(t, registry.Route{Host: "kass.bab.carlosapp.dev", Kind: "instance", |
| Status: "ready", App: "kass", Platform: true, DNSStatus: "pointing", Account: "paul@keymail.dev"}) |
| c := signInPaul(t, fx) |
| - _, _, body := get(t, c, fx.u("/apps/kass")) |
| + _, _, body := get(t, c, fx.u("/apps/kass/settings")) |
| for _, want := range []string{ |
| "Domains", "kass.bab.carlosapp.dev", "platform", "pointing", |
| "kass.carlosapp.dev", "custom", "—", |
| @@ -1293,7 +1368,7 @@ func TestAppPageRendersDomains(t *testing.T) { |
| "Checks run every few minutes.", |
| } { |
| if !strings.Contains(body, want) { |
| - t.Fatalf("/apps/kass missing %q in:\n%s", want, body) |
| + t.Fatalf("/apps/kass/settings missing %q in:\n%s", want, body) |
| } |
| } |
| } |
| @@ -1643,9 +1718,9 @@ func TestAppPageOwnerMetricsBoundary(t *testing.T) { |
| } |
| |
| c := signInPaul(t, fx) |
| - status, _, body := get(t, c, fx.u("/apps/kass")) |
| + status, _, body := get(t, c, fx.u("/apps/kass/instances")) |
| if status != http.StatusOK { |
| - t.Fatalf("/apps/kass = %d, want 200: %s", status, body) |
| + t.Fatalf("/apps/kass/instances = %d, want 200: %s", status, body) |
| } |
| for _, want := range []string{ |
| "Resources", "owned.example.com", "11%", "1.0 KB", |
| @@ -1669,19 +1744,20 @@ func TestAppPageOwnerMetricsBoundary(t *testing.T) { |
| } |
| |
| // TestAppPageSurvivesMetricsFailure: metrics are decoration. A bucket that |
| -// fails every read must still leave the owner a fully rendered app page. |
| +// fails every read must still leave the owner a fully rendered instances tab |
| +// — the only tab that reads metrics now that the overview doesn't. |
| func TestAppPageSurvivesMetricsFailure(t *testing.T) { |
| fx := newConsoleFixture(t) |
| fx.addRoute(t, registry.Route{Host: "owned.example.com", Kind: "instance", |
| Status: "ready", App: "kass", Backing: registry.BackingUnit}) |
| fx.srv.cfg.Releases = failingListStore{fx.rel} |
| c := signInPaul(t, fx) |
| - status, _, body := get(t, c, fx.u("/apps/kass")) |
| + status, _, body := get(t, c, fx.u("/apps/kass/instances")) |
| if status != http.StatusOK { |
| - t.Fatalf("/apps/kass with a broken metrics read = %d, want 200: %s", status, body) |
| + t.Fatalf("/apps/kass/instances with a broken metrics read = %d, want 200: %s", status, body) |
| } |
| - if !strings.Contains(body, "Domains") { |
| - t.Fatalf("app page did not render its normal sections: %s", body) |
| + if !strings.Contains(body, `aria-current="page">Instances</a>`) { |
| + t.Fatalf("instances tab did not render its normal chrome: %s", body) |
| } |
| } |
| |
| @@ -1732,9 +1808,9 @@ func TestAppPageOwnerMetricsCrossAccountAppName(t *testing.T) { |
| } |
| |
| c := signInPaul(t, fx) |
| - status, _, body := get(t, c, fx.u("/apps/kass")) |
| + status, _, body := get(t, c, fx.u("/apps/kass/instances")) |
| if status != http.StatusOK { |
| - t.Fatalf("/apps/kass = %d, want 200", status) |
| + t.Fatalf("/apps/kass/instances = %d, want 200", status) |
| } |
| if !strings.Contains(body, "11%") { |
| t.Fatalf("own instance's metrics missing in:\n%s", body) |
| @@ -1765,7 +1841,7 @@ func resourcesSection(t *testing.T, body string) string { |
| if i < 0 { |
| t.Fatalf("no Resources section in:\n%s", body) |
| } |
| - j := strings.Index(body[i:], "<h2>Channels</h2>") |
| + j := strings.Index(body[i:], "</main>") |
| if j < 0 { |
| t.Fatalf("Resources section never ended:\n%s", body[i:]) |
| } |
| @@ -1796,9 +1872,9 @@ func TestAppPageOwnerMetricsStaleBoxIsNotCurrent(t *testing.T) { |
| } |
| |
| c := signInPaul(t, fx) |
| - status, _, body := get(t, c, fx.u("/apps/kass")) |
| + status, _, body := get(t, c, fx.u("/apps/kass/instances")) |
| if status != http.StatusOK { |
| - t.Fatalf("/apps/kass = %d, want 200", status) |
| + t.Fatalf("/apps/kass/instances = %d, want 200", status) |
| } |
| if !strings.Contains(body, "asleep — no recent readings") { |
| t.Fatalf("stale box did not render as no-recent-readings:\n%s", resourcesSection(t, body)) |