| 1 | package memoriatest |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/http/httptest" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | "golang.org/x/net/html" |
| 10 | ) |
| 11 | |
| 12 | // The tests in this file cover the list that stops being stale. |
| 13 | // |
| 14 | // Since 2026-09-29 the drop zone and the tray live on /add, and the |
| 15 | // document list stays on the index. So the page drop.js re-fetches is /add, |
| 16 | // and its only refreshable region is the passage count in the footer. The |
| 17 | // browser runs recorded below were made on the index, when the drop zone |
| 18 | // was there; they are kept as the record of how the swap itself behaves. |
| 19 | // |
| 20 | // A drop posts each file in the background and never navigates, so |
| 21 | // everything the server rendered before the drop was still on the screen |
| 22 | // afterwards: the document list, the search results, the passage count in |
| 23 | // the footer. The tray said so and offered a link. It now asks the server |
| 24 | // for the same page again — the page the reader is ON, query string and |
| 25 | // all — and puts the freshly rendered regions in place of the old ones. |
| 26 | // |
| 27 | // The server stays the only thing that renders a document row. Nothing in |
| 28 | // the browser composes markup; the regions come out of the answer as nodes |
| 29 | // and go in as nodes. Two renderers for one row is the drift this codebase |
| 30 | // has already been bitten by twice. |
| 31 | // |
| 32 | // Go cannot run the script, so what these tests hold is the half a Go test |
| 33 | // can hold, and it is the half the design rests on: WHERE the regions are. |
| 34 | // A swap replaces its element whole, so a region reaching one tag too far |
| 35 | // would destroy the paste box someone is typing in, or the drop zone, or |
| 36 | // the tray itself with rows still uploading in it. |
| 37 | // |
| 38 | // The other half was checked in a real browser, because a Go test cannot |
| 39 | // see a list change. drop_test.go's header carries the CDP recipe; |
| 40 | // Chromium 151, 2026-09-05, against tools/drive, with the viewport at |
| 41 | // 780x493 (drop coordinates have to be INSIDE it — a drop dispatched at |
| 42 | // y=700 in that window does nothing at all, silently, which cost the |
| 43 | // first run of this): |
| 44 | // |
| 45 | // - Three files dropped on the front page, one of them binary, with |
| 46 | // "half-typed, and it must survive" in the paste box first. The tray |
| 47 | // showed three rows; the summary read "Stored 2 documents. 1 |
| 48 | // refused: scan.png is not text. The document list below is up to |
| 49 | // date."; location.href never changed; the table went from four rows |
| 50 | // to six and its heading from "4 documents" to "6 documents"; the |
| 51 | // footer went from 9 to 11 passages; the stale notice stayed hidden; |
| 52 | // the paste box still held its text. |
| 53 | // - The same drop while looking at "/?q=zebrafinch", with one file |
| 54 | // matching that search and one not. The results went from ["two"] to |
| 55 | // ["two","four"] and the heading from "1 result" to "2 results" — |
| 56 | // the non-matching file did NOT appear there, and both files did |
| 57 | // appear in the document list below, which the search does not |
| 58 | // filter. The address stayed "/?q=zebrafinch". |
| 59 | // - With the keyboard focus on a Delete button INSIDE the document |
| 60 | // list, a drop stored its file and did not swap: the notice appeared, |
| 61 | // the summary was "Stored 1 document." with no claim about the list, |
| 62 | // the table was unchanged, and document.activeElement was still that |
| 63 | // button. |
| 64 | // - With only the re-fetch blocked (Network.setBlockedURLs on the |
| 65 | // page's own address, so the POST still went through), the file |
| 66 | // stored, the summary was "Stored 1 document." with no claim about |
| 67 | // the list, the notice and the reload link came back, and the link's |
| 68 | // href was "/?q=zebrafinch" — the view the reader was on. Unblocking |
| 69 | // and following it showed the document really had landed. |
| 70 | // - The other way round, and the one the unconfirmed case turns on: |
| 71 | // only the UPLOAD blocked, so the answer was lost and the re-fetch |
| 72 | // got through. The row said "six.txt got no answer, so whether it |
| 73 | // was stored is unknown", the summary added "The document list below |
| 74 | // is up to date.", and the list — refreshed, three rows, unchanged — |
| 75 | // did not contain six.txt. Which is the truth about that file, |
| 76 | // arrived at by looking rather than by claiming. Nothing new is said |
| 77 | // about the file itself, because nothing here knows anything new |
| 78 | // about it. |
| 79 | // - Focus was on the search box throughout the runs above and stayed |
| 80 | // there: every region is outside it. |
| 81 | // - Zero console messages and zero log entries across all of it. |
| 82 | // - And the scriptless path, with Emulation.setScriptExecutionDisabled |
| 83 | // on: the zone was unmarked, the tray, hint and note all `hidden`, |
| 84 | // and choosing two files (DOM.setFileInputFiles) and pressing Store |
| 85 | // it navigated to "/?stored=2" with the list rendered fresh by the |
| 86 | // server. No script ran at any point in that. |
| 87 | |
| 88 | // The regions the tray swaps hold NOTHING the person is in the middle of |
| 89 | // using. |
| 90 | // |
| 91 | // A swap replaces an element whole: everything inside it is destroyed and |
| 92 | // rebuilt from the server's answer. So a region reaching one tag too far |
| 93 | // would take the half-typed paste box with it, or the drop zone, or the |
| 94 | // tray itself — including rows for files still uploading, whose outcome |
| 95 | // has not been said yet. |
| 96 | // |
| 97 | // Each page is checked for the things it protects BEFORE checking they |
| 98 | // sit outside every region. A page that had lost its form would otherwise |
| 99 | // pass the containment check for the wrong reason, which is what happened |
| 100 | // when the form moved to /add and this test kept reading the index. |
| 101 | // |
| 102 | // Names are checked for uniqueness because the script pairs each region |
| 103 | // on screen with the one of that name in the answer. |
| 104 | // |
| 105 | // MUTATION-TESTED 2026-09-29, each reverted: |
| 106 | // |
| 107 | // (a) rst-refresh="upload" added to /add's <section rst-section> → "the |
| 108 | // "upload" region on /add contains the upload form", and the same |
| 109 | // for the drop zone, the tray and the paste box. Astra's |
| 110 | // single-quoted rst-refresh='upload' passed the regex this test |
| 111 | // used to be; it reads the parsed page now, and is red on it too. |
| 112 | // (b) the rst-dropzone attribute deleted from add.html → "/add has no |
| 113 | // drop zone", the presence check, which fatals before any |
| 114 | // containment line runs |
| 115 | // (c) rst-refresh deleted from the footer count → "/add has no |
| 116 | // "passages" region" |
| 117 | // (d) Astra's: rst-refresh="title" on the title input, a region INSIDE |
| 118 | // the form rather than around it → "the "title" region on /add is |
| 119 | // inside the upload form" |
| 120 | func TestTheRefreshedRegionsCannotTakeTheFormOrTheTrayWithThem(t *testing.T) { |
| 121 | h := newApp(t) |
| 122 | submit(t, h, "", "", file{"revenue.txt", "Revenue was 100 across the first region we serve."}) |
| 123 | |
| 124 | type kept struct { |
| 125 | what string |
| 126 | match func(*html.Node) bool |
| 127 | } |
| 128 | for _, pg := range []struct { |
| 129 | path string |
| 130 | want []string |
| 131 | keep []kept |
| 132 | }{ |
| 133 | {"/add", []string{"passages"}, []kept{ |
| 134 | {"upload form", elementWith("form", "action", "/documents")}, |
| 135 | {"drop zone", hasAttr("rst-dropzone")}, |
| 136 | {"tray", hasAttr("rst-tray")}, |
| 137 | {"paste box", elementWith("textarea", "id", "body")}, |
| 138 | }}, |
| 139 | // search.js writes into the results region, so the search box |
| 140 | // must not be inside it. A searched page, so the region has |
| 141 | // something in it to over-reach with. |
| 142 | {"/?q=revenue", []string{"results", "passages"}, []kept{ |
| 143 | {"search form", hasAttr("rst-search")}, |
| 144 | {"search box", elementWith("input", "type", "search")}, |
| 145 | }}, |
| 146 | } { |
| 147 | doc := parsePage(t, get(t, h, pg.path).Body.String()) |
| 148 | protected := map[string][]*html.Node{} |
| 149 | for _, k := range pg.keep { |
| 150 | protected[k.what] = findAll(doc, k.match) |
| 151 | if len(protected[k.what]) == 0 { |
| 152 | t.Fatalf("%s has no %s, so the containment check below would pass for a page "+ |
| 153 | "without it", pg.path, k.what) |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | regions := findAll(doc, hasAttr("rst-refresh")) |
| 158 | seen := map[string]bool{} |
| 159 | for _, r := range regions { |
| 160 | name, _ := attrOf(r, "rst-refresh") |
| 161 | if name == "" { |
| 162 | t.Errorf("%s: a region carries an empty rst-refresh; the script pairs regions "+ |
| 163 | "by name and an unnamed one can be paired with anything", pg.path) |
| 164 | } |
| 165 | if seen[name] { |
| 166 | t.Errorf("%s: two regions are both named %q, so a swap puts one region's "+ |
| 167 | "content into the other's place", pg.path, name) |
| 168 | } |
| 169 | seen[name] = true |
| 170 | } |
| 171 | for _, want := range pg.want { |
| 172 | if !seen[want] { |
| 173 | t.Errorf("%s has no %q region", pg.path, want) |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | for _, r := range regions { |
| 178 | name, _ := attrOf(r, "rst-refresh") |
| 179 | for what, nodes := range protected { |
| 180 | for _, n := range nodes { |
| 181 | if within(n, r) { |
| 182 | t.Errorf("the %q region on %s contains the %s. A swap replaces the "+ |
| 183 | "region whole, so it would be destroyed and rebuilt from the server's "+ |
| 184 | "answer — a half-typed paste, a drop in progress, or a tray row whose "+ |
| 185 | "outcome has not been said yet, gone without a word.", name, pg.path, what) |
| 186 | } |
| 187 | // The other direction: a region INSIDE the form is a |
| 188 | // control the swap replaces with the server's empty one. |
| 189 | if within(r, n) && r != n { |
| 190 | t.Errorf("the %q region on %s is inside the %s, so a swap replaces "+ |
| 191 | "something in it with the server's copy — a typed title, say, with an "+ |
| 192 | "empty field.", name, pg.path, what) |
| 193 | } |
| 194 | } |
| 195 | } |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | count := textOf(region(t, parsePage(t, get(t, h, "/add").Body.String()), "passages")) |
| 200 | if !strings.Contains(count, "indexed") { |
| 201 | t.Errorf("the %q region does not hold the passage count: %q", "passages", count) |
| 202 | } |
| 203 | if strings.Contains(count, "no document ever") { |
| 204 | t.Errorf("the %q region has swallowed the egress claim beside it: %q. That sentence is "+ |
| 205 | "not derived from anything and has no business being re-fetched.", "passages", count) |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | // The re-fetch drop.js makes of /add brings the passage count up to date. |
| 210 | // |
| 211 | // It is made the way drop.js makes it, with the header the page tells the |
| 212 | // script to send. |
| 213 | // |
| 214 | // MUTATION-TESTED 2026-09-29, reverted: `Chunks: a.chunkCount()` → |
| 215 | // `Chunks: 0` in addContent → "the passage count on /add did not change |
| 216 | // after 1 document was stored". |
| 217 | func TestARefetchOfAddMovesThePassageCount(t *testing.T) { |
| 218 | h := newApp(t) |
| 219 | add := get(t, h, "/add").Body.String() |
| 220 | before := textOf(region(t, parsePage(t, add), "passages")) |
| 221 | if !strings.Contains(before, "indexed") { |
| 222 | t.Fatalf("/add's passage region holds no count to compare: %q", before) |
| 223 | } |
| 224 | |
| 225 | readReply(t, submitJSON(t, h, file{"hosting.txt", "Hosting costs fell after the migration completed."})) |
| 226 | |
| 227 | req := httptest.NewRequest(http.MethodGet, "/add", nil) |
| 228 | req.Header.Set(refreshHeaderOn(t, add), "1") |
| 229 | rec := httptest.NewRecorder() |
| 230 | h.ServeHTTP(rec, req) |
| 231 | if rec.Code != http.StatusOK { |
| 232 | t.Fatalf("the re-fetch of /add answered %d, want 200", rec.Code) |
| 233 | } |
| 234 | if after := textOf(region(t, parsePage(t, rec.Body.String()), "passages")); after == before { |
| 235 | t.Errorf("the passage count on /add did not change after 1 document was stored: %q. "+ |
| 236 | "The tray's re-fetch is the only thing that brings it up to date.", after) |
| 237 | } |
| 238 | } |
| 239 | |