feat: the document list updates itself, and the reviewed copy lands
The list under the upload form was left stale by background uploads, and the tray admitted it with a notice and a reload link. That was working around the problem rather than solving it. Asked for by the owner. When every row in a drop settles, the script re-reads the page's CURRENT url and swaps in the freshly rendered regions: the document list, the search results, and the footer's passage count. Re-reading the current url rather than a constructed "/" is what makes a filtered view behave — on /?q=revenue a newly stored document that does not match the query correctly does not appear in the results, and does appear in the list. Verified in a browser both ways. No markup is composed in JavaScript. Server-rendered nodes are parsed out of the answer and put into the DOM as nodes, so there is exactly one renderer for a document row and nothing to drift. The swap is all-or-nothing: a missing region, an unnamed region, or focus sitting inside one, and nothing is swapped at all. The stale notice is shown FIRST and hidden only by a swap that actually happened. So every failure — no answer, a non-200, a body that is not this page, no DOMParser — lands on today's behaviour by construction rather than through a second code path that could rot. A list that silently failed to update, with nothing to say so, would be worse than the notice it replaced. It also resolves the unconfirmed case honestly. A file whose answer was lost is neither claimed nor denied; the refreshed list is the server's own account, and the reader can look. Verified by blocking the upload: "Nothing was stored. 1 unconfirmed" — and the refreshed list did not contain it. The guard on the drop script is narrowed again rather than loosened. It now permits exactly two requests: one POST to the form's own action, and one GET of this page's own url. Both are matched against anchored patterns, both counted, and everything else still fails loudly. A mutation that fetches another address with NO string literal at all is caught on the shape alone. Copy from the review gate is applied verbatim, six edits. The owner's wording, not mine, including where it drops an em dash for a full stop. One string here has NOT been reviewed: "The document list below is up to date." It is new, it is the twin of the notice it replaces, and it needs its own short round before this ships.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bukt6PQkyrVsVQaSAU1Co4
7 files changed,
+643
−40
internal/memoria/handlers.go+23 −9internal/memoria/static/drop.js+145 −11internal/memoria/templates/index.html+32 −2internal/memoria/templates/layout.html+12 −1internal/memoriatest/drop_test.go+99 −17internal/memoriatest/refresh_test.go+325 −0internal/memoriatest/tray_test.go+7 −0
diff --git a/internal/memoria/handlers.go b/internal/memoria/handlers.go| index 321a16c..fc9fefd 100644 |
| --- a/internal/memoria/handlers.go |
| +++ b/internal/memoria/handlers.go |
| @@ -191,13 +191,26 @@ type dropZone struct { |
| // again because they travel into the summary sentence too. |
| RowStored string |
| - // Said once, when everything settles: the document list further down |
| - // the page was rendered before any of this and does not show it. The |
| - // page is NOT reloaded for you — someone mid-drop does not want the |
| + // Said once, when everything settles, and only while the list below |
| + // really is out of date: the tray now fetches this same page again and |
| + // puts the freshly rendered list in place of the old one, so these two |
| + // are what is left when that fetch does not happen or does not arrive. |
| + // The page is still NOT reloaded — someone mid-drop does not want the |
| // ground moving — so the offer is a link and the choice is theirs. |
| Stale string |
| Reload string |
| + // Fresh is Stale's twin and the only new sentence the refresh needed. |
| + // It is said when the list below HAS been replaced, in the tray's live |
| + // region, because that swap is otherwise silent: a screen reader is |
| + // told what was stored and then the list changes underneath with |
| + // nothing to hear. It is also the honest end of an unconfirmed file — |
| + // the answer was lost, and the list below is now the server's own, |
| + // which is the only place the truth about that file was ever going to |
| + // be. It claims nothing about any particular file, because nothing |
| + // here knows that. |
| + Fresh string |
| + |
| // The outcome sentence, both halves. The handler composes it after |
| // storing; the browser composes the refusal half before posting, out |
| // of the same strings — so a file refused in the page and the same |
| @@ -291,7 +304,7 @@ func newDropZone() dropZone { |
| MaxFiles: maxDropFiles, |
| SniffBytes: sniffBytes, |
| - TitleHint: "(optional — it titles the pasted text; each file keeps its own name)", |
| + TitleHint: "(Optional. Gives a title to pasted text)", |
| FileLabel: "…or upload text files", |
| // Derived, so the hint cannot come to disagree with the caps it |
| // describes — nor with the refusals, which say them in the same |
| @@ -299,12 +312,12 @@ func newDropZone() dropZone { |
| Hint: fill("…or drop them anywhere on this page. Up to {max} files at once, {limit} each and {total} in all.", |
| "{max}", fileLimit(), "{limit}", uploadLimit(), "{total}", dropLimit()), |
| - TooManyFiles: fill("That drop had {n} files. memoria takes {max} at a time, so none of them were stored — drop them in smaller batches.", |
| + TooManyFiles: fill("That drop had {n} files. memoria takes {max} at a time, so none were stored. Drop in smaller batches.", |
| "{max}", fileLimit()), |
| - TooMuchAtOnce: fill("That drop was more than {total} altogether. memoria takes {total} at a time, so none of it was stored — drop fewer files at once.", |
| + TooMuchAtOnce: fill("That drop was more than {total} altogether. memoria takes {total} at a time, so none was stored. Drop fewer files at once.", |
| "{total}", dropLimit()), |
| - Folder: "That is a folder, and memoria stores text files. Drop the files inside it instead.", |
| + Folder: "Memoria upload doesn't support folders. Drop the files inside it instead.", |
| // The browsers that refuse this are old, but the failure is |
| // silent — the files simply never arrive in the input — and a |
| // silent nothing is the one outcome that must not happen. |
| @@ -320,8 +333,9 @@ func newDropZone() dropZone { |
| Storing: "Storing…", |
| RowStored: "Stored", |
| - Stale: "The document list below was drawn before this and does not show it yet.", |
| + Stale: "Upload will appear in the list on next reload", |
| Reload: "Reload the page", |
| + Fresh: "The document list below is up to date.", |
| StoredOne: "Stored 1 document.", |
| StoredMany: "Stored {n} documents.", |
| @@ -347,7 +361,7 @@ func newDropZone() dropZone { |
| NothingSubmitted: "There was nothing to store — the box was empty and no file was chosen.", |
| Wrong: "That did not work.", |
| - DeleteFailed: "That document was not deleted — it is still stored.", |
| + DeleteFailed: "Document was not deleted. It is still stored.", |
| } |
| } |
diff --git a/internal/memoria/static/drop.js b/internal/memoria/static/drop.js| index 0728be1..7846af7 100644 |
| --- a/internal/memoria/static/drop.js |
| +++ b/internal/memoria/static/drop.js |
| @@ -22,6 +22,13 @@ |
| disk — is still checked, because it is still the same handler. |
| TestTheDropScriptPostsOnlyToTheFormsOwnAction holds that shape. |
| + It asks for one other thing, and only one: the page the reader is |
| + already looking at, so the list at the bottom can stop being the |
| + one drawn before the drop. That address is not written down here |
| + either — it is where the browser already is — and it is a GET of a |
| + page this person had already been served. The same test pins both |
| + shapes and permits no third. |
| + |
| 3. It never does less than it was asked and calls it done. A drop of |
| five files where one is a PDF sends four and NAMES the fifth with |
| the reason, in its own row; it never quietly reduces five to four. |
| @@ -38,14 +45,18 @@ |
| a default: a guessed cap would refuse files the server would have |
| taken, and a guessed sentence is copy nobody reviewed. |
| - What it deliberately does not do: reload the page for you when the |
| - uploads finish (someone mid-drop does not want the ground moving — |
| - the tray offers a link and the choice is theirs), read anything but |
| - the first sniff-window bytes of each file, take a folder (that needs |
| - a different API and is out of scope), or name a file anywhere but on |
| - your own screen. There is no console output in this file at all — a |
| - filename is somebody's content and this codebase keeps content out of |
| - logs. */ |
| + What it deliberately does not do: navigate, at any point, for any |
| + reason — someone mid-drop does not want the ground moving, and the |
| + half-typed paste box is theirs. When a drop settles it fetches this |
| + same page in the background and replaces the regions the server |
| + marked refreshable, which is a list that changes and nothing else. It |
| + does not build a document row itself either, ever: the server is the |
| + only thing in this app that renders one, and two renderers for one |
| + row drift. It does not read anything but the first sniff-window bytes |
| + of each file, does not take a folder (that needs a different API and |
| + is out of scope), and does not name a file anywhere but on your own |
| + screen. There is no console output in this file at all — a filename |
| + is somebody's content and this codebase keeps content out of logs. */ |
| (function () { |
| "use strict"; |
| @@ -105,7 +116,7 @@ |
| var keys = [ |
| "toomany", "toototal", "folder", "blocked", |
| "waiting", "uploading", "storing", "row-stored", |
| - "stored-one", "stored-many", "none-stored", |
| + "stored-one", "stored-many", "none-stored", "fresh", |
| "refused-one", "refused-many", "unsure-one", "unsure-many" |
| ]; |
| for (var i = 0; i < keys.length; i++) { |
| @@ -327,11 +338,20 @@ |
| return clauseList(head, unknown); |
| } |
| + // busy is "something is still going to happen". It is the guard on |
| + // announcing and the guard on refreshing the list, and it is one |
| + // function because those two must never disagree: a list fetched while |
| + // a file is still in the air describes a moment that has already |
| + // passed. |
| + function busy() { |
| + return judging > 0 || flying > 0 || queue.length > 0; |
| + } |
| + |
| // announce says what a whole drop did, once, when nothing is left in |
| // the air. Called after every settled upload and after judging, and it |
| // says nothing at all until there is nothing left to wait for. |
| function announce() { |
| - if (judging > 0 || flying > 0 || queue.length > 0) return; |
| + if (busy()) return; |
| if (batch.stored === 0 && batch.refused.length === 0 && |
| batch.lagging.length === 0 && batch.unsure.length === 0) return; |
| @@ -348,11 +368,125 @@ |
| // The document list further down was drawn before any of this. It is |
| // stale the moment one file lands — and equally stale when a file's |
| // fate is unknown, because the only way to find out is to look. |
| - if (batch.stored > 0 || batch.unsure.length > 0) stale.hidden = false; |
| + // |
| + // Said FIRST and then unsaid, rather than the other way round. The |
| + // fetch below may fail, may take a while, may never come back at |
| + // all, and in every one of those the list is out of date and this |
| + // notice is the truth; only an answer actually put on the page earns |
| + // taking it away. A list that had quietly not been refreshed and |
| + // said nothing about it would be worse than never having tried. |
| + if (batch.stored > 0 || batch.unsure.length > 0) { |
| + stale.hidden = false; |
| + refresh(); |
| + } |
| batch = newBatch(); |
| } |
| + // ---- the list below -------------------------------------------------- |
| + // |
| + // A background upload never navigates, so everything the server |
| + // rendered before the drop is still on the screen: the document list, |
| + // the search results, the passage count in the footer. This asks the |
| + // server for the page again — the page the reader is ON, query string |
| + // and all, so a filtered view stays filtered and a document that does |
| + // not match what is on screen does not appear in it — and moves the |
| + // rendered regions across. |
| + // |
| + // Nothing here composes markup. The regions come out of the server's |
| + // own answer as nodes and go in as nodes, so there is exactly one |
| + // thing in this app that knows what a document row looks like. |
| + // |
| + // Every way it can fail — no answer, a status that is not 200, a body |
| + // that is not this page, a region that has gone, a reader with the |
| + // cursor inside one — leaves the notice announce() has already shown |
| + // and the link beside it, which is precisely what happened here before |
| + // any of this existed. |
| + |
| + // Which fetch is the current one. A count, not a flag, for the reason |
| + // judging is a count: two drops can overlap, and the answer to the |
| + // first describes a page the second has already changed. An older |
| + // answer arriving late is dropped rather than painted. |
| + var refreshes = 0; |
| + |
| + function refresh() { |
| + // No parser, no swap, and the notice stands. That is the fallback, |
| + // not a lesser version of it. |
| + if (typeof DOMParser === "undefined") return; |
| + var mine = ++refreshes; |
| + var req = new XMLHttpRequest(); |
| + req.open("GET", location.pathname + location.search, true); |
| + // The list as it is NOW. Revalidating costs one conditional request; |
| + // a cached copy would be the same old list with nothing left on the |
| + // page admitting it, which is the one outcome worse than not having |
| + // asked at all. |
| + req.setRequestHeader("Cache-Control", "no-cache"); |
| + req.onload = function () { |
| + // A newer fetch is already out, or a drop started while this one |
| + // was in the air: either way this answer is older than the page it |
| + // would be describing. Whatever is running will ask again when it |
| + // settles. |
| + if (mine !== refreshes || busy()) return; |
| + if (req.status !== 200) return; |
| + put(req.responseText); |
| + }; |
| + // No handler for the ways this ends without an answer, and that is |
| + // the design rather than an omission: each of them leaves the page |
| + // exactly as announce() left it, saying the list is out of date and |
| + // offering the link. |
| + req.send(); |
| + } |
| + |
| + // put swaps in the answer's refreshable regions. All of them or none: |
| + // half an answer is a page whose own parts disagree about what is |
| + // stored, and there is already a sentence on screen for "not |
| + // refreshed". |
| + function put(html) { |
| + var answer = new DOMParser().parseFromString(html, "text/html"); |
| + if (!answer) return; |
| + var here = document.querySelectorAll("[rst-refresh]"); |
| + if (!here.length) return; |
| + var pairs = []; |
| + for (var i = 0; i < here.length; i++) { |
| + var name = here[i].getAttribute("rst-refresh"); |
| + if (!name) return; |
| + var fresh = answer.querySelector('[rst-refresh="' + name + '"]'); |
| + // Not this page. A sign-in page after a session ran out, a proxy's |
| + // apology, a truncated body: none of them is the list, and none of |
| + // them goes onto the screen. |
| + if (!fresh) return; |
| + // Somebody has the cursor in there — a delete button reached by |
| + // keyboard is the likely one. Replacing an element takes the focus |
| + // inside it back to the top of the document, which loses a |
| + // reader's place without saying so, and the reader is mid-task. So |
| + // the region stays, the notice stands, and the link is there when |
| + // they are ready for it. |
| + if (holdsFocus(here[i])) return; |
| + pairs.push([here[i], fresh]); |
| + } |
| + for (var j = 0; j < pairs.length; j++) { |
| + var old = pairs[j][0]; |
| + old.parentNode.replaceChild(document.importNode(pairs[j][1], true), old); |
| + } |
| + stale.hidden = true; |
| + // The swap is otherwise silent, and silence here is a regression for |
| + // anyone who cannot see the list: they were told what happened, the |
| + // notice they would have followed has gone, and nothing said why. It |
| + // goes in the status because the status is the one live region this |
| + // tray has — a second would compete with it — and it claims only |
| + // what just happened, which is that what is below them is now the |
| + // server's own answer. It says nothing about any particular file, |
| + // including one whose own answer was lost: the list below is where |
| + // that was always going to be settled, and it is now current. |
| + var said = status.textContent; |
| + status.textContent = said ? said + " " + msg.fresh : msg.fresh; |
| + } |
| + |
| + function holdsFocus(el) { |
| + var active = document.activeElement; |
| + return !!active && active !== document.body && el.contains(active); |
| + } |
| + |
| // ---- sending ------------------------------------------------------- |
| function pump() { |
diff --git a/internal/memoria/templates/index.html b/internal/memoria/templates/index.html| index d2be8ca..632acce 100644 |
| --- a/internal/memoria/templates/index.html |
| +++ b/internal/memoria/templates/index.html |
| @@ -7,6 +7,26 @@ |
| <button rst-btn="primary">Search</button> |
| </form> |
| +{{/* Everything the tray refreshes carries rst-refresh, and its value |
| + names it. drop.js fetches THIS page again when a drop settles and |
| + puts each of these regions in place of the one on screen — the |
| + server stays the only thing that renders a document row, and the |
| + browser only moves the rendered nodes across. |
| + |
| + The results region is here rather than around the {{if}} because a |
| + region that is absent cannot be replaced: on a page with no search |
| + the empty div is what the answer's own results are swapped into, |
| + and a drop made while looking at "?q=revenue" re-renders that |
| + search rather than the front page. A document that does not match |
| + what is on screen therefore does not appear in it. |
| + |
| + Nothing the person is USING is inside one of these. The upload |
| + form, the paste box, the drop zone and the tray are all outside, |
| + because a swap replaces the element whole: a half-typed paste or a |
| + row still uploading would go with it. |
| + TestTheRefreshedRegionsCannotTakeTheFormOrTheTrayWithThem holds |
| + that, and it is the property the whole design rests on. */}} |
| +<div rst-refresh="results"> |
| {{if .Searched}} |
| <h2>{{len .Results}} result{{if ne (len .Results) 1}}s{{end}} for “{{.Query}}”</h2> |
| {{if not .Results}} |
| @@ -33,6 +53,7 @@ |
| {{end}} |
| </ol> |
| {{end}} |
| +</div> |
| <section rst-section> |
| <h2>Add something</h2> |
| @@ -84,6 +105,7 @@ |
| data-msg-stored-one="{{.StoredOne}}" |
| data-msg-stored-many="{{.StoredMany}}" |
| data-msg-none-stored="{{.NoneStored}}" |
| + data-msg-fresh="{{.Fresh}}" |
| data-msg-refused-one="{{.RefusedOne}}" |
| data-msg-refused-many="{{.RefusedMany}}" |
| data-msg-unsure-one="{{.UnsureOne}}" |
| @@ -136,7 +158,15 @@ |
| The reload link is a real link with a real href, and nothing |
| reloads on its own: someone mid-drop does not want the ground |
| moving under them. drop.js repoints it at the current URL so a |
| - reload keeps the search that is on screen. */}} |
| + reload keeps the search that is on screen. |
| + |
| + That link and the sentence beside it are the FALLBACK now rather |
| + than the usual case. When a drop settles, drop.js fetches this |
| + same page again and swaps the rst-refresh regions; the sentence is |
| + shown first and only an answer actually put on the page takes it |
| + away, so every way that fetch can fail leaves exactly what was |
| + here before it existed. Watched failing, on purpose, in a real |
| + browser — see refresh_test.go's header. */}} |
| {{with .Drop}} |
| <div rst-tray hidden role="region" aria-labelledby="tray-title"> |
| <div rst-tray-head> |
| @@ -150,7 +180,7 @@ |
| {{end}} |
| </section> |
| -<section rst-section> |
| +<section rst-section rst-refresh="documents"> |
| <h2>{{len .Documents}} document{{if ne (len .Documents) 1}}s{{end}}</h2> |
| {{if not .Documents}} |
| <p rst-cell-mut>Nothing stored yet.</p> |
diff --git a/internal/memoria/templates/layout.html b/internal/memoria/templates/layout.html| index 11d50bf..69e3bcf 100644 |
| --- a/internal/memoria/templates/layout.html |
| +++ b/internal/memoria/templates/layout.html |
| @@ -30,8 +30,19 @@ |
| {{template "content" .}} |
| </main> |
| <footer rst-page-footer> |
| + {{/* The passage count is stale for the same reason the document list |
| + is — an upload that never navigates cannot change either — so it |
| + carries rst-refresh and drop.js swaps it out of the same answer. |
| + The span wraps ONLY the count: the egress claim beside it is not |
| + derived from anything and has no business travelling with it. |
| + |
| + Both branches are inside the span, deliberately. Chunks is -1 when |
| + the count could not be read at all, and the sentence for that is |
| + the one thing here that must survive a refresh: a region that |
| + carried only the number would refresh a failure into a confident |
| + zero, which is the failure shape this app has already had once. */}} |
| <p rst-cell-mut> |
| - {{if .Signed}}{{if lt .Chunks 0}}The passage count is unavailable.{{else}}{{.Chunks}} passage{{if ne .Chunks 1}}s{{end}} indexed.{{end}}{{end}} |
| + <span rst-refresh="passages">{{if .Signed}}{{if lt .Chunks 0}}The passage count is unavailable.{{else}}{{.Chunks}} passage{{if ne .Chunks 1}}s{{end}} indexed.{{end}}{{end}}</span> |
| Search is lexical only — no assistant, and <strong>no document ever |
| leaves this instance</strong>. |
| </p> |
diff --git a/internal/memoriatest/drop_test.go b/internal/memoriatest/drop_test.go| index d1d8c71..d4127a5 100644 |
| --- a/internal/memoriatest/drop_test.go |
| +++ b/internal/memoriatest/drop_test.go |
| @@ -226,13 +226,35 @@ func TestTheUploadFormStillWorksWithoutScript(t *testing.T) { |
| // |
| // - fetch(, sendBeacon, .submit( and requestSubmit( stay forbidden. |
| // One posting mechanism, so there is one thing to check. |
| -// - exactly ONE .open( call site exists, and its address argument is |
| -// the variable `endpoint` — not a literal, not an expression. |
| +// - exactly TWO .open( call sites exist, and each is one of exactly |
| +// two permitted shapes — never a literal, never an expression built |
| +// out of one. |
| // - `endpoint` is assigned exactly once, from the form's own action |
| // attribute, and the form is the file input's own form. |
| // - no address literal appears anywhere in the file. A second endpoint |
| // has to be spelled somewhere, and this is where. |
| // |
| +// WIDENED BY ONE REQUEST, on 2026-09-05, on the owner's approval of the |
| +// refreshing document list. A drop that never navigates leaves the list |
| +// below it as the server drew it before the drop, and the tray said so |
| +// and offered a link. It now asks the server for the page again and puts |
| +// the freshly rendered regions in place of the old ones — which needs a |
| +// GET, and this test permitted exactly one request of any kind. |
| +// |
| +// It is widened by ONE SHAPE and no more, because narrowing it once |
| +// already proved insufficient: an earlier version was walked straight |
| +// past with `endpoint += '/../upload'`. So the second shape is not "a |
| +// GET" and not "a same-origin GET" — it is the page the browser is |
| +// already on, spelled as location.href or as location.pathname + |
| +// location.search, and nothing else parses. A constructed path, a |
| +// literal, a concatenation onto either of those, or a third call site of |
| +// any kind fails, and so does a POST that is not the form's action. |
| +// |
| +// Why that address is safe to permit when no other is: it is a GET, of a |
| +// page this person was already served, at an address the browser chose |
| +// rather than this file. It stores nothing, and it can reach nothing the |
| +// reader could not reach by pressing reload. |
| +// |
| // Note for whoever edits drop.js next: this scans the file whole, |
| // comments included, so prose in that file may not spell the forbidden |
| // names either, nor quote a path. That is the deliberate direction of |
| @@ -276,10 +298,45 @@ func TestTheUploadFormStillWorksWithoutScript(t *testing.T) { |
| // style, which the old double-quotes-only pattern could not see → |
| // "the drop script contains the address literal `/upload`" |
| // |
| -// And one that does NOT fail, recorded because a reader will try it: |
| -// changing the form's action in index.html. The script reads it, so both |
| -// sides move together — which is the point of reading it rather than |
| -// writing it down twice. |
| +// MUTATION-TESTED 2026-09-05, third round, after the second request was |
| +// permitted. Every one of these was run against the narrowed test, and |
| +// each was reverted: |
| +// |
| +// (i) `req.open("GET", "/documents", true)` — the re-read pointed at |
| +// another address → "the drop script opens a request that is |
| +// neither of the two things it is allowed to open", "0 re-reads of |
| +// this page", and "the address literal "/documents"" |
| +// (j) the reviewer's own shape, moved to the new request: `var where = |
| +// location.pathname + location.search; where += '/../upload'; |
| +// req.open("GET", where, true)` → the same three failures, the |
| +// third naming '/../upload' |
| +// (k) `req.open("GET", location.pathname + location.search + endpoint, |
| +// true)` — the sharp one, because it writes NO literal down and |
| +// would slip past the literal ban entirely → red on the shape |
| +// alone: the trailing anchor is what stops an address that merely |
| +// begins with the permitted one |
| +// (l) a second re-read call site added → "the drop script opens 3 |
| +// requests; exactly two call sites may exist" |
| +// (m) `req.open("POST", location.pathname + location.search, true)` — |
| +// the permitted address with the verb changed, which would be a |
| +// second way in wearing the re-read's clothes → "neither of the two |
| +// things it is allowed to open" and "0 re-reads of this page" |
| +// (n) the ORIGINAL property, replayed to prove widening did not blunt |
| +// it: `xhr.open("POST", "/upload", true)` → "0 posts to the form's |
| +// action" and "the address literal "/upload"" |
| +// (o) and the code review's compound assignment, replayed for the same |
| +// reason: `endpoint += '/../upload'` after the read → "the drop |
| +// script assigns `endpoint` 2 times" and "the address literal |
| +// '/../upload'" |
| +// |
| +// And two that do NOT fail, recorded because a reader will try them: |
| +// changing the form's action in index.html (the script reads it, so both |
| +// sides move together — the point of reading it rather than writing it |
| +// down twice), and spelling the re-read `location.href` instead of |
| +// `location.pathname + location.search`. The second is deliberate: both |
| +// name the page the browser is already on, neither is written down here, |
| +// and pinning one spelling would be pinning a style rather than the |
| +// property. |
| func TestTheDropScriptPostsOnlyToTheFormsOwnAction(t *testing.T) { |
| src := servedDropScript(t, newApp(t)) |
| @@ -294,18 +351,43 @@ func TestTheDropScriptPostsOnlyToTheFormsOwnAction(t *testing.T) { |
| } |
| } |
| - // Exactly one request is opened, and its address is the variable — |
| - // not a literal, not something built out of one. |
| + // Exactly two requests are opened, and each is one of exactly two |
| + // shapes: the POST to the address read off the form, and the GET of |
| + // the page the browser is already on. Neither address may be a |
| + // literal, and neither may be an expression built out of one. |
| opens := regexp.MustCompile(`\.open\s*\(([^)]*)\)`).FindAllStringSubmatch(src, -1) |
| - if len(opens) != 1 { |
| - t.Fatalf("the drop script opens %d requests; exactly one call site may exist, or this "+ |
| - "test cannot say where all of them go", len(opens)) |
| - } |
| - if !regexp.MustCompile(`^\s*"POST"\s*,\s*endpoint\s*(,[^,]*)?$`).MatchString(opens[0][1]) { |
| - t.Errorf("the drop script opens a request on something other than the endpoint read "+ |
| - "off the form: .open(%s). The address may not be a literal and may not be an "+ |
| - "expression — it is the form's own action, or it is a second endpoint nothing "+ |
| - "on the server side agreed to.", opens[0][1]) |
| + if len(opens) != 2 { |
| + t.Fatalf("the drop script opens %d requests; exactly two call sites may exist — the "+ |
| + "upload and the page it re-reads — or this test cannot say where all of them go", |
| + len(opens)) |
| + } |
| + // The upload. Unchanged: the variable, and only the variable. |
| + toTheForm := regexp.MustCompile(`^\s*"POST"\s*,\s*endpoint\s*(,[^,]*)?$`) |
| + // The re-read. Both spellings of "where the browser already is", and |
| + // nothing that merely starts with one: the trailing anchor is what |
| + // stops `location.href + "/../upload"` from parsing as this shape. |
| + toThisPage := regexp.MustCompile( |
| + `^\s*"GET"\s*,\s*(?:location\.href|location\.pathname\s*\+\s*location\.search)\s*(,[^,]*)?$`) |
| + posts, rereads := 0, 0 |
| + for _, o := range opens { |
| + switch { |
| + case toTheForm.MatchString(o[1]): |
| + posts++ |
| + case toThisPage.MatchString(o[1]): |
| + rereads++ |
| + default: |
| + t.Errorf("the drop script opens a request that is neither of the two things it is "+ |
| + "allowed to open: .open(%s). It may POST to the endpoint read off the form, "+ |
| + "and it may GET the page the reader is already on (location.href, or "+ |
| + "location.pathname + location.search). An address that is a literal, or built "+ |
| + "out of one, or anything else at all, is an address nothing on the server "+ |
| + "side agreed to.", o[1]) |
| + } |
| + } |
| + if posts != 1 || rereads != 1 { |
| + t.Errorf("the drop script opens %d posts to the form's action and %d re-reads of this "+ |
| + "page; it may open exactly one of each. Two posts is a second way in; two "+ |
| + "re-reads is a race for which answer lands last.", posts, rereads) |
| } |
| // And that variable is the form's action, assigned once. A second |
diff --git a/internal/memoriatest/refresh_test.go b/internal/memoriatest/refresh_test.go| new file mode 100644 |
| index 0000000..5f0969d |
| --- /dev/null |
| +++ b/internal/memoriatest/refresh_test.go |
| @@ -0,0 +1,325 @@ |
| +package memoriatest |
| + |
| +import ( |
| + "regexp" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// The tests in this file cover the list that stops being stale. |
| +// |
| +// A drop posts each file in the background and never navigates, so |
| +// everything the server rendered before the drop was still on the screen |
| +// afterwards: the document list, the search results, the passage count in |
| +// the footer. The tray said so and offered a link. It now asks the server |
| +// for the same page again — the page the reader is ON, query string and |
| +// all — and puts the freshly rendered regions in place of the old ones. |
| +// |
| +// The server stays the only thing that renders a document row. Nothing in |
| +// the browser composes markup; the regions come out of the answer as nodes |
| +// and go in as nodes. Two renderers for one row is the drift this codebase |
| +// has already been bitten by twice. |
| +// |
| +// Go cannot run the script, so what these tests hold is the half a Go test |
| +// can hold, and it is the half the design rests on: WHERE the regions are. |
| +// A swap replaces its element whole, so a region reaching one tag too far |
| +// would destroy the paste box someone is typing in, or the drop zone, or |
| +// the tray itself with rows still uploading in it. |
| +// |
| +// The other half was checked in a real browser, because a Go test cannot |
| +// see a list change. drop_test.go's header carries the CDP recipe; |
| +// Chromium 151, 2026-09-05, against tools/drive, with the viewport at |
| +// 780x493 (drop coordinates have to be INSIDE it — a drop dispatched at |
| +// y=700 in that window does nothing at all, silently, which cost the |
| +// first run of this): |
| +// |
| +// - Three files dropped on the front page, one of them binary, with |
| +// "half-typed, and it must survive" in the paste box first. The tray |
| +// showed three rows; the summary read "Stored 2 documents. 1 |
| +// refused: scan.png is not text. The document list below is up to |
| +// date."; location.href never changed; the table went from four rows |
| +// to six and its heading from "4 documents" to "6 documents"; the |
| +// footer went from 9 to 11 passages; the stale notice stayed hidden; |
| +// the paste box still held its text. |
| +// - The same drop while looking at "/?q=zebrafinch", with one file |
| +// matching that search and one not. The results went from ["two"] to |
| +// ["two","four"] and the heading from "1 result" to "2 results" — |
| +// the non-matching file did NOT appear there, and both files did |
| +// appear in the document list below, which the search does not |
| +// filter. The address stayed "/?q=zebrafinch". |
| +// - With the keyboard focus on a Delete button INSIDE the document |
| +// list, a drop stored its file and did not swap: the notice appeared, |
| +// the summary was "Stored 1 document." with no claim about the list, |
| +// the table was unchanged, and document.activeElement was still that |
| +// button. |
| +// - With only the re-fetch blocked (Network.setBlockedURLs on the |
| +// page's own address, so the POST still went through), the file |
| +// stored, the summary was "Stored 1 document." with no claim about |
| +// the list, the notice and the reload link came back, and the link's |
| +// href was "/?q=zebrafinch" — the view the reader was on. Unblocking |
| +// and following it showed the document really had landed. |
| +// - The other way round, and the one the unconfirmed case turns on: |
| +// only the UPLOAD blocked, so the answer was lost and the re-fetch |
| +// got through. The row said "six.txt got no answer, so whether it |
| +// was stored is unknown", the summary added "The document list below |
| +// is up to date.", and the list — refreshed, three rows, unchanged — |
| +// did not contain six.txt. Which is the truth about that file, |
| +// arrived at by looking rather than by claiming. Nothing new is said |
| +// about the file itself, because nothing here knows anything new |
| +// about it. |
| +// - Focus was on the search box throughout the runs above and stayed |
| +// there: every region is outside it. |
| +// - Zero console messages and zero log entries across all of it. |
| +// - And the scriptless path, with Emulation.setScriptExecutionDisabled |
| +// on: the zone was unmarked, the tray, hint and note all `hidden`, |
| +// and choosing two files (DOM.setFileInputFiles) and pressing Store |
| +// it navigated to "/?stored=2" with the list rendered fresh by the |
| +// server. No script ran at any point in that. |
| + |
| +// refreshRegion returns one whole rst-refresh region, opening tag to |
| +// matching close. |
| +// |
| +// It counts nested elements of the same name rather than looking for the |
| +// first close tag, and that is not fussiness: the results region contains |
| +// a <div rst-callout> when nothing matched, so a scan that stopped at the |
| +// first </div> would return half a region and every containment assertion |
| +// below would pass for the wrong reason. |
| +func refreshRegion(t *testing.T, body, name string) string { |
| + t.Helper() |
| + at := strings.Index(body, `rst-refresh="`+name+`"`) |
| + if at < 0 { |
| + t.Fatalf("no region named %q on the page:\n%s", name, body) |
| + } |
| + open := strings.LastIndex(body[:at], "<") |
| + if open < 0 { |
| + t.Fatalf("the %q region's attribute is not inside a tag", name) |
| + } |
| + el := regexp.MustCompile(`^<([a-zA-Z][a-zA-Z0-9]*)`).FindStringSubmatch(body[open:]) |
| + if el == nil { |
| + t.Fatalf("the %q region's opening tag has no element name: %.60s", name, body[open:]) |
| + } |
| + tag := regexp.MustCompile(`</?` + el[1] + `\b`) |
| + depth, i := 0, open |
| + for { |
| + loc := tag.FindStringIndex(body[i:]) |
| + if loc == nil { |
| + t.Fatalf("the %q region (<%s>) never closes", name, el[1]) |
| + } |
| + start := i + loc[0] |
| + if body[start+1] == '/' { |
| + depth-- |
| + if depth == 0 { |
| + end := strings.Index(body[start:], ">") |
| + if end < 0 { |
| + t.Fatalf("the %q region's closing tag never closes", name) |
| + } |
| + return body[open : start+end+1] |
| + } |
| + } else { |
| + depth++ |
| + } |
| + i = start + 2 |
| + } |
| +} |
| + |
| +// The regions the tray swaps hold the rendered list and NOTHING the |
| +// person is in the middle of using. |
| +// |
| +// This is the property the whole refresh rests on. A swap replaces an |
| +// element whole: everything inside it is destroyed and rebuilt from the |
| +// server's answer. So a region reaching one tag too far would take the |
| +// half-typed paste box with it, or the drop zone, or the tray itself — |
| +// including rows for files still uploading, whose outcome has not been |
| +// said yet. That is the silent failure with a progress bar on it, and the |
| +// only thing standing between this design and it is where two tags sit. |
| +// |
| +// Names are checked for uniqueness for the same reason: the script pairs |
| +// each region on screen with the one of that name in the answer, and two |
| +// regions sharing a name is a swap that puts one region's content in |
| +// another's place. |
| +// |
| +// MUTATION-TESTED 2026-09-05, each reverted: |
| +// |
| +// (a) rst-refresh="documents" moved onto the <section> that holds the |
| +// upload form → six failures, four of them the ones this test is |
| +// for: "the "documents" region contains the upload form", and the |
| +// same for the drop zone, the tray and the paste box; then "holds 0 |
| +// of the page's 1 document rows" and the missing heading |
| +// (b) the results region's opening tag moved ABOVE the search form, so |
| +// the region wraps the search box → "the "results" region contains |
| +// the search box" |
| +// (c) rst-refresh="documents" changed to rst-refresh="results", so two |
| +// regions share a name → "two regions are both named "results"", |
| +// then "the page has no "documents" region" |
| +// (d) the <h2> that counts the rows moved outside the documents section |
| +// → "the "documents" region does not contain the heading that |
| +// counts the rows" |
| +// (e) rst-refresh deleted from the footer count → the VACUITY guard, |
| +// not the named one: "the page carries 2 refreshable regions; the |
| +// tray needs the results, the document list and the passage count". |
| +// Recorded as it printed rather than as it was expected to print — |
| +// the count check sits first and fatals, so the "no passages |
| +// region" line never runs. Both are aimed at the same mistake. |
| +// (f) the span around the footer count widened to wrap the whole |
| +// paragraph → "the "passages" region has swallowed the egress claim |
| +// beside it" |
| +func TestTheRefreshedRegionsCannotTakeTheFormOrTheTrayWithThem(t *testing.T) { |
| + h := newApp(t) |
| + submit(t, h, "", "", file{"revenue.txt", "Revenue was 100 across the first region we serve."}) |
| + // A searched page, so every region has something in it. An empty |
| + // results region would make the containment checks below trivially |
| + // true for the one region most likely to over-reach. |
| + body := get(t, h, "/?q=revenue").Body.String() |
| + |
| + found := regexp.MustCompile(`rst-refresh="([^"]*)"`).FindAllStringSubmatch(body, -1) |
| + if len(found) < 3 { |
| + t.Fatalf("the page carries %d refreshable regions; the tray needs the results, the "+ |
| + "document list and the passage count, so this pattern has stopped matching and "+ |
| + "nothing below proves anything:\n%s", len(found), body) |
| + } |
| + seen := map[string]bool{} |
| + for _, f := range found { |
| + if f[1] == "" { |
| + t.Error("a region carries an empty rst-refresh; the script pairs regions by name " + |
| + "and an unnamed one can be paired with anything") |
| + } |
| + if seen[f[1]] { |
| + t.Errorf("two regions are both named %q. The script pairs each region on screen "+ |
| + "with the one of that name in the answer, so a shared name puts one region's "+ |
| + "content into another's place.", f[1]) |
| + } |
| + seen[f[1]] = true |
| + } |
| + for _, want := range []string{"results", "documents", "passages"} { |
| + if !seen[want] { |
| + t.Errorf("the page has no %q region, so that part of it stays as it was drawn "+ |
| + "before the drop while the rest of the page has been refreshed around it", want) |
| + } |
| + } |
| + |
| + // Nothing the person is using is inside any of them. |
| + for name := range seen { |
| + region := refreshRegion(t, body, name) |
| + for _, keep := range []struct{ what, marker string }{ |
| + {"the upload form", `action="/documents"`}, |
| + {"the drop zone", "<div rst-dropzone"}, |
| + {"the tray", "<div rst-tray"}, |
| + {"the paste box", `<textarea id="body"`}, |
| + {"the search box", `<input type="search"`}, |
| + } { |
| + if strings.Contains(region, keep.marker) { |
| + t.Errorf("the %q region contains %s. A swap replaces the region whole, so it "+ |
| + "would be destroyed and rebuilt from the server's answer — a half-typed "+ |
| + "paste, a drop in progress, or a tray row whose outcome has not been said "+ |
| + "yet, gone without a word.", name, keep.what) |
| + } |
| + } |
| + } |
| + |
| + // And each region holds what it is there to refresh. |
| + docs := refreshRegion(t, body, "documents") |
| + rows := strings.Count(body, `<td><a href="/documents/`) |
| + if rows == 0 { |
| + t.Fatal("the page lists no documents at all, so the region below could contain none " + |
| + "of them and still pass") |
| + } |
| + if n := strings.Count(docs, `<td><a href="/documents/`); n != rows { |
| + t.Errorf("the %q region holds %d of the page's %d document rows. A region holding part "+ |
| + "of the list refreshes part of the list, and the rest stays as it was with "+ |
| + "nothing on the page saying so.", "documents", n, rows) |
| + } |
| + // The heading counts the rows, so it goes with them or the count and |
| + // the list disagree the moment one is replaced. |
| + if !regexp.MustCompile(`<h2>\d+ documents?</h2>`).MatchString(docs) { |
| + t.Errorf("the %q region does not contain the heading that counts the rows:\n%s", |
| + "documents", docs) |
| + } |
| + |
| + if r := refreshRegion(t, body, "results"); !strings.Contains(r, "rst-result-title") { |
| + t.Errorf("the %q region holds no search result on a page that searched; the reader's "+ |
| + "view would keep its old results after an upload:\n%s", "results", r) |
| + } |
| + |
| + count := refreshRegion(t, body, "passages") |
| + if !strings.Contains(count, "indexed") { |
| + t.Errorf("the %q region does not hold the passage count: %q", "passages", count) |
| + } |
| + if strings.Contains(count, "no document ever") { |
| + t.Errorf("the %q region has swallowed the egress claim beside it: %q. That sentence is "+ |
| + "not derived from anything and has no business being re-fetched.", "passages", count) |
| + } |
| +} |
| + |
| +// Re-fetching the CURRENT url is what makes a filtered view stay |
| +// filtered. |
| +// |
| +// This is the whole reason the script reads location rather than building |
| +// an address: a person looking at "?q=revenue" who drops a file about |
| +// hosting must not have it appear in their results, and one who drops a |
| +// file about revenue must. Nothing in the browser decides that — the |
| +// server does, because the answer is the same page rendered again. |
| +// |
| +// The assertions are on the REGIONS, never on the page. That matters here |
| +// more than anywhere: the flash sentence and the document list both name |
| +// the file, so "the filename appears in the HTML" would pass just as well |
| +// for a view that had not filtered at all. |
| +// |
| +// MUTATION-TESTED 2026-09-05, each reverted: |
| +// |
| +// (a) `q := strings.TrimSpace(r.URL.Query().Get("q"))` → `q := ""` in |
| +// index(), so every fetch renders the front page → "the results |
| +// region for "?q=revenue" holds 0 results, want 1" — the filtered |
| +// view answering as if it were unfiltered — and again on the second |
| +// half, printing the empty region |
| +// (b) `p.Documents = docs` → `p.Documents = nil` when p.Searched, the |
| +// plausible "a search page does not need the list" edit → "hosting |
| +// is not in the document list region after being stored" |
| +// (c) `Chunks: a.chunkCount()` → `Chunks: 0` in index() → "the passage |
| +// count region did not change after 1 document was stored" |
| +func TestARefetchOfTheCurrentURLIsTheViewTheReaderIsLookingAt(t *testing.T) { |
| + h := newApp(t) |
| + submit(t, h, "", "", file{"revenue.txt", "Revenue was 100 across the first region we serve."}) |
| + |
| + // What the reader is looking at, before the drop. |
| + before := get(t, h, "/?q=revenue").Body.String() |
| + wasCounting := refreshRegion(t, before, "passages") |
| + |
| + // The drop: one file, posted the way the tray posts it, about |
| + // something the search on screen does not match. |
| + readReply(t, submitJSON(t, h, file{"hosting.txt", "Hosting costs fell after the migration completed."})) |
| + |
| + // The re-fetch: the same address, not a constructed one. |
| + after := get(t, h, "/?q=revenue").Body.String() |
| + |
| + results := refreshRegion(t, after, "results") |
| + if n := strings.Count(results, "rst-result-title"); n != 1 { |
| + t.Errorf("the results region for %q holds %d results, want 1. A re-fetch of the "+ |
| + "address the reader is on renders their view, not the front page.", "?q=revenue", n) |
| + } |
| + if strings.Contains(results, "hosting") { |
| + t.Errorf("hosting.txt appears in the results for %q, which it does not match. The list "+ |
| + "is refreshed by re-rendering the reader's own view; a document that does not "+ |
| + "belong in that view must not arrive in it just because it was uploaded:\n%s", |
| + "?q=revenue", results) |
| + } |
| + // It is in the document list, though, which the search does not |
| + // filter — so the reader still sees that it landed. |
| + if docs := refreshRegion(t, after, "documents"); !strings.Contains(docs, ">hosting<") { |
| + t.Errorf("hosting is not in the document list region after being stored:\n%s", docs) |
| + } |
| + // And the footer count moved, because passages were added. |
| + if now := refreshRegion(t, after, "passages"); now == wasCounting { |
| + t.Errorf("the passage count region did not change after 1 document was stored: %q. It "+ |
| + "is refreshed out of the same answer as the list, so a count that cannot change "+ |
| + "is a count that will sit under a table it disagrees with.", now) |
| + } |
| + |
| + // The other half: a document that DOES match the view arrives in it. |
| + readReply(t, submitJSON(t, h, file{"more-revenue.txt", "Revenue was 200 across the second region we serve."})) |
| + again := refreshRegion(t, get(t, h, "/?q=revenue").Body.String(), "results") |
| + if n := strings.Count(again, "rst-result-title"); n != 2 { |
| + t.Errorf("the results region holds %d results after a second matching document was "+ |
| + "stored, want 2; a refresh that cannot add to the view shows the reader a search "+ |
| + "that has stopped being true:\n%s", n, again) |
| + } |
| +} |
diff --git a/internal/memoriatest/tray_test.go b/internal/memoriatest/tray_test.go| index 7bed79b..ca27b1e 100644 |
| --- a/internal/memoriatest/tray_test.go |
| +++ b/internal/memoriatest/tray_test.go |
| @@ -42,6 +42,13 @@ import ( |
| // appeared with its reload link; the page never navigated; the document |
| // list below was still showing its old three rows until the link was |
| // followed, which is the point of not reloading under someone. |
| +// |
| +// THAT LAST CLAUSE IS NO LONGER TRUE, and it is left standing because it |
| +// is the history this branch changed: the list is refreshed in place now. |
| +// It was re-checked the same way on 2026-09-05, Chromium 151, and what |
| +// that run showed is recorded in refresh_test.go's header — including the |
| +// two runs that matter most, where the refresh could not happen and the |
| +// notice and the link above came back exactly as they are described here. |
| // trayReply is the JSON contract, spelled out here so a change to it |
| // fails a test rather than a browser. The tags are the wire names. |