| 1 | package memoriatest |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "regexp" |
| 11 | "strconv" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | ) |
| 15 | |
| 16 | // The tests in this file cover the asynchronous half of upload: a drop |
| 17 | // posts each file on its own, in the background, and a tray in the corner |
| 18 | // says how each one went. drop_test.go covers the drag itself and the |
| 19 | // scriptless form underneath; read its header first, including the CDP |
| 20 | // recipe for driving a real drop, because Go still cannot run the script. |
| 21 | // |
| 22 | // The safety argument for this feature is one sentence: there is ONE |
| 23 | // handler. The tray's request differs from the form's only in an Accept |
| 24 | // header, so the caps, the NUL sniff, the whole-body limit that keeps |
| 25 | // parts off the disk and the same-origin check are not "also applied" to |
| 26 | // it — they are the same lines of code, and |
| 27 | // TestTheJSONPathStillEnforcesEveryServerCheck exists to notice the day |
| 28 | // somebody adds a shortcut that skips them. |
| 29 | // |
| 30 | // What Go CANNOT prove here, and what was checked in a browser instead |
| 31 | // (Chromium 151, 2026-09-04, over CDP, recipe in drop_test.go's header): |
| 32 | // that the tray fills as files upload, that the bar stops claiming a |
| 33 | // percentage the moment the body is sent, that a refusal lands in its own |
| 34 | // file's row rather than as one sentence for the whole drop, that |
| 35 | // dismissing works, and that the scriptless page is unchanged. What the |
| 36 | // run found: five files dropped at once gave five rows; the three text |
| 37 | // files went "Waiting…" then "Uploading …%" then "Storing…" then |
| 38 | // "Stored"; scan.png and blank.txt settled immediately, before any |
| 39 | // request, with "scan.png is not text" and "blank.txt is empty" in their |
| 40 | // own rows; the summary line read "Stored 3 documents. 2 refused: |
| 41 | // scan.png is not text; blank.txt is empty." and the stale notice |
| 42 | // appeared with its reload link; the page never navigated; the document |
| 43 | // list below was still showing its old three rows until the link was |
| 44 | // followed, which is the point of not reloading under someone. |
| 45 | // |
| 46 | // THAT LAST CLAUSE IS NO LONGER TRUE, and it is left standing because it |
| 47 | // is the history this branch changed: the list is refreshed in place now. |
| 48 | // It was re-checked the same way on 2026-09-05, Chromium 151, and what |
| 49 | // that run showed is recorded in refresh_test.go's header — including the |
| 50 | // two runs that matter most, where the refresh could not happen and the |
| 51 | // notice and the link above came back exactly as they are described here. |
| 52 | |
| 53 | // trayReply is the JSON contract, spelled out here so a change to it |
| 54 | // fails a test rather than a browser. The tags are the wire names. |
| 55 | type trayReply struct { |
| 56 | Stored int `json:"stored"` |
| 57 | Refused []string `json:"refused"` |
| 58 | Message string `json:"message"` |
| 59 | Kind string `json:"kind"` |
| 60 | } |
| 61 | |
| 62 | // readReply decodes one judgement, and refuses to decode anything that is |
| 63 | // not one: a test that quietly treated an HTML redirect as an empty reply |
| 64 | // would pass against a JSON path that does not exist. |
| 65 | func readReply(t *testing.T, rec *httptest.ResponseRecorder) trayReply { |
| 66 | t.Helper() |
| 67 | if rec.Code != http.StatusOK { |
| 68 | t.Fatalf("the JSON path answered %d, want 200. The tray reads any non-200 as \"no "+ |
| 69 | "answer arrived\" and tells the person that file's fate is unknown, so a "+ |
| 70 | "judged submission must not come back as one:\n%s", rec.Code, rec.Body.String()) |
| 71 | } |
| 72 | if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { |
| 73 | t.Fatalf("the JSON path answered Content-Type %q; the tray parses the body and would "+ |
| 74 | "fall back to \"no answer\"", ct) |
| 75 | } |
| 76 | var reply trayReply |
| 77 | if err := json.Unmarshal(rec.Body.Bytes(), &reply); err != nil { |
| 78 | t.Fatalf("the JSON path did not answer JSON (%v):\n%s", err, rec.Body.String()) |
| 79 | } |
| 80 | return reply |
| 81 | } |
| 82 | |
| 83 | // The asynchronous path is the same judgement in a different shape. |
| 84 | // |
| 85 | // What could still drift, given one handler, is the WORDS. A file posted |
| 86 | // both ways must come back with the identical sentence, or the tray and |
| 87 | // the scriptless flash have begun to disagree about the same file and |
| 88 | // nobody can tell which of them is right. |
| 89 | // |
| 90 | // The comparison is between two apps, not two posts at one app: the same |
| 91 | // filename twice at one app is an update, not a second document, and the |
| 92 | // second sentence would be composed against a different corpus. |
| 93 | // |
| 94 | // MUTATION-TESTED 2026-09-04, each reverted: |
| 95 | // |
| 96 | // (a) the `wantsJSON` branch deleted from createDocument, so the tray |
| 97 | // gets the redirect → "the JSON path answered 303, want 200" |
| 98 | // (b) `Message: msg` → `Message: c.storedPart(stored)` in |
| 99 | // createDocument's JSON branch — the plausible "the tray shows |
| 100 | // refusals in its rows, the sentence only needs the count" edit → |
| 101 | // "the two paths say different things about scan.png", printing |
| 102 | // both: the JSON one had dropped the names |
| 103 | // (c) `Stored: stored` → `Stored: stored + len(refused)` → "the JSON |
| 104 | // path says 1 stored but the document list holds 0" |
| 105 | func TestTheJSONPathIsTheSameJudgementInADifferentShape(t *testing.T) { |
| 106 | cases := []struct { |
| 107 | name, body string |
| 108 | stored int |
| 109 | }{ |
| 110 | {"revenue.txt", "Revenue was 100 across the first region we serve.", 1}, |
| 111 | {"scan.png", "\x89PNG\x00\x00\x00 binary payload that is not text", 0}, |
| 112 | {"blank.txt", " \n ", 0}, |
| 113 | } |
| 114 | // Vacuity: both outcomes have to be represented, or this proves the |
| 115 | // agreement for successes only and misses the half that matters. |
| 116 | good, bad := 0, 0 |
| 117 | for _, c := range cases { |
| 118 | if c.stored == 1 { |
| 119 | good++ |
| 120 | } else { |
| 121 | bad++ |
| 122 | } |
| 123 | } |
| 124 | if good == 0 || bad == 0 { |
| 125 | t.Fatal("the fixtures cover only one outcome; the comparison below would prove half of it") |
| 126 | } |
| 127 | |
| 128 | for _, c := range cases { |
| 129 | t.Run(c.name, func(t *testing.T) { |
| 130 | viaForm := newApp(t) |
| 131 | viaTray := newApp(t) |
| 132 | |
| 133 | said := outcome(t, viaForm, submit(t, viaForm, "", "", file{c.name, c.body})) |
| 134 | reply := readReply(t, submitJSON(t, viaTray, file{c.name, c.body})) |
| 135 | |
| 136 | if reply.Message != said { |
| 137 | t.Errorf("the two paths say different things about %s.\n form: %q\n tray: %q\n"+ |
| 138 | "One sentence for one judgement, or the tray is copy nobody reviewed.", |
| 139 | c.name, said, reply.Message) |
| 140 | } |
| 141 | // The count is the number of documents that exist, checked |
| 142 | // against the list rather than against itself. |
| 143 | if n := storedCount(t, viaTray); reply.Stored != n { |
| 144 | t.Errorf("the JSON path says %d stored but the document list holds %d", |
| 145 | reply.Stored, n) |
| 146 | } |
| 147 | if reply.Stored != c.stored { |
| 148 | t.Errorf("%s: the JSON path stored %d, want %d", c.name, reply.Stored, c.stored) |
| 149 | } |
| 150 | // A refusal is NAMED, in the list the tray puts beside the |
| 151 | // row. A count with no name leaves a person guessing. |
| 152 | if c.stored == 0 { |
| 153 | if len(reply.Refused) != 1 { |
| 154 | t.Fatalf("%s was not stored and the reply names %d refusals: %v", |
| 155 | c.name, len(reply.Refused), reply.Refused) |
| 156 | } |
| 157 | if !strings.Contains(reply.Refused[0], c.name) { |
| 158 | t.Errorf("the refusal does not name the file it refused: %q", reply.Refused[0]) |
| 159 | } |
| 160 | if reply.Kind != "danger" { |
| 161 | t.Errorf("%s was refused but the reply is kind %q", c.name, reply.Kind) |
| 162 | } |
| 163 | } else if len(reply.Refused) != 0 { |
| 164 | t.Errorf("%s was stored but the reply refuses %v", c.name, reply.Refused) |
| 165 | } |
| 166 | }) |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | // The JSON path sets no flash cookie. |
| 171 | // |
| 172 | // The cookie is one-shot and it OUTLIVES the request that set it. The |
| 173 | // tray has already said what happened, out loud, in the row the file |
| 174 | // belongs to — so a cookie set here would ambush whatever page the person |
| 175 | // opens next with a notice about a drop that finished ten minutes ago, |
| 176 | // attached to nothing on the screen. And it is the sentence that carries |
| 177 | // filenames. |
| 178 | // |
| 179 | // The scriptless half is asserted first and deliberately: without it this |
| 180 | // test passes just as well against an app that has stopped setting the |
| 181 | // cookie at all, which would silently delete the naming half of the |
| 182 | // scriptless outcome. |
| 183 | // |
| 184 | // MUTATION-TESTED 2026-09-04, each reverted: |
| 185 | // |
| 186 | // (a) `flash.Set` moved above the `wantsJSON` branch in createDocument |
| 187 | // → "a judged submission on the JSON path set 1 cookie(s)" |
| 188 | // (b) the same in refuse() → the same failure, second case |
| 189 | // (c) `flash.Set` deleted from the HTML path → "the form path set no |
| 190 | // cookie" — the vacuity guard doing its job |
| 191 | func TestTheJSONPathSetsNoFlashCookie(t *testing.T) { |
| 192 | h := newApp(t) |
| 193 | if n := len(submit(t, h, "", "", file{"scan.png", "\x00\x00 not text"}).Result().Cookies()); n == 0 { |
| 194 | t.Fatal("the form path set no cookie; this test would pass against an app that had " + |
| 195 | "stopped naming refusals altogether, which is what the cookie exists for") |
| 196 | } |
| 197 | |
| 198 | for _, c := range []struct { |
| 199 | what string |
| 200 | rec *httptest.ResponseRecorder |
| 201 | }{ |
| 202 | // A judged submission… |
| 203 | {"a judged submission", submitJSON(t, newApp(t), file{"notes.txt", "Revenue was 100 across the region."})}, |
| 204 | // …and a whole-submission refusal, which goes through refuse() |
| 205 | // and is the half that gets forgotten. |
| 206 | {"a whole-submission refusal", submitJSON(t, newApp(t), tooManyFiles(t, newApp(t))...)}, |
| 207 | } { |
| 208 | if n := len(c.rec.Result().Cookies()); n != 0 { |
| 209 | t.Errorf("%s on the JSON path set %d cookie(s): %v. The tray has already said this "+ |
| 210 | "on screen; a one-shot cookie would say it again over the next page the "+ |
| 211 | "person opens.", c.what, n, c.rec.Result().Cookies()) |
| 212 | } |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | // tooManyFiles builds a drop one file over the cap the page advertises. |
| 217 | func tooManyFiles(t *testing.T, h http.Handler) []file { |
| 218 | t.Helper() |
| 219 | n := atoi(t, dropZoneAttr(t, get(t, h, "/add").Body.String(), "data-max-files")) |
| 220 | if n < 2 || n > 200 { |
| 221 | t.Fatalf("data-max-files is %d; the drop built here would prove nothing", n) |
| 222 | } |
| 223 | files := make([]file, 0, n+1) |
| 224 | for i := 0; i <= n; i++ { |
| 225 | files = append(files, file{fmt.Sprintf("over-%02d.txt", i), fmt.Sprintf("Passage %d about revenue.", i)}) |
| 226 | } |
| 227 | return files |
| 228 | } |
| 229 | |
| 230 | // Every server-side check still applies on the JSON path. |
| 231 | // |
| 232 | // It applies because it is the same handler, and this test exists to |
| 233 | // notice the day that stops being true — the obvious future shortcut is a |
| 234 | // branch near the top of createDocument that answers the tray without |
| 235 | // walking the rest. So each check is aimed at the JSON path directly: the |
| 236 | // per-file cap, the NUL sniff, and both submission caps, which the tray |
| 237 | // itself never triggers (it sends one file per request) and which are |
| 238 | // therefore exactly the ones a shortcut would take with it. |
| 239 | // |
| 240 | // MUTATION-TESTED 2026-09-04, each reverted: |
| 241 | // |
| 242 | // (a) the shortcut itself — `if wantsJSON(r) { writeOutcome(w, |
| 243 | // uploadOutcome{Stored: 1, Message: c.StoredOne, Kind: "good"}); |
| 244 | // return }` inserted immediately after ParseMultipartForm → |
| 245 | // THREE of the four subtests fail: "a file 1 byte over the cap was |
| 246 | // answered stored=1", "a NUL inside the sniff window was answered |
| 247 | // stored=1", and "the reply to an over-cap drop is stored=1 |
| 248 | // kind=\"good\"". |
| 249 | // |
| 250 | // The total-size subtest stayed GREEN, and that is worth writing |
| 251 | // down rather than quietly dropping: the body cap is |
| 252 | // http.MaxBytesReader, which fires INSIDE ParseMultipartForm — |
| 253 | // before the insertion point — so that drop was already refused |
| 254 | // by the time the shortcut ran. It is honest protection, not a |
| 255 | // hole, but this test does not prove it: a shortcut placed one |
| 256 | // line higher would take that check too and only three subtests |
| 257 | // would notice. Move the insertion above the MaxBytesReader and |
| 258 | // all four fail. |
| 259 | // (b) `case !looksLikeText(raw)` deleted from readUpload → "a NUL |
| 260 | // inside the sniff window was answered stored=1" and "1 documents |
| 261 | // exist after a binary upload on the JSON path" |
| 262 | func TestTheJSONPathStillEnforcesEveryServerCheck(t *testing.T) { |
| 263 | page := get(t, newApp(t), "/add").Body.String() |
| 264 | capBytes := atoi(t, dropZoneAttr(t, page, "data-max-bytes")) |
| 265 | window := atoi(t, dropZoneAttr(t, page, "data-sniff-bytes")) |
| 266 | if capBytes < 1024 || window < 16 { |
| 267 | t.Fatalf("the advertised cap (%d) or sniff window (%d) is too small for these fixtures "+ |
| 268 | "to prove anything", capBytes, window) |
| 269 | } |
| 270 | filler := "The quarterly figure was recorded in full and then again.\n\n" |
| 271 | over := strings.Repeat(filler, capBytes/len(filler)+2)[:capBytes+1] |
| 272 | |
| 273 | t.Run("the per-file cap", func(t *testing.T) { |
| 274 | h := newApp(t) |
| 275 | reply := readReply(t, submitJSON(t, h, file{"over.txt", over})) |
| 276 | if reply.Stored != 0 { |
| 277 | t.Errorf("a file 1 byte over the cap was answered stored=%d", reply.Stored) |
| 278 | } |
| 279 | if n := storedCount(t, h); n != 0 { |
| 280 | t.Errorf("%d documents exist after an over-cap upload on the JSON path", n) |
| 281 | } |
| 282 | if !strings.Contains(reply.Message, "over.txt") { |
| 283 | t.Errorf("the refusal does not name the file: %q", reply.Message) |
| 284 | } |
| 285 | }) |
| 286 | |
| 287 | t.Run("the NUL sniff", func(t *testing.T) { |
| 288 | h := newApp(t) |
| 289 | body := strings.Repeat("a", window-1) + "\x00" + strings.Repeat("b", 64) |
| 290 | reply := readReply(t, submitJSON(t, h, file{"inside.txt", body})) |
| 291 | if reply.Stored != 0 { |
| 292 | t.Errorf("a NUL inside the sniff window was answered stored=%d", reply.Stored) |
| 293 | } |
| 294 | if n := storedCount(t, h); n != 0 { |
| 295 | t.Errorf("%d documents exist after a binary upload on the JSON path", n) |
| 296 | } |
| 297 | }) |
| 298 | |
| 299 | t.Run("the file-count cap", func(t *testing.T) { |
| 300 | h := newApp(t) |
| 301 | files := tooManyFiles(t, h) |
| 302 | reply := readReply(t, submitJSON(t, h, files...)) |
| 303 | if n := storedCount(t, h); n != 0 { |
| 304 | t.Errorf("%d files, one over the cap, stored %d documents on the JSON path. A "+ |
| 305 | "submission cap refuses the drop whole or it is not a submission cap.", |
| 306 | len(files), n) |
| 307 | } |
| 308 | if reply.Stored != 0 || reply.Kind != "danger" { |
| 309 | t.Errorf("the reply to an over-cap drop is stored=%d kind=%q", reply.Stored, reply.Kind) |
| 310 | } |
| 311 | if !strings.Contains(reply.Message, strconv.Itoa(len(files))) { |
| 312 | t.Errorf("the refusal does not say how many were dropped (%d): %q", len(files), reply.Message) |
| 313 | } |
| 314 | }) |
| 315 | |
| 316 | t.Run("the total-size cap", func(t *testing.T) { |
| 317 | h := newApp(t) |
| 318 | total := atoi(t, dropZoneAttr(t, get(t, h, "/add").Body.String(), "data-max-total-bytes")) |
| 319 | if total <= capBytes { |
| 320 | t.Fatalf("the advertised total (%d) is not larger than the per-file cap (%d)", total, capBytes) |
| 321 | } |
| 322 | one := strings.Repeat(filler, capBytes/len(filler)+1)[:capBytes] |
| 323 | files := make([]file, 0, total/capBytes+1) |
| 324 | for i := 0; len(files)*capBytes <= total; i++ { |
| 325 | files = append(files, file{fmt.Sprintf("big-%02d.txt", i), one}) |
| 326 | } |
| 327 | reply := readReply(t, submitJSON(t, h, files...)) |
| 328 | if n := storedCount(t, h); n != 0 { |
| 329 | t.Errorf("a drop of %d bytes, over the advertised %d, stored %d documents on the "+ |
| 330 | "JSON path", len(files)*capBytes, total, n) |
| 331 | } |
| 332 | if reply.Stored != 0 { |
| 333 | t.Errorf("the reply to an over-total drop is stored=%d", reply.Stored) |
| 334 | } |
| 335 | }) |
| 336 | } |
| 337 | |
| 338 | // Every word the tray writes into the page comes from Go. |
| 339 | // |
| 340 | // The tray is the second place this app says what happened to a file, and |
| 341 | // the first — the scriptless flash — is composed in handlers.go. A |
| 342 | // sentence typed into drop.js would be copy nobody reviewed, in a file no |
| 343 | // copy pass reads, saying something subtly different from what the same |
| 344 | // person would have seen with scripts off. |
| 345 | // |
| 346 | // So: every string the script writes is published on the drop zone as a |
| 347 | // data attribute, the script reads every one of them by key, and NONE of |
| 348 | // their values appears in the script itself. The third check is the one |
| 349 | // with teeth — the first two would both pass against a script that read |
| 350 | // the attributes and then ignored them. |
| 351 | // |
| 352 | // MUTATION-TESTED 2026-09-04, each reverted: |
| 353 | // |
| 354 | // (a) `row.phase.textContent = msg.storing` → a literal in drop.js → |
| 355 | // "the drop script spells out the copy "Storing…", which is |
| 356 | // published as data-msg-storing" |
| 357 | // (b) "storing" removed from drop.js's key list, leaving the attribute |
| 358 | // rendered → "the page publishes data-msg-storing and the drop |
| 359 | // script never reads it; a sentence nothing reads is a sentence |
| 360 | // that has already drifted" |
| 361 | // (c) `ReasonBinary: "that file is not text"` — the name taken out of |
| 362 | // a clause → "data-reason-binary does not contain {name}" |
| 363 | // |
| 364 | // It caught one for real while being written, which is worth recording: |
| 365 | // a COMMENT added to drop.js used the word the page publishes as |
| 366 | // data-msg-row-stored, and the test went red on the untouched build. |
| 367 | // That is the direction of error this scan is meant to have — it reads |
| 368 | // the file whole, comments included — and the comment was reworded. A |
| 369 | // scan that stripped comments first would risk skipping a real string. |
| 370 | func TestEveryWordTheTrayWritesComesFromGo(t *testing.T) { |
| 371 | h := newApp(t) |
| 372 | tag := dropZoneTag(t, get(t, h, "/add").Body.String()) |
| 373 | src := servedDropScript(t, h) |
| 374 | |
| 375 | pairs := regexp.MustCompile(`\bdata-(msg|reason)-([a-z-]+)="([^"]*)"`).FindAllStringSubmatch(tag, -1) |
| 376 | // Vacuity, and it has to be here: a pattern that stopped matching |
| 377 | // would leave this loop empty and the test green over a script full |
| 378 | // of hand-typed sentences. |
| 379 | if len(pairs) < 12 { |
| 380 | t.Fatalf("only %d published messages were found on the drop zone; the tray alone needs "+ |
| 381 | "more than that, so this pattern has stopped matching and the test proves "+ |
| 382 | "nothing:\n%s", len(pairs), tag) |
| 383 | } |
| 384 | |
| 385 | for _, p := range pairs { |
| 386 | attr, key, value := "data-"+p[1]+"-"+p[2], p[2], unescape(p[3]) |
| 387 | |
| 388 | // The script reads it by key: it builds the attribute name from |
| 389 | // the prefix and the key, so the key is what appears in there. |
| 390 | if !strings.Contains(src, `"`+key+`"`) { |
| 391 | t.Errorf("the page publishes %s and the drop script never reads it; a sentence "+ |
| 392 | "nothing reads is a sentence that has already drifted from the one the "+ |
| 393 | "scriptless page shows", attr) |
| 394 | } |
| 395 | // And the script does not carry the words themselves. |
| 396 | if value != "" && strings.Contains(src, value) { |
| 397 | t.Errorf("the drop script spells out the copy %q, which is published as %s. Two "+ |
| 398 | "copies of one sentence means one of them is out of date and nobody can "+ |
| 399 | "tell which — and only one of them was ever reviewed.", value, attr) |
| 400 | } |
| 401 | |
| 402 | // Every REASON clause names its own file, and the tray's layout |
| 403 | // now depends on it. A refused row drops its separate name label |
| 404 | // and lets the clause be the row, because rendering both put |
| 405 | // |
| 406 | // scan.png |
| 407 | // scan.png is not text |
| 408 | // |
| 409 | // on the screen — the same shape as the search result that |
| 410 | // repeated its own heading, found by looking at the page and |
| 411 | // invisible to every test here. A clause that stopped naming its |
| 412 | // file would leave that row anonymous: a refusal with nothing to |
| 413 | // attach it to, which is the silent failure wearing a red tone. |
| 414 | if p[1] == "reason" && !strings.Contains(value, "{name}") { |
| 415 | t.Errorf("%s does not contain {name}: %q. Every reason clause names the file it is "+ |
| 416 | "about — the tray removes a refused row's name label because the clause "+ |
| 417 | "carries it, and the outcome sentence lists these clauses for a whole drop "+ |
| 418 | "where nothing else says which file each one belongs to.", attr, value) |
| 419 | } |
| 420 | } |
| 421 | } |
| 422 | |
| 423 | // The tray is a live region, and the lesson the note taught applies to it |
| 424 | // unchanged: a region unhidden in the same tick as its first message is |
| 425 | // commonly not announced at all. So the tray enters the accessibility |
| 426 | // tree at the ready step, empty, and stays there; the panel paints |
| 427 | // nothing until a row is added. |
| 428 | // |
| 429 | // The other half is what this design could get wrong in the opposite |
| 430 | // direction. Five files uploading is five rows whose text changes several |
| 431 | // times a second, and a live region over THAT is a screen reader nobody |
| 432 | // can use. So exactly one element in the tray is live — the status line, |
| 433 | // written once, when everything has settled — and the row list must not |
| 434 | // be. |
| 435 | // |
| 436 | // MUTATION-TESTED 2026-09-04, each reverted: |
| 437 | // |
| 438 | // (a) `tray.hidden = false` moved into showTray() → "drop.js unhides |
| 439 | // the tray before the ready step" |
| 440 | // (b) deleting it → "drop.js never unhides the tray" |
| 441 | // (c) `aria-live="polite"` added to <ul rst-tray-list> in index.html → |
| 442 | // "the row list is a live region" |
| 443 | // (d) `role="status" aria-live="polite"` deleted from the status |
| 444 | // paragraph → "the tray status is not a polite live region" |
| 445 | // (e) the [rst-tray-status]:empty rule deleted from memoria.css → |
| 446 | // "memoria.css has no :empty rule for the tray status" |
| 447 | func TestTheTrayIsALiveRegionThatExistsBeforeItSpeaks(t *testing.T) { |
| 448 | h := newApp(t) |
| 449 | src := servedDropScript(t, h) |
| 450 | |
| 451 | const anchor = "and only now does the zone exist" |
| 452 | at := strings.Index(src, anchor) |
| 453 | if at < 0 { |
| 454 | t.Fatalf("drop.js no longer carries the %q marker, so this test cannot tell the ready "+ |
| 455 | "step from the rest of the file and proves nothing", anchor) |
| 456 | } |
| 457 | unhide := regexp.MustCompile(`tray\.hidden\s*=`).FindAllStringIndex(src, -1) |
| 458 | if len(unhide) == 0 { |
| 459 | t.Fatal("drop.js never unhides the tray; every upload then happens invisibly and every " + |
| 460 | "refusal is silent") |
| 461 | } |
| 462 | if len(unhide) > 1 { |
| 463 | t.Errorf("drop.js assigns tray.hidden in %d places. One is the ready step; any other "+ |
| 464 | "can only be unhiding it later, beside its first message — the pattern that "+ |
| 465 | "does not announce.", len(unhide)) |
| 466 | } |
| 467 | for _, u := range unhide { |
| 468 | if u[0] < at { |
| 469 | t.Errorf("drop.js unhides the tray before the ready step (byte %d, marker at %d). "+ |
| 470 | "The live region must enter the accessibility tree empty and early.", u[0], at) |
| 471 | } |
| 472 | } |
| 473 | |
| 474 | body := get(t, h, "/add").Body.String() |
| 475 | stat := regexp.MustCompile(`<p rst-tray-status[^>]*>`).FindString(body) |
| 476 | if stat == "" { |
| 477 | t.Fatalf("no tray status element on the page:\n%s", body) |
| 478 | } |
| 479 | if !strings.Contains(stat, `aria-live="polite"`) || !strings.Contains(stat, `role="status"`) { |
| 480 | t.Errorf("the tray status is not a polite live region, so unhiding it early buys "+ |
| 481 | "nothing and a refused file is never announced: %s", stat) |
| 482 | } |
| 483 | rows := regexp.MustCompile(`<ul rst-tray-list[^>]*>`).FindString(body) |
| 484 | if rows == "" { |
| 485 | t.Fatalf("no tray row list on the page:\n%s", body) |
| 486 | } |
| 487 | for _, loud := range []string{"aria-live", `role="status"`, `role="alert"`, "aria-atomic"} { |
| 488 | if strings.Contains(rows, loud) { |
| 489 | t.Errorf("the row list carries %s: %s\nA five-file drop would announce every "+ |
| 490 | "percentage of every row several times a second, which is a screen reader "+ |
| 491 | "nobody can use. Only the status line announces, and only once.", loud, rows) |
| 492 | } |
| 493 | } |
| 494 | |
| 495 | css, err := os.ReadFile(filepath.Join("..", "memoria", "static", "memoria.css")) |
| 496 | if err != nil { |
| 497 | t.Fatal(err) |
| 498 | } |
| 499 | if !regexp.MustCompile(`\[rst-tray-status\]:empty\s*\{[^}]*margin\s*:\s*0`).Match(css) { |
| 500 | t.Error("memoria.css has no :empty rule for the tray status zeroing its margin. It is " + |
| 501 | "unhidden from the moment the script runs, so an empty paragraph leaves a stray " + |
| 502 | "half-line pinned in the corner of every page — and the obvious fix for that is " + |
| 503 | "to hide the region again, which is the bug.") |
| 504 | } |
| 505 | } |
| 506 | |
| 507 | // The tray is not part of the form, and a scriptless page does not have |
| 508 | // one. |
| 509 | // |
| 510 | // Both halves matter. Inside the form it would be markup the scriptless |
| 511 | // submit carries around for nothing, and a <button> inside a form is a |
| 512 | // SUBMIT button unless something says otherwise — a Dismiss control that |
| 513 | // posted the upload form is the kind of bug that ships. Unhidden it would |
| 514 | // pin an empty panel to the corner of a page that cannot upload anything |
| 515 | // in the background at all. |
| 516 | // |
| 517 | // MUTATION-TESTED 2026-09-04, each reverted: |
| 518 | // |
| 519 | // (a) the tray moved inside the upload form in index.html → "the tray |
| 520 | // is inside the upload form" |
| 521 | // (b) `hidden` deleted from the tray → "the tray is visible before any |
| 522 | // script has run" |
| 523 | // (c) `type="button"` deleted from the dismiss control → "the tray's |
| 524 | // dismiss control has no type=button, so it is a submit button" |
| 525 | // (d) writing rst-tray="open" in the template → "the tray is |
| 526 | // pre-marked open in the template" |
| 527 | func TestTheTrayIsOutsideTheFormAndAbsentWithoutScript(t *testing.T) { |
| 528 | body := get(t, newApp(t), "/add").Body.String() |
| 529 | |
| 530 | i := strings.Index(body, `action="/documents"`) |
| 531 | if i < 0 { |
| 532 | t.Fatalf("no upload form on the page:\n%s", body) |
| 533 | } |
| 534 | j := strings.Index(body[i:], "</form>") |
| 535 | if j < 0 { |
| 536 | t.Fatal("the upload form never closes") |
| 537 | } |
| 538 | form := body[i : i+j] |
| 539 | |
| 540 | tray := strings.Index(body, "<div rst-tray") |
| 541 | if tray < 0 { |
| 542 | t.Fatalf("no tray on the page:\n%s", body) |
| 543 | } |
| 544 | if strings.Contains(form, "<div rst-tray") { |
| 545 | t.Error("the tray is inside the upload form. It is not something the form submits, " + |
| 546 | "and a control inside a form is one attribute away from submitting it.") |
| 547 | } |
| 548 | |
| 549 | close := strings.Index(body[tray:], ">") |
| 550 | if close < 0 { |
| 551 | t.Fatal("the tray tag never closes") |
| 552 | } |
| 553 | tag := body[tray : tray+close+1] |
| 554 | if !regexp.MustCompile(`(^|\s)hidden(\s|>|=)`).MatchString(tag) { |
| 555 | t.Errorf("the tray is visible before any script has run, so a page that cannot upload "+ |
| 556 | "anything in the background still shows the panel that reports it: %s", tag) |
| 557 | } |
| 558 | if strings.Contains(tag, `rst-tray="`) { |
| 559 | t.Errorf("the tray is pre-marked open in the template: %s\nThe panel paints on "+ |
| 560 | "rst-tray=\"open\", which only the script may write — otherwise a page whose "+ |
| 561 | "script failed pins an empty box to the corner.", tag) |
| 562 | } |
| 563 | |
| 564 | dismiss := regexp.MustCompile(`<button[^>]*rst-tray-dismiss[^>]*>`).FindString(body) |
| 565 | if dismiss == "" { |
| 566 | t.Fatalf("the tray has no dismiss control; it would stay on screen forever:\n%s", body[tray:]) |
| 567 | } |
| 568 | if !strings.Contains(dismiss, `type="button"`) { |
| 569 | t.Errorf("the tray's dismiss control has no type=button, so it is a submit button: %s", |
| 570 | dismiss) |
| 571 | } |
| 572 | } |
| 573 | |