| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "testing" |
| 6 | ) |
| 7 | |
| 8 | // stageByChannel finds one stage across every band, so the tests can assert |
| 9 | // about a channel without caring which band the assembler filed it under. |
| 10 | func stageByChannel(f FlowView, channel string) *FlowStage { |
| 11 | for _, band := range [][]FlowStage{f.Line, f.Tests, f.Extra} { |
| 12 | for i := range band { |
| 13 | if band[i].Channel == channel { |
| 14 | return &band[i] |
| 15 | } |
| 16 | } |
| 17 | } |
| 18 | return nil |
| 19 | } |
| 20 | |
| 21 | // The line is the app's release path, and it is drawn whole even when the |
| 22 | // app has only reached the first rung: a pipeline with its later steps |
| 23 | // missing reads as "there is no later step", which is the opposite of true. |
| 24 | func TestFlowLineIsAlwaysTheWholePath(t *testing.T) { |
| 25 | v := &AppView{Channels: []ChannelView{{Name: "edge", Version: "a1"}}} |
| 26 | f := assembleFlow(v, nil, "a1", "", "/a/x/apps/app/instances") |
| 27 | |
| 28 | if len(f.Line) != 3 { |
| 29 | t.Fatalf("line has %d stages, want edge/beta/live: %+v", len(f.Line), f.Line) |
| 30 | } |
| 31 | for i, want := range []string{"edge", "beta", "stable"} { |
| 32 | if f.Line[i].Channel != want { |
| 33 | t.Errorf("line[%d] is %q, want %q", i, f.Line[i].Channel, want) |
| 34 | } |
| 35 | } |
| 36 | if f.Line[2].Name != "live" { |
| 37 | t.Errorf("the stable stage is labelled %q, want the word owners use: live", f.Line[2].Name) |
| 38 | } |
| 39 | if f.Line[1].Version != "" { |
| 40 | t.Errorf("beta holds nothing but reports version %q", f.Line[1].Version) |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | // The whole point of the picture is that an address hangs off the channel |
| 45 | // feeding it. An address filed under the wrong stage would draw a lie. |
| 46 | func TestFlowAddressesAttachToTheirChannel(t *testing.T) { |
| 47 | v := &AppView{Channels: []ChannelView{ |
| 48 | {Name: "stable", Version: "a1"}, |
| 49 | {Name: "canary/rc", Version: "b2"}, |
| 50 | }} |
| 51 | rows := []InstanceRow{ |
| 52 | {Host: "kass.dev", Channel: "stable", Version: "a1", HealthWord: "Running"}, |
| 53 | {Host: "www.kass.dev", Channel: "stable", Version: "a1", HealthWord: "Running"}, |
| 54 | {Host: "rc.kass.dev", Channel: "canary/rc", Version: "b2", HealthWord: "Asleep"}, |
| 55 | } |
| 56 | f := assembleFlow(v, rows, "b2", "", "/a/x/apps/app/instances") |
| 57 | |
| 58 | live := stageByChannel(f, "stable") |
| 59 | if live == nil || len(live.Addresses) != 2 { |
| 60 | t.Fatalf("live stage should carry both stable hosts: %+v", live) |
| 61 | } |
| 62 | if live.Addresses[0].Host != "kass.dev" || live.Addresses[1].Host != "www.kass.dev" { |
| 63 | t.Errorf("live addresses out of order: %+v", live.Addresses) |
| 64 | } |
| 65 | rc := stageByChannel(f, "canary/rc") |
| 66 | if rc == nil || len(rc.Addresses) != 1 || rc.Addresses[0].Host != "rc.kass.dev" { |
| 67 | t.Fatalf("the canary host should hang off canary/rc: %+v", rc) |
| 68 | } |
| 69 | if rc.Name != "rc" { |
| 70 | t.Errorf("canary stage is labelled %q, want the short name rc", rc.Name) |
| 71 | } |
| 72 | if len(f.Tests) != 1 { |
| 73 | t.Errorf("canary/rc belongs in the test band, got %d test stages", len(f.Tests)) |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | // A running address that the diagram does not draw is worse than no diagram: |
| 78 | // the picture claims to be the whole story. Whatever channel a route follows, |
| 79 | // its host must appear somewhere. |
| 80 | func TestFlowDrawsEveryRunningAddress(t *testing.T) { |
| 81 | v := &AppView{Channels: []ChannelView{{Name: "stable", Version: "a1"}}} |
| 82 | rows := []InstanceRow{ |
| 83 | {Host: "kass.dev", Channel: "stable", HealthWord: "Running"}, |
| 84 | {Host: "rc.kass.dev", Channel: "canary/rc", HealthWord: "Running"}, |
| 85 | {Host: "odd.kass.dev", Channel: "experiment", HealthWord: "Running"}, |
| 86 | } |
| 87 | f := assembleFlow(v, rows, "a1", "", "/a/x/apps/app/instances") |
| 88 | |
| 89 | for _, want := range []string{"kass.dev", "rc.kass.dev", "odd.kass.dev"} { |
| 90 | found := false |
| 91 | for _, band := range [][]FlowStage{f.Line, f.Tests, f.Extra} { |
| 92 | for _, s := range band { |
| 93 | for _, a := range s.Addresses { |
| 94 | if a.Host == want { |
| 95 | found = true |
| 96 | } |
| 97 | } |
| 98 | } |
| 99 | } |
| 100 | if !found { |
| 101 | t.Errorf("%s is running but appears nowhere in the diagram", want) |
| 102 | } |
| 103 | } |
| 104 | if len(f.Extra) != 1 || f.Extra[0].Channel != "experiment" { |
| 105 | t.Errorf("a route off the ladder needs its own band, got %+v", f.Extra) |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | // The safety delay is the single most confusing thing on the old ladder, so |
| 110 | // the diagram states it — but only when it is actually switched on, and only |
| 111 | // on the channels that hold. |
| 112 | func TestFlowShowsTheSafetyDelayOnlyWhenItApplies(t *testing.T) { |
| 113 | chans := []ChannelView{{Name: "edge", Version: "a1"}, {Name: "beta", Version: "a1"}, {Name: "stable", Version: "a1"}} |
| 114 | |
| 115 | on := assembleFlow(&AppView{Channels: chans, Production: ProductionView{On: true}}, nil, "a1", "", "/a/x/apps/app/instances") |
| 116 | if got := stageByChannel(on, "beta").Hold; got != "~24h" { |
| 117 | t.Errorf("beta hold is %q, want ~24h", got) |
| 118 | } |
| 119 | if got := stageByChannel(on, "stable").Hold; got != "~72h" { |
| 120 | t.Errorf("live hold is %q, want ~72h", got) |
| 121 | } |
| 122 | if got := stageByChannel(on, "edge").Hold; got != "" { |
| 123 | t.Errorf("edge holds nothing back but says %q", got) |
| 124 | } |
| 125 | |
| 126 | off := assembleFlow(&AppView{Channels: chans}, nil, "a1", "", "/a/x/apps/app/instances") |
| 127 | for _, s := range off.Line { |
| 128 | if s.Hold != "" { |
| 129 | t.Errorf("safety delay is off, but %s advertises a %q wait", s.Channel, s.Hold) |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | // An unreadable flag is treated as on, because that is what the boxes do. |
| 134 | bad := assembleFlow(&AppView{Channels: chans, Production: ProductionView{Err: "unverifiable"}}, nil, "a1", "", "/a/x/apps/app/instances") |
| 135 | if stageByChannel(bad, "stable").Hold == "" { |
| 136 | t.Error("an unreadable production flag must still show the delay — boxes keep baking") |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | // Health is the third of the three questions the console exists to answer, |
| 141 | // and the diagram is where it gets answered per address. Asleep must read as |
| 142 | // idle, not as a problem: hibernating is the platform working. |
| 143 | func TestFlowTonesAddressHealth(t *testing.T) { |
| 144 | rows := []InstanceRow{ |
| 145 | {Host: "a.dev", Channel: "stable", HealthWord: "Running"}, |
| 146 | {Host: "b.dev", Channel: "stable", HealthWord: "Asleep"}, |
| 147 | {Host: "c.dev", Channel: "stable", HealthWord: "Not responding"}, |
| 148 | {Host: "d.dev", Channel: "stable", HealthWord: "Failed"}, |
| 149 | {Host: "e.dev", Channel: "stable", HealthWord: "Setting up"}, |
| 150 | {Host: "f.dev", Channel: "stable", HealthWord: "Serving"}, |
| 151 | {Host: "g.dev", Channel: "stable", HealthWord: "Something new"}, |
| 152 | {Host: "h.dev", Channel: "stable", HealthWord: "No readings"}, |
| 153 | } |
| 154 | f := assembleFlow(&AppView{Channels: []ChannelView{{Name: "stable", Version: "a1"}}}, rows, "a1", "", "/a/x/apps/app/instances") |
| 155 | |
| 156 | want := map[string]string{ |
| 157 | "a.dev": "ok", "b.dev": "idle", "c.dev": "bad", "d.dev": "bad", |
| 158 | "e.dev": "wait", "f.dev": "ok", |
| 159 | // A word this function has never been taught is missing information: |
| 160 | // neither reassurance nor alarm. |
| 161 | "g.dev": "idle", |
| 162 | // Nobody could have sampled it either — honest ignorance reads the |
| 163 | // same as hibernating, not as a problem. |
| 164 | "h.dev": "idle", |
| 165 | } |
| 166 | for _, a := range stageByChannel(f, "stable").Addresses { |
| 167 | if a.Tone != want[a.Host] { |
| 168 | t.Errorf("%s (%s): tone=%q, want %q", a.Host, a.Health, a.Tone, want[a.Host]) |
| 169 | } |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | // The solid-vs-dashed arrow is the diagram's one claim beyond the words on |
| 174 | // it: that a version really did travel this step. It must be exactly true. |
| 175 | func TestFlowCarriesTheArrowOnlyWhereTheVersionTravelled(t *testing.T) { |
| 176 | // a1 shipped, promoted all the way to beta, not yet live. |
| 177 | f := assembleFlow(&AppView{Channels: []ChannelView{ |
| 178 | {Name: "edge", Version: "a1"}, |
| 179 | {Name: "beta", Version: "a1"}, |
| 180 | {Name: "stable", Version: "old"}, |
| 181 | }}, nil, "a1", "", "/a/x/apps/app/instances") |
| 182 | want := []bool{true, true, false} |
| 183 | for i, s := range f.Line { |
| 184 | if s.Carried != want[i] { |
| 185 | t.Errorf("%s: carried=%v, want %v", s.Channel, s.Carried, want[i]) |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | // Two empty boxes in a row share the empty string, and no version has |
| 190 | // travelled between them — the arrow must stay dashed. |
| 191 | empty := assembleFlow(&AppView{Channels: []ChannelView{{Name: "edge", Version: "a1"}}}, nil, "a1", "", "/a/x/apps/app/instances") |
| 192 | if empty.Line[1].Carried || empty.Line[2].Carried { |
| 193 | t.Errorf("empty boxes must not chain a solid arrow: %+v", empty.Line) |
| 194 | } |
| 195 | |
| 196 | // Nothing shipped at all: the very first arrow has nothing to carry. |
| 197 | none := assembleFlow(&AppView{Channels: []ChannelView{{Name: "edge", Version: "a1"}}}, nil, "", "", "/a/x/apps/app/instances") |
| 198 | if none.Line[0].Carried { |
| 199 | t.Error("no shipped version — the arrow into edge cannot be solid") |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | // A host following a channel nothing was ever published to is the failure the |
| 204 | // ladder exists to make visible; the diagram must not quietly draw an empty |
| 205 | // box instead. |
| 206 | func TestFlowSaysWhenAChannelHasNothingPublished(t *testing.T) { |
| 207 | rows := []InstanceRow{{Host: "rc.kass.dev", Channel: "canary/rc", HealthWord: "Not responding"}} |
| 208 | f := assembleFlow(&AppView{}, rows, "", "", "/a/x/apps/app/instances") |
| 209 | |
| 210 | rc := stageByChannel(f, "canary/rc") |
| 211 | if rc == nil { |
| 212 | t.Fatal("a host follows canary/rc — the stage must be drawn") |
| 213 | } |
| 214 | if rc.Err == "" { |
| 215 | t.Error("canary/rc has no version and no pointer: the stage must say so") |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | // Nothing shipped and nothing running is not a diagram — it is an empty |
| 220 | // state, and the page has better words for that than three blank boxes. |
| 221 | func TestFlowHidesItselfWhenThereIsNothingToDraw(t *testing.T) { |
| 222 | if f := assembleFlow(&AppView{}, nil, "", "", "/a/x/apps/app/instances"); f.Show { |
| 223 | t.Error("an app with no channels and no addresses should not draw a diagram") |
| 224 | } |
| 225 | if f := assembleFlow(&AppView{Channels: []ChannelView{{Name: "edge", Version: "a1"}}}, nil, "a1", "", "/a/x/apps/app/instances"); !f.Show { |
| 226 | t.Error("a shipped version on edge is worth drawing") |
| 227 | } |
| 228 | rows := []InstanceRow{{Host: "kass.dev", Channel: "stable", HealthWord: "Running"}} |
| 229 | if f := assembleFlow(&AppView{}, rows, "", "", "/a/x/apps/app/instances"); !f.Show { |
| 230 | t.Error("a running address is worth drawing even with no readable pointer") |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | // The rollup exists for stages that anticipate hundreds of addresses; a |
| 235 | // stage still inside the normal range must render exactly as it always has, |
| 236 | // with no rollup fields engaged at all. |
| 237 | func TestFlowSmallStagesUnchanged(t *testing.T) { |
| 238 | rows := make([]InstanceRow, 0, flowAddrMax) |
| 239 | for i := 0; i < flowAddrMax; i++ { |
| 240 | h := fmt.Sprintf("i%02d.example.test", i) |
| 241 | rows = append(rows, InstanceRow{Host: h, Channel: "edge", HealthWord: "Running"}) |
| 242 | } |
| 243 | view := &AppView{Channels: []ChannelView{{Name: "edge", Version: "v1"}}} |
| 244 | f := assembleFlow(view, rows, "v1", "", "/a/x/apps/app/instances") |
| 245 | st := f.Line[0] |
| 246 | if st.RolledUp { |
| 247 | t.Fatalf("%d addresses must not roll up: %+v", flowAddrMax, st) |
| 248 | } |
| 249 | if len(st.Addresses) != flowAddrMax { |
| 250 | t.Fatalf("all %d addresses should be listed, got %d: %+v", flowAddrMax, len(st.Addresses), st.Addresses) |
| 251 | } |
| 252 | if st.Total != 0 || st.Attention != 0 || st.Hidden != 0 || st.MoreURL != "" { |
| 253 | t.Fatalf("rollup fields must stay zero when not rolled up: %+v", st) |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | // The needs-attention list inside a rolled-up stage is capped so the diagram |
| 258 | // never grows unbounded again by another route — the Running tab is where |
| 259 | // the rest of the story lives, via MoreURL. |
| 260 | func TestFlowRollupCapsExceptions(t *testing.T) { |
| 261 | rows := make([]InstanceRow, 0, 20) |
| 262 | for i := 0; i < 20; i++ { |
| 263 | h := fmt.Sprintf("i%02d.example.test", i) |
| 264 | w := "Running" |
| 265 | if i < 9 { |
| 266 | w = "Not responding" |
| 267 | } |
| 268 | rows = append(rows, InstanceRow{Host: h, Channel: "edge", HealthWord: w}) |
| 269 | } |
| 270 | view := &AppView{Channels: []ChannelView{{Name: "edge", Version: "v1"}}} |
| 271 | f := assembleFlow(view, rows, "v1", "", "/a/x/apps/app/instances") |
| 272 | st := f.Line[0] |
| 273 | if !st.RolledUp || st.Attention != 9 { |
| 274 | t.Fatalf("rollup: %+v", st) |
| 275 | } |
| 276 | if len(st.Addresses) != flowExceptionMax { |
| 277 | t.Fatalf("exceptions capped at %d, got %d: %+v", flowExceptionMax, len(st.Addresses), st.Addresses) |
| 278 | } |
| 279 | if st.Hidden != 4 { |
| 280 | t.Fatalf("hidden should be 9 attention - 5 shown = 4, got %d", st.Hidden) |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | // The exact test from the task brief, kept verbatim for full alignment with |
| 285 | // the interface it specifies. |
| 286 | func TestFlowRollsUpBigStages(t *testing.T) { |
| 287 | rows := make([]InstanceRow, 0, 12) |
| 288 | for i := 0; i < 12; i++ { |
| 289 | h := fmt.Sprintf("i%02d.example.test", i) |
| 290 | w := "Running" |
| 291 | if i == 3 || i == 7 { |
| 292 | w = "Not responding" |
| 293 | } |
| 294 | rows = append(rows, InstanceRow{Host: h, Channel: "edge", HealthWord: w}) |
| 295 | } |
| 296 | view := &AppView{Channels: []ChannelView{{Name: "edge", Version: "v1"}}} |
| 297 | f := assembleFlow(view, rows, "v1", "", "/a/x/apps/app/instances") |
| 298 | st := f.Line[0] // edge |
| 299 | if !st.RolledUp || st.Total != 12 || st.Attention != 2 || st.Hidden != 0 { |
| 300 | t.Fatalf("rollup: %+v", st) |
| 301 | } |
| 302 | if len(st.Addresses) != 2 || st.Addresses[0].Health != "Not responding" { |
| 303 | t.Fatalf("exceptions: %+v", st.Addresses) |
| 304 | } |
| 305 | if st.MoreURL != "/a/x/apps/app/instances" { |
| 306 | t.Fatalf("MoreURL: %q", st.MoreURL) |
| 307 | } |
| 308 | } |
| 309 | |