oficina / memoria Public

Clone
git clone https://amadan.net/oficina/memoria

Plain git — no account needed to clone.

Download

Download this file

1package memoriatest
2
3import (
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.
55type 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.
65func 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"
105func 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
191func 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.
217func 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"
262func 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.
370func 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"
447func 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"
527func 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