| 1 | package callstest |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "regexp" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | "time" |
| 9 | ) |
| 10 | |
| 11 | func TestHoldingPageShowsTitleStandingAndFloor(t *testing.T) { |
| 12 | x, sqid, _ := memberWithMeeting(t) |
| 13 | c := x.browser(t) |
| 14 | c.signIn("paul@example.com") |
| 15 | rec := c.get("/m/" + sqid + "/join") |
| 16 | body := rec.Body.String() |
| 17 | for _, want := range []string{"Board meeting", "You’ll join as paul.", "Calls outside your network may not connect yet.", `rst-tone="warning"`, "End meeting for everyone"} { |
| 18 | if !strings.Contains(body, want) { |
| 19 | t.Errorf("holding page lacks %q", want) |
| 20 | } |
| 21 | } |
| 22 | } |
| 23 | |
| 24 | // A meeting that exists, a person who is signed in, and no standing |
| 25 | // toward it. The earlier version of this test asked for a meeting that |
| 26 | // did not exist, so it passed before standingFor was ever called |
| 27 | // (review C1-8). |
| 28 | func TestHoldingPageIs404ForNoStanding(t *testing.T) { |
| 29 | x, first, tok := memberWithMeeting(t) |
| 30 | |
| 31 | // A signed-in non-member: an outsider who followed the first |
| 32 | // meeting's link, so they hold a grant for that meeting and none |
| 33 | // for any other. |
| 34 | g := x.browser(t) |
| 35 | g.get("/j/" + tok) |
| 36 | if dest := g.signIn("guest@example.com"); dest != "/m/"+first+"/join" { |
| 37 | t.Fatalf("outsider landed on %s", dest) |
| 38 | } |
| 39 | |
| 40 | member := x.browser(t) |
| 41 | member.signIn("paul@example.com") |
| 42 | second, _ := createMeeting(t, member, "A meeting they were not invited to", "") |
| 43 | |
| 44 | if rec := g.get("/m/" + second + "/join"); rec.Code != http.StatusNotFound { |
| 45 | t.Fatalf("a real meeting with no grant: %d, want 404", rec.Code) |
| 46 | } |
| 47 | // And the meeting they do hold a grant for, once it has run out. |
| 48 | x.advance(2*time.Hour + time.Minute) |
| 49 | if rec := g.get("/m/" + first + "/join"); rec.Code != http.StatusNotFound { |
| 50 | t.Fatalf("a real meeting with an expired grant: %d, want 404", rec.Code) |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | // Fix round 1, Important 12: once this session already holds a peer in |
| 55 | // the room, the holding page points at the takeover confirm page rather |
| 56 | // than offering a plain join that would collide with it. |
| 57 | func TestHoldingPageOffersTakeoverWhenThisSessionIsAlreadyInTheRoom(t *testing.T) { |
| 58 | x, sqid, _ := memberWithMeeting(t) |
| 59 | m := x.browser(t) |
| 60 | m.signIn("paul@example.com") |
| 61 | if rec := m.get("/m/" + sqid + "/join"); strings.Contains(rec.Body.String(), "already open in another tab") { |
| 62 | t.Fatal("holding page offers takeover before any stream is open") |
| 63 | } |
| 64 | s := m.stream("/m/" + sqid + "/stream") |
| 65 | defer s.close() |
| 66 | s.hello() |
| 67 | rec := m.get("/m/" + sqid + "/join") |
| 68 | body := rec.Body.String() |
| 69 | if !strings.Contains(body, "already open in another tab") || !strings.Contains(body, "/m/"+sqid+"/takeover") { |
| 70 | t.Fatalf("holding page lacks the takeover offer once a peer is live: %s", body) |
| 71 | } |
| 72 | // devices.js's own join button must not offer a fresh stream that |
| 73 | // would 409: once this session already has a peer here, the |
| 74 | // #devices block's join target is the takeover confirm page, not |
| 75 | // the room. |
| 76 | if !strings.Contains(body, `data-join-href="/m/`+sqid+`/takeover"`) { |
| 77 | t.Fatalf("holding page's join target should switch to takeover once a peer is live: %s", body) |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | // Task 7: devices.js takes over the region the slice-1 placeholder |
| 82 | // held. The script tag lives in <head> (the room page's own pattern, |
| 83 | // and the CSP's default-src 'self' wants no inline script), the |
| 84 | // #devices block still carries the sqid the script reads, and its |
| 85 | // default join target is the room itself — not the takeover page, |
| 86 | // which is only for a session that already has a peer here (the next |
| 87 | // test). |
| 88 | func TestHoldingPageHasDevicesScriptAndNoLegacyPlaceholder(t *testing.T) { |
| 89 | x, sqid, _ := memberWithMeeting(t) |
| 90 | c := x.browser(t) |
| 91 | c.signIn("paul@example.com") |
| 92 | rec := c.get("/m/" + sqid + "/join") |
| 93 | body := rec.Body.String() |
| 94 | |
| 95 | headEnd := strings.Index(body, "</head>") |
| 96 | // asset() fingerprints the filename (static/devices.<hash>.js), so |
| 97 | // the literal "devices.js" never appears — match the stable prefix, |
| 98 | // as room_test.go does for calls.css. |
| 99 | script := strings.Index(body, "/static/devices.") |
| 100 | if headEnd < 0 || script < 0 || script > headEnd { |
| 101 | t.Fatalf("devices.js is not inside <head>: head ends at %d, script at %d\n%s", headEnd, script, body) |
| 102 | } |
| 103 | if !strings.Contains(body, `id="devices" data-sqid="`+sqid+`"`) { |
| 104 | t.Fatalf("holding page lacks the devices block's data-sqid: %s", body) |
| 105 | } |
| 106 | if !strings.Contains(body, `data-join-href="/m/`+sqid+`"`) { |
| 107 | t.Fatalf("holding page's default join target should be the room: %s", body) |
| 108 | } |
| 109 | if !strings.Contains(body, "<noscript>") || !strings.Contains(body, "needs JavaScript") { |
| 110 | t.Fatal("holding page lacks its scriptless paragraph") |
| 111 | } |
| 112 | if strings.Contains(body, "Calls coming soon") { |
| 113 | t.Fatal("holding page still carries the slice-1 placeholder") |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | // Fix round 1, Important 5, restated for the reserved layout (shared |
| 118 | // chrome R2): #devices is in the layout from the first paint, so that |
| 119 | // devices.js revealing it does not push the page down, and without |
| 120 | // script a <noscript> stylesheet hides it instead — a regression that |
| 121 | // dropped either would show a bare, unstyled device block to every |
| 122 | // browser with script disabled, or move the page under one with it. |
| 123 | func TestHoldingPageDevicesBlockIsHiddenOnlyWithoutScript(t *testing.T) { |
| 124 | x, sqid, _ := memberWithMeeting(t) |
| 125 | c := x.browser(t) |
| 126 | c.signIn("paul@example.com") |
| 127 | rec := c.get("/m/" + sqid + "/join") |
| 128 | body := rec.Body.String() |
| 129 | if !strings.Contains(body, `data-join-href="/m/`+sqid+`">`) { |
| 130 | t.Fatalf("holding page's #devices block should be in the layout from the first paint: %s", body) |
| 131 | } |
| 132 | m := regexp.MustCompile(`<noscript><link rel="stylesheet" href="(/static/noscript\.[0-9a-f]+\.css)"></noscript>`).FindStringSubmatch(body) |
| 133 | if m == nil { |
| 134 | t.Fatalf("holding page has no <noscript> stylesheet to hide #devices: %s", body) |
| 135 | } |
| 136 | css := c.get(m[1]).Body.String() |
| 137 | if !strings.Contains(css, "#devices { display: none; }") { |
| 138 | t.Fatalf("the noscript sheet does not hide #devices: %s", css) |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | // The two toggles carry their accessible name in aria-label and their |
| 143 | // state in aria-pressed and a child span, so the name a screen reader |
| 144 | // tracks never changes under it and the state is never carried by |
| 145 | // colour alone (AGENTS.md's UX conventions). The server renders all |
| 146 | // three; devices.js only ever moves the attribute and the span's word, |
| 147 | // which is why the markup is worth asserting here rather than in a |
| 148 | // browser. |
| 149 | func TestHoldingPageTogglesNameThemselvesAndShowTheirState(t *testing.T) { |
| 150 | x, sqid, _ := memberWithMeeting(t) |
| 151 | c := x.browser(t) |
| 152 | c.signIn("paul@example.com") |
| 153 | body := c.get("/m/" + sqid + "/join").Body.String() |
| 154 | for _, want := range []string{ |
| 155 | `id="toggle-mute" rst-btn aria-label="Microphone" aria-pressed="false"`, |
| 156 | `<span id="toggle-mute-state" class="calls-toggle-state" aria-hidden="true">On</span>`, |
| 157 | `id="toggle-camera" rst-btn aria-label="Camera" aria-pressed="false"`, |
| 158 | `<span id="toggle-camera-state" class="calls-toggle-state" aria-hidden="true">On</span>`, |
| 159 | } { |
| 160 | if !strings.Contains(body, want) { |
| 161 | t.Fatalf("holding page's toggles: missing %q\n%s", want, body) |
| 162 | } |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | func TestHoldingPageIs404ForEndedMeetings(t *testing.T) { |
| 167 | x, sqid, _ := memberWithMeeting(t) |
| 168 | m := x.browser(t) |
| 169 | m.signIn("paul@example.com") |
| 170 | m.post("/meetings/"+sqid+"/end", nil) |
| 171 | if rec := m.get("/m/" + sqid + "/join"); rec.Code != http.StatusNotFound { |
| 172 | t.Fatalf("ended: %d", rec.Code) |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | // Studio audio is the unprocessed capture option (the microphone asked |
| 177 | // for with echo cancellation, noise suppression and automatic gain all |
| 178 | // off, and a higher Opus ceiling to send it through). Three facts about |
| 179 | // the markup are worth holding here rather than in a browser, because |
| 180 | // devices.js only ever moves the checkbox's `checked` and the row's |
| 181 | // `hidden` and would go on "working" with any of them broken: |
| 182 | // |
| 183 | // - it is the vendored field-check and not a hand-rolled checkbox, |
| 184 | // which AGENTS.md's field rule requires — rst-switch is that |
| 185 | // partial's own attribute and nothing else in these pages emits it; |
| 186 | // - the row starts hidden, like the speaker row, so a scriptless |
| 187 | // browser is not shown a control that cannot do anything; |
| 188 | // - the help sentence carries the id devices.js wires to the input |
| 189 | // with aria-describedby, so a rename here that left the script |
| 190 | // alone would silently unwire the explanation. |
| 191 | func TestHoldingPageOffersStudioAudioAsAVendoredField(t *testing.T) { |
| 192 | x, sqid, _ := memberWithMeeting(t) |
| 193 | c := x.browser(t) |
| 194 | c.signIn("paul@example.com") |
| 195 | body := c.get("/m/" + sqid + "/join").Body.String() |
| 196 | for _, want := range []string{ |
| 197 | `<div id="raw-row" class="calls-raw-row">`, |
| 198 | `<label rst-switch>`, |
| 199 | `name="raw-audio"`, |
| 200 | `id="raw-help"`, |
| 201 | } { |
| 202 | if !strings.Contains(body, want) { |
| 203 | t.Fatalf("holding page lacks %q for studio audio: %s", want, body) |
| 204 | } |
| 205 | } |
| 206 | // The sentence has to say what is being turned off. "Studio audio" |
| 207 | // alone is a name, not an explanation, and someone reaching for it |
| 208 | // on a laptop should be told why not before they hear the echo. |
| 209 | for _, want := range []string{"echo cancellation", "noise suppression", "automatic gain"} { |
| 210 | if !strings.Contains(body, want) { |
| 211 | t.Fatalf("studio audio's help sentence does not mention %q: %s", want, body) |
| 212 | } |
| 213 | } |
| 214 | } |
| 215 | |