oficina / memoria Public

Add content gets its own page; the index keeps search and the list

Paul, 2026-09-29: remove the Add forms from the Memoria index; that
should be a separate Add content form.

GET /add carries the paste form, the drop zone and the upload tray;
POST /documents redirects back there. The index keeps search and the
document list, with an Add content button. The tray points to the
document list with a real link.

Uploads and failed deletes each get their own one-shot notice cookie
(notice.go), so neither page spends the other's. drop.js's background
re-fetch sends a header named by the page and spends neither.

Reviewed four times by GPT-6 Astra (codex/gpt-6-astra via Paseo), each
REQUEST CHANGES, all answered; the runtime was verified in Chromium on
each pass. Twenty-four mutations, each built and seen red. Residual:
Go cannot run drop.js, so its checks are structural; Astra recommends
an executable JS harness as a follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Squashed from feat/add-content-page into main.
Paul Campbell pushed by claude agent, operated by paul@keymail.dev cd4856ac2eea00efb5313a012ec3ea6e68edf2b2 parent d495612
24 files changed, +997 −517
  • README.md +3 −2
  • go.mod +1 −0
  • go.sum +2 −8
  • internal/memoria/app.go +1 −0
  • internal/memoria/delete_test.go +31 −1
  • internal/memoria/handlers.go +51 −43
  • internal/memoria/lag_test.go +1 −1
  • internal/memoria/notice.go +74 −0
  • internal/memoria/notice_test.go +86 −0
  • internal/memoria/render.go +1 −1
  • internal/memoria/static/drop.js +31 −45
  • internal/memoria/static/memoria.css +1 −1
  • internal/memoria/static/search.js +5 −13
  • internal/memoria/templates/add.html +128 −0
  • internal/memoria/templates/index.html +10 −153
  • internal/memoria/templates/layout.html +3 −3
  • internal/memoriatest/app_test.go +1 −1
  • internal/memoriatest/dom_test.go +92 −0
  • internal/memoriatest/drop_test.go +34 −22
  • internal/memoriatest/gates_test.go +3 −3
  • internal/memoriatest/notice_test.go +303 −0
  • internal/memoriatest/refresh_test.go +127 −214
  • internal/memoriatest/routes_test.go +2 −0
  • internal/memoriatest/tray_test.go +6 −6

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/memoria/cmd/memoria	[no test files]
ok  	amadan.net/oficina/memoria/internal/memoria	3.822s
ok  	amadan.net/oficina/memoria/internal/memoria/chunk	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/fact	(cached)
?   	amadan.net/oficina/memoria/internal/memoria/icons	[no test files]
ok  	amadan.net/oficina/memoria/internal/memoria/index	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/notion	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/provider	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/query	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/seal	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/sibling	(cached)
ok  	amadan.net/oficina/memoria/internal/memoriatest	2.034s
?   	amadan.net/oficina/memoria/scripts/writeestilo	[no test files]
?   	amadan.net/oficina/memoria/tools/bench	[no test files]
?   	amadan.net/oficina/memoria/tools/drive	[no test files]
?   	amadan.net/oficina/memoria/tools/seed	[no test files]
?   	amadan.net/oficina/memoria/tools/vendorassets	[no test files]
=== migration-check ===
go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
diff --git a/README.md b/README.md
index fcbe278..f03327c 100644
--- a/README.md
+++ b/README.md
@@ -53,7 +53,8 @@ serving `71bebef`. Sign in with `paul@keymail.dev`, `paul@rslw.com` or
`paul@tito.io`; real email goes out through the platform's SES, so no
sign-in link is ever written to a log.
-It starts **empty**. Paste or upload on the front page to fill it — the
+It starts **empty**. Paste or upload on **Add content** (`/add`, linked
+from the front page) to fill it — the
seed tool needs filesystem access to the database, so it only works
against a local instance.
@@ -118,7 +119,7 @@ accident:
### Adding your own
-Paste or upload text on the front page, or seed a directory:
+Paste or upload text on **Add content** (`/add`), or seed a directory:
```
cd /home/paulca/memoriadrive
diff --git a/go.mod b/go.mod
index 6998b6e..2b55040 100644
--- a/go.mod
+++ b/go.mod
@@ -14,6 +14,7 @@ require (
github.com/go-chi/chi/v5 v5.3.2
github.com/google/uuid v1.6.0
golang.org/x/crypto v0.55.0
+ golang.org/x/net v0.57.0
gorm.io/gorm v1.31.2
modernc.org/sqlite v1.55.0
)
diff --git a/go.sum b/go.sum
index df349a7..f4ea6fa 100644
--- a/go.sum
+++ b/go.sum
@@ -1,11 +1,3 @@
-amadan.net/oficina/estilo.git v0.0.0-20260911182719-0bcb9287988d h1:3HsB96tGs+P0tGf2VzqpqA8yqYBhJ+apBh2txAoz9qI=
-amadan.net/oficina/estilo.git v0.0.0-20260911182719-0bcb9287988d/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY=
-amadan.net/oficina/estilo.git v0.0.0-20260929070530-5b2ce835e773 h1:9DIAhG3TaGG+LE2+8LvU73ZEhfK+oNn2y2p4kFjAAi0=
-amadan.net/oficina/estilo.git v0.0.0-20260929070530-5b2ce835e773/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
-amadan.net/oficina/estilo.git v0.0.0-20260929095046-d0664ee49914 h1:V0AyxXYCxqGZClOLqvppw+cCTXsn3b9NmRQ8CV5ZlXY=
-amadan.net/oficina/estilo.git v0.0.0-20260929095046-d0664ee49914/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
-amadan.net/oficina/estilo.git v0.0.0-20260929095838-0994675f496e h1:LTSmIWUS8RjPb6m94Ynf7moyFYeBXr08rdOr3nmNqvg=
-amadan.net/oficina/estilo.git v0.0.0-20260929095838-0994675f496e/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
amadan.net/oficina/estilo.git v0.0.0-20260929165053-846b39772f3c h1:YYocIz3r3b4oIA0K/58JkSsmyTOCzMT/2GaW/MFbbPo=
amadan.net/oficina/estilo.git v0.0.0-20260929165053-846b39772f3c/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f h1:lkw3tjzHfZxe74pNfo+tGDz4mkZLg+D6R+FTkmsym6M=
@@ -66,6 +58,8 @@ golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
+golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
+golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/internal/memoria/app.go b/internal/memoria/app.go
index 150b625..50b44f8 100644
--- a/internal/memoria/app.go
+++ b/internal/memoria/app.go
@@ -141,6 +141,7 @@ func App(d *db.DB, origin string, sealer *seal.Sealer, logger *slog.Logger) (*ht
r.Post("/api/native/search", a.nativeSearch)
r.Post("/api/native/notes", a.nativeNote)
r.Get("/", a.index)
+ r.Get("/add", a.addContent)
r.Get("/search", a.searchResults)
r.Post("/documents", a.createDocument)
r.Get("/documents/{id}", a.showDocument)
diff --git a/internal/memoria/delete_test.go b/internal/memoria/delete_test.go
index 0df53f3..b92a93c 100644
--- a/internal/memoria/delete_test.go
+++ b/internal/memoria/delete_test.go
@@ -283,7 +283,7 @@ func passageText(t *testing.T, a *app, id int64) string {
// the code as it stood before this review → "the document was
// deleted from the database even though the index still holds its
// passages"
-// (b) `flash.Set` deleted from deleteDocument's error branch → "the
+// (b) the notice deleted from deleteDocument's error branch → "the
// page says nothing about the delete that did not happen"
//
// One more was tried and came back GREEN, recorded because a mutation
@@ -325,6 +325,36 @@ func TestADeleteTheIndexRefusesLeavesTheDocumentAlone(t *testing.T) {
}
}
+// A failed delete's notice survives a visit to /add.
+//
+// It is the index's notice, and /add has nowhere to show it. When both
+// pages shared one cookie, whichever loaded first spent it, so opening
+// Add content in another tab was enough to lose the only word that a
+// delete had not happened.
+//
+// MUTATION-TESTED 2026-09-29, reverted: addContent takes indexNotice
+// instead of addNotice → "/add showed the failed delete's notice, which
+// belongs on the index".
+func TestAFailedDeleteNoticeWaitsForTheIndex(t *testing.T) {
+ h, a := appWithAWorkingIndex(t)
+ id := storeOneDocument(t, a, "redundancy")
+ if err := a.store.ix.Close(); err != nil {
+ t.Fatalf("closing the index: %v", err)
+ }
+
+ b := newCookieBrowser(t, h)
+ b.keep(postDelete(t, h, id))
+ visit := b.visit
+
+ failed := newDropZone().DeleteFailed
+ if strings.Contains(visit("/add"), failed) {
+ t.Error("/add showed the failed delete's notice, which belongs on the index")
+ }
+ if !strings.Contains(visit("/"), failed) {
+ t.Error("after a visit to /add, the index no longer says the delete failed")
+ }
+}
+
// The ordinary delete still deletes, said here because the test above
// only proves the refusal. A guard that made every delete fail would pass
// it.
diff --git a/internal/memoria/handlers.go b/internal/memoria/handlers.go
index 3b475c1..bc9e64f 100644
--- a/internal/memoria/handlers.go
+++ b/internal/memoria/handlers.go
@@ -17,7 +17,6 @@ import (
"amadan.net/oficina/suite"
"github.com/carlosframework/rastrillo/db"
- "github.com/carlosframework/rastrillo/flash"
"github.com/go-chi/chi/v5"
"amadan.net/oficina/memoria/internal/memoria/query"
@@ -240,6 +239,11 @@ type dropZone struct {
// reads it.
MaxInFlight int
+ // RefreshHeader is the header the tray's re-fetch sends so the page
+ // leaves its notice unspent (notice.go). Published here so the script
+ // and the server cannot spell it differently.
+ RefreshHeader string
+
// ---- the tray -----------------------------------------------------
//
// A drop no longer fills the form's input and wait for Store it: each
@@ -271,24 +275,17 @@ type dropZone struct {
// again because they travel into the summary sentence too.
RowStored string
- // 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.
+ // Shown once everything settles, when anything was stored or might
+ // have been. The document list is not on this page, so this points to
+ // it. For an unconfirmed file the list is the only place to find out.
+ // The link goes to "/" and nothing rewrites it; the page never moves
+ // on its own, because someone mid-drop does not want the ground moving.
+ Listed string
+ ViewDocs string
+
+ // Fresh is said in the tray's live region when the passage count in
+ // the footer has been replaced from a re-fetch of this page, because
+ // that swap is otherwise silent. It claims nothing about any file.
Fresh string
// The outcome sentence, both halves. The handler composes it after
@@ -403,7 +400,8 @@ func newDropZone() dropZone {
// silent nothing is the one outcome that must not happen.
Blocked: "This browser would not let a dropped file into the form, so nothing was taken. Choose the files above instead.",
- MaxInFlight: maxInFlight,
+ MaxInFlight: maxInFlight,
+ RefreshHeader: refreshHeader,
TrayTitle: "Uploads",
TrayDismiss: "Dismiss",
@@ -413,9 +411,9 @@ func newDropZone() dropZone {
Storing: "Storing…",
RowStored: "Stored",
- Stale: "Upload will appear in the list on next reload",
- Reload: "Reload the page",
- Fresh: "The document list below is up to date.",
+ Listed: "Stored documents are in the document list.",
+ ViewDocs: "View documents",
+ Fresh: "The passage count below is up to date.",
StoredOne: "Stored 1 document.",
StoredMany: "Stored {n} documents.",
@@ -558,13 +556,11 @@ func (a *app) chunkCount() int {
func (a *app) index(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
- drop := newDropZone()
p := page{
Signed: true, HomeEnabled: a.showHomeConnection(r), ModelOn: a.modelOn(),
Chunks: a.chunkCount(),
Query: q,
Searched: q != "",
- Drop: drop,
}
a.runSearch(r, &p)
docs, err := a.store.Summaries(r.Context())
@@ -572,6 +568,25 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
a.logger.Error("list documents", "err", err)
}
p.Documents = docs
+ // Adding content moved to /add. The one message that still lands here
+ // is a failed delete, and it has a cookie of its own; see notice.go.
+ if kind, msg, ok := indexNotice.take(w, r); ok {
+ p.Flash, p.FlashKind = msg, kind
+ }
+ render(w, "index", p)
+}
+
+// addContent is GET /add: the page for adding content — pasting, choosing
+// or dropping files — with the outcome of the last submission. It used to
+// be a section of the index; Paul asked for it to be its own page
+// (2026-09-29). POST /documents redirects back here.
+func (a *app) addContent(w http.ResponseWriter, r *http.Request) {
+ drop := newDropZone()
+ p := page{
+ Signed: true, HomeEnabled: a.showHomeConnection(r), ModelOn: a.modelOn(),
+ Chunks: a.chunkCount(),
+ Drop: drop,
+ }
if f := r.URL.Query().Get("stored"); f != "" {
p.Flash, p.FlashKind = drop.outcomeCounts(count(f), count(r.URL.Query().Get("refused")))
}
@@ -579,20 +594,13 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
p.Flash, p.FlashKind = flashFor(f, count(r.URL.Query().Get("n"))), "danger"
}
// The cookie carries the sentence that NAMES things, so it wins over
- // the counts in the URL. It is taken unconditionally: Take clears it
- // whether or not this page uses it, and a one-shot notice that
- // outlives its own page is a notice about the wrong thing.
- //
- // A filename is content, and this cookie is the one place content is
- // allowed to travel outside the page body. It is HttpOnly, one-shot,
- // sixty seconds, and it goes only to the browser about to be shown
- // the same words on the same screen. It is never a log line, never
- // an error string, and never a URL — the redirects below carry
- // counts and nothing else.
- if fl, ok := flash.Take(w, r); ok && fl.Message != "" {
- p.Flash, p.FlashKind = fl.Message, fl.Kind
+ // the counts in the URL, which are counts and nothing else. It is
+ // this page's own cookie and a visit to any other page leaves it
+ // alone; see notice.go.
+ if kind, msg, ok := addNotice.take(w, r); ok {
+ p.Flash, p.FlashKind = msg, kind
}
- render(w, "index", p)
+ render(w, "add", p)
}
// searchResults renders the results region alone, for search.js.
@@ -936,8 +944,8 @@ func (a *app) createDocument(w http.ResponseWriter, r *http.Request) {
writeOutcome(w, uploadOutcome{Stored: stored, Refused: refused, Lagging: lagging, Message: msg, Kind: kind})
return
}
- flash.Set(w, kind, msg)
- target := "/?stored=" + strconv.Itoa(stored)
+ addNotice.set(w, kind, msg)
+ target := "/add?stored=" + strconv.Itoa(stored)
if len(refused) > 0 {
target += "&refused=" + strconv.Itoa(len(refused))
}
@@ -994,8 +1002,8 @@ func (a *app) refuse(w http.ResponseWriter, r *http.Request, code string, n int)
writeOutcome(w, uploadOutcome{Message: msg, Kind: "danger"})
return
}
- flash.Set(w, "danger", msg)
- target := "/?err=" + code
+ addNotice.set(w, "danger", msg)
+ target := "/add?err=" + code
if n > 0 {
target += "&n=" + strconv.Itoa(n)
}
@@ -1104,7 +1112,7 @@ func (a *app) deleteDocument(w http.ResponseWriter, r *http.Request) {
// the log line — the id is a coordinate, not a passage, and it
// is the only thing logged.
a.logger.Error("delete document", "id", id, "err", err)
- flash.Set(w, "danger", newDropZone().DeleteFailed)
+ indexNotice.set(w, "danger", newDropZone().DeleteFailed)
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
diff --git a/internal/memoria/lag_test.go b/internal/memoria/lag_test.go
index d5662d6..64faee5 100644
--- a/internal/memoria/lag_test.go
+++ b/internal/memoria/lag_test.go
@@ -196,7 +196,7 @@ func TestTheScriptlessRedirectDoesNotCountAStoredDocumentAsRefused(t *testing.T)
"committed document any more", len(docs))
}
loc := rec.Header().Get("Location")
- if loc != "/?stored=1" {
+ if loc != "/add?stored=1" {
t.Errorf("the scriptless path redirected to %s after storing %d document. The counts in "+
"the URL are the fallback for a lost flash cookie, and a stored document "+
"counted as refused there is the same lie in fewer words.", loc, len(docs))
diff --git a/internal/memoria/notice.go b/internal/memoria/notice.go
new file mode 100644
index 0000000..a697229
--- /dev/null
+++ b/internal/memoria/notice.go
@@ -0,0 +1,74 @@
+package memoria
+
+import (
+ "encoding/base64"
+ "net/http"
+ "strings"
+)
+
+// A notice is a one-shot sentence carried across a redirect in a cookie:
+// rastrillo's flash, with one difference that is the reason this file
+// exists. Each page that shows a notice has its own cookie.
+//
+// With one shared cookie, whichever page loaded first took the notice,
+// whether or not it was the page the notice was about. After an upload,
+// visiting the index spent the sentence naming the refused files, so /add
+// was left with the bare counts in its URL. A failed delete was spent by
+// a visit to /add, which has no place to show it. Worst of all, /add's
+// own background refresh spent it, and that refresh only swaps the
+// footer, so nobody saw it at all.
+//
+// A notice may name a file, and a filename is content. This cookie is the
+// one place content travels outside a page body: HttpOnly, sixty
+// seconds, spent on first display, and sent only to the browser that is
+// about to be shown the same words. It never goes into a log line, an
+// error string or a URL.
+type notice string
+
+const (
+ // addNotice is the outcome of a submission, shown on /add.
+ addNotice notice = "memoria_notice_add"
+ // indexNotice is a failed delete, shown on the index.
+ indexNotice notice = "memoria_notice_index"
+)
+
+// refreshHeader marks drop.js's background re-fetch of the page. The
+// refresh swaps only the regions marked rst-refresh, and a notice is
+// never inside one, so the re-fetch must leave the notice unspent.
+const refreshHeader = "X-Memoria-Refresh"
+
+func (n notice) set(w http.ResponseWriter, kind, message string) {
+ http.SetCookie(w, &http.Cookie{
+ Name: string(n),
+ Value: base64.RawURLEncoding.EncodeToString([]byte(kind + "\x00" + message)),
+ Path: "/",
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ MaxAge: 60,
+ })
+}
+
+// take returns the notice and clears it. It returns nothing and clears
+// nothing on a refresh request, because the refresh never shows a notice.
+func (n notice) take(w http.ResponseWriter, r *http.Request) (kind, message string, ok bool) {
+ if r.Header.Get(refreshHeader) != "" {
+ return "", "", false
+ }
+ c, err := r.Cookie(string(n))
+ if err != nil {
+ return "", "", false
+ }
+ http.SetCookie(w, &http.Cookie{
+ Name: string(n), Value: "", Path: "/",
+ HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: -1,
+ })
+ data, err := base64.RawURLEncoding.DecodeString(c.Value)
+ if err != nil {
+ return "", "", false
+ }
+ kind, message, found := strings.Cut(string(data), "\x00")
+ if !found || message == "" {
+ return "", "", false
+ }
+ return kind, message, true
+}
diff --git a/internal/memoria/notice_test.go b/internal/memoria/notice_test.go
new file mode 100644
index 0000000..9c94e8d
--- /dev/null
+++ b/internal/memoria/notice_test.go
@@ -0,0 +1,86 @@
+package memoria
+
+import (
+ "net/http"
+ "net/http/cookiejar"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+// cookieBrowser carries cookies between requests the way a browser does,
+// deletions included, so a test can visit pages in an order and see what
+// each visit spent.
+type cookieBrowser struct {
+ t *testing.T
+ h http.Handler
+ jar *cookiejar.Jar
+}
+
+var cookieBase = &url.URL{Scheme: "http", Host: "example.com", Path: "/"}
+
+func newCookieBrowser(t *testing.T, h http.Handler) *cookieBrowser {
+ jar, err := cookiejar.New(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return &cookieBrowser{t: t, h: h, jar: jar}
+}
+
+func (b *cookieBrowser) keep(rec *httptest.ResponseRecorder) {
+ b.jar.SetCookies(cookieBase, rec.Result().Cookies())
+}
+
+func (b *cookieBrowser) visit(path string) string {
+ b.t.Helper()
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ for _, c := range b.jar.Cookies(cookieBase) {
+ req.AddCookie(c)
+ }
+ rec := httptest.NewRecorder()
+ b.h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ b.t.Fatalf("GET %s answered %d, want 200", path, rec.Code)
+ }
+ b.keep(rec)
+ return rec.Body.String()
+}
+
+// With both notices pending, each page shows its own once and leaves the
+// other alone.
+//
+// Astra, 2026-09-29: clearing the wrong cookie passed make ci. Taking the
+// index's notice then erased a pending upload refusal and left the delete
+// notice to be said again. Both orders are run because that mutation fails
+// differently in each.
+//
+// MUTATION-TESTED 2026-09-29, reverted: the clearing cookie in
+// notice.take named string(addNotice) instead of string(n) → "/add no
+// longer shows its notice after a visit to /" and "/ showed its notice a
+// second time (/ first)".
+func TestEachPageSpendsOnlyItsOwnNotice(t *testing.T) {
+ const uploaded, deleted = "scan.png is not text", "The delete did not happen"
+ pages := map[string]string{"/": deleted, "/add": uploaded}
+ for _, order := range [][2]string{{"/", "/add"}, {"/add", "/"}} {
+ h, _ := appWithAWorkingIndex(t)
+ b := newCookieBrowser(t, h)
+ seed := httptest.NewRecorder()
+ addNotice.set(seed, "danger", uploaded)
+ indexNotice.set(seed, "danger", deleted)
+ b.keep(seed)
+
+ first, second := order[0], order[1]
+ if page := b.visit(first); !strings.Contains(page, pages[first]) {
+ t.Fatalf("%s did not show its notice with both pending", first)
+ }
+ if page := b.visit(second); !strings.Contains(page, pages[second]) {
+ t.Errorf("%s no longer shows its notice after a visit to %s", second, first)
+ }
+ for path, said := range pages {
+ if strings.Contains(b.visit(path), said) {
+ t.Errorf("%s showed its notice a second time (%s first); it is one-shot", path, first)
+ }
+ }
+ }
+}
diff --git a/internal/memoria/render.go b/internal/memoria/render.go
index 7b9d4f1..d9b89ad 100644
--- a/internal/memoria/render.go
+++ b/internal/memoria/render.go
@@ -52,7 +52,7 @@ func funcs() template.FuncMap {
}
func init() {
- for _, name := range []string{"index", "document", "signin", "signin_oauth", "connectors", "notion", "home", "credits"} {
+ for _, name := range []string{"index", "add", "document", "signin", "signin_oauth", "connectors", "notion", "home", "credits"} {
pages[name] = template.Must(template.New("layout").
Funcs(funcs()).
ParseFS(appFS, "templates/layout.html", "templates/results.html", "templates/"+name+".html"))
diff --git a/internal/memoria/static/drop.js b/internal/memoria/static/drop.js
index 7846af7..fc7742d 100644
--- a/internal/memoria/static/drop.js
+++ b/internal/memoria/static/drop.js
@@ -23,8 +23,8 @@
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
+ already looking at, so the passage count 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.
@@ -35,7 +35,7 @@
An upload that dies mid-flight is not reported as a refusal
either: the request may have been received and stored in full with
only the answer lost, so that row says the fate is unknown and the
- tray offers a reload rather than pretending to know.
+ tray points to the document list rather than pretending to know.
4. Its words are the server's words. The four numbers, the sniff
window and every sentence come off the page as data attributes,
@@ -75,10 +75,9 @@
if (!tray) return;
var list = tray.querySelector("[rst-tray-list]");
var status = tray.querySelector("[rst-tray-status]");
- var stale = tray.querySelector("[rst-tray-stale]");
- var reload = tray.querySelector("[rst-tray-reload]");
+ var listed = tray.querySelector("[rst-tray-listed]");
var dismiss = tray.querySelector("[rst-tray-dismiss]");
- if (!list || !status || !stale || !reload || !dismiss) return;
+ if (!list || !status || !listed || !dismiss) return;
// The form that owns the input, and the one address anything is sent
// to. Read off the form rather than written down here, so a background
@@ -111,6 +110,11 @@
var maxInFlight = number("data-max-in-flight");
if (maxBytes === null || maxTotal === null || maxFiles === null) return;
if (sniffBytes === null || maxInFlight === null) return;
+ // The header that tells the server a re-fetch is not a visit. Its name
+ // is the server's, read off the page for the same reason the numbers
+ // are.
+ var refreshHeader = zone.getAttribute("data-refresh-header");
+ if (!refreshHeader) return;
var msg = {};
var keys = [
@@ -199,7 +203,7 @@
function hideTray() {
tray.setAttribute("rst-tray", "");
status.textContent = "";
- stale.hidden = true;
+ listed.hidden = true;
}
// addRow puts one file in the tray and returns the handles for
@@ -365,18 +369,14 @@
if (batch.unsure.length > 0) parts.push(unsureSentence(batch.unsure));
status.textContent = parts.join(" ");
- // 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.
- //
- // 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.
+ // The document list is on another page. Once a file has landed, or
+ // its fate is unknown, point there: for an unconfirmed file, looking
+ // at the list is the only way to find out. The pointer stays until
+ // the tray is dismissed, because it is true whether or not the
+ // refresh below arrives. The refresh brings the footer's passage
+ // count up to date.
if (batch.stored > 0 || batch.unsure.length > 0) {
- stale.hidden = false;
+ listed.hidden = false;
refresh();
}
@@ -386,22 +386,16 @@
// ---- 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.
+ // rendered before the drop is still on the screen — on this page, the
+ // passage count in the footer. This asks the server for the page the
+ // reader is ON again and moves its rst-refresh 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.
+ // own answer as nodes and go in as nodes.
//
// 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.
+ // cursor inside one — leaves the page as announce() left it.
// 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
@@ -421,6 +415,9 @@
// page admitting it, which is the one outcome worse than not having
// asked at all.
req.setRequestHeader("Cache-Control", "no-cache");
+ // Tells the server this is not a visit, so it leaves the page's
+ // one-shot notice for the visit that will show it (notice.go).
+ req.setRequestHeader(refreshHeader, "1");
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
@@ -432,8 +429,7 @@
};
// 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.
+ // exactly as announce() left it.
req.send();
}
@@ -468,16 +464,10 @@
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.
+ // The swap is otherwise silent. 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. It says
+ // nothing about any particular file.
var said = status.textContent;
status.textContent = said ? said + " " + msg.fresh : msg.fresh;
}
@@ -825,10 +815,6 @@
note.hidden = false;
tray.hidden = false;
hideTray();
- // A reload keeps whatever is on screen — the search someone is looking
- // at, not the front page. The href in the template is the fallback for
- // a browser that got this far and no further.
- reload.href = location.pathname + location.search;
mark("ready");
hint.hidden = false;
})();
diff --git a/internal/memoria/static/memoria.css b/internal/memoria/static/memoria.css
index 977febe..35d6235 100644
--- a/internal/memoria/static/memoria.css
+++ b/internal/memoria/static/memoria.css
@@ -190,7 +190,7 @@
* this whole arrangement exists to avoid.
*/
[rst-tray-status]:empty { margin: 0; }
-[rst-tray-stale] { color: var(--rst-text-muted); margin: 0.4rem 0 0; }
+[rst-tray-listed] { color: var(--rst-text-muted); margin: 0.4rem 0 0; }
[rst-results] { list-style: none; margin: 0; padding: 0; }
[rst-result] { border-top: 1px solid var(--rst-line); padding: 0.9rem 0; }
diff --git a/internal/memoria/static/search.js b/internal/memoria/static/search.js
index 4542bc2..77d0fb1 100644
--- a/internal/memoria/static/search.js
+++ b/internal/memoria/static/search.js
@@ -31,13 +31,11 @@
* link needs no ARIA to be announced, gets the browser's own
* focus ring, and Enter on it is just a link being followed.
*
- * SHARING THE REGION WITH drop.js. Both write to [rst-refresh="results"]
- * and neither owns it. drop.js re-fetches pathname+search when a drop
- * settles and REPLACES that node, so this file never holds a reference
- * to it — every read re-queries. In exchange, this file keeps
- * location.search in step with the box, so drop's re-fetch re-renders
- * the search that is actually on screen rather than painting the URL's
- * older query over it.
+ * THE REGION. Results go into [rst-refresh="results"]. This file never
+ * holds a reference to it — every read re-queries — so anything that
+ * replaces the node cannot leave it writing to a detached one. It keeps
+ * location.search in step with the box, so a reload or a bookmark
+ * carries the query the screen is showing.
*
* WHAT IS ON SCREEN. The server stamps the query it rendered onto the
* region as rst-search-results-for, and that attribute — never the input
@@ -202,12 +200,6 @@
if (window.history && history.replaceState) {
var url = q === "" ? location.pathname : location.pathname + "?q=" + encodeURIComponent(q);
history.replaceState(null, "", url);
- // drop.js sets the tray's reload link once, at load, from the URL
- // as it was then. Its own template promises that link keeps the
- // search on screen — true before live search existed, and false
- // from the first keystroke unless it is repointed here.
- var reload = document.querySelector("[rst-tray-reload]");
- if (reload) reload.setAttribute("href", url);
}
announce();
}
diff --git a/internal/memoria/templates/add.html b/internal/memoria/templates/add.html
new file mode 100644
index 0000000..7af1689
--- /dev/null
+++ b/internal/memoria/templates/add.html
@@ -0,0 +1,128 @@
+{{define "title"}}Add content — memoria{{end}}
+{{define "content"}}
+{{/* Adding content, on its own page (2026-09-29, Paul: "remove the Add
+ forms from the index … that should be a separate Add content form").
+ The form, the drop zone, the upload tray and the outcome message moved
+ here unchanged; POST /documents still receives them, and now redirects
+ back HERE with its outcome rather than to the index. The index keeps
+ search and the document list, and links here. */}}
+<p><a href="/">← Back to search</a></p>
+<section rst-section>
+ <h1>Add content</h1>
+ <form method="post" action="/documents" enctype="multipart/form-data" rst-stack>
+ <label for="title">Title <span rst-cell-mut>{{.Drop.TitleHint}}</span></label>
+ <input type="text" id="title" name="title">
+ <label for="body">Paste text</label>
+ <textarea id="body" name="body" rows="8" placeholder="Paste a document, notes, minutes…"></textarea>
+ <label for="file">{{.Drop.FileLabel}}</label>
+ {{/* The drop zone WRAPS the file input, it does not replace it. With
+ no script the div is an unstyled box, the hint and the note stay
+ hidden, and this is the same form it was: choose files, press
+ Store it. static/drop.js adds rst-dropzone="ready", which is what
+ the styling keys off — so a page whose script did not load never
+ draws a drop target that cannot take a drop.
+
+ What a DROP does has changed: it no longer fills this input and
+ wait for Store it. Each dropped file is posted on its own, in
+ the background, to this form's own action, and the tray below
+ the form says how each one went. Choosing files here and
+ pressing Store it is untouched and still posts the whole form.
+
+ The data attributes are the handler's own limits and the
+ handler's own words (see dropZone in handlers.go). The browser
+ reads them from here rather than carrying a second copy, so it
+ cannot come to refuse what the server would accept.
+
+ The input is `multiple`, and that is a plain HTML affordance
+ with no script behind it: createDocument iterates every part
+ named "file" and stores one document each, so a scriptless
+ chooser that hands it five files gets five documents. It was
+ deliberately single-file while the handler read only the first
+ part — four of five would have vanished without a word. */}}
+ {{with .Drop}}
+ <div rst-dropzone
+ data-max-bytes="{{.MaxBytes}}"
+ data-max-total-bytes="{{.MaxTotalBytes}}"
+ data-max-files="{{.MaxFiles}}"
+ data-sniff-bytes="{{.SniffBytes}}"
+ data-max-in-flight="{{.MaxInFlight}}"
+ data-refresh-header="{{.RefreshHeader}}"
+ data-msg-toomany="{{.TooManyFiles}}"
+ data-msg-toototal="{{.TooMuchAtOnce}}"
+ data-msg-folder="{{.Folder}}"
+ data-msg-blocked="{{.Blocked}}"
+ data-msg-waiting="{{.Waiting}}"
+ data-msg-uploading="{{.Uploading}}"
+ data-msg-storing="{{.Storing}}"
+ data-msg-row-stored="{{.RowStored}}"
+ 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}}"
+ data-msg-unsure-many="{{.UnsureMany}}"
+ data-reason-toobig="{{.ReasonTooBig}}"
+ data-reason-binary="{{.ReasonBinary}}"
+ data-reason-empty="{{.ReasonEmpty}}"
+ data-reason-unreadable="{{.ReasonUnreadable}}"
+ data-reason-noanswer="{{.ReasonNoAnswer}}">
+ <input type="file" id="file" name="file" multiple accept=".txt,.md,.markdown,.csv,text/plain,text/markdown">
+ {{/* No rst-cell-mut here: that utility is a class, `.rst-cell-mut`,
+ and spelled as an attribute it styles nothing at all — the hint
+ would have looked muted in the markup and read as body text on
+ the screen. The class itself is wrong for a sentence anyway
+ (nowrap plus an ellipsis, written for a table cell), so the
+ muting lives in memoria.css on [rst-dropzone-hint].
+
+ Both paragraphs are `hidden` so a scriptless page promises no
+ drop target. drop.js unhides the note while it is still EMPTY,
+ before it ever writes into it: a live region that enters the
+ accessibility tree in the same tick as its first message is
+ commonly not announced at all, and a refusal nobody hears is
+ the one outcome this feature must not have. */}}
+ <p rst-dropzone-hint hidden>{{.Hint}}</p>
+ <p rst-dropzone-note role="status" aria-live="polite" hidden></p>
+ </div>
+ {{end}}
+ <div rst-form-bar><button rst-btn="primary">Store it</button></div>
+ </form>
+ {{if .Flash}}<div rst-callout rst-tone="{{.FlashKind | tone}}"><p rst-callout-body>{{.Flash}}</p></div>{{end}}
+
+ {{/* The upload tray. It lives OUTSIDE the form on purpose: it is not
+ something the form submits, and a scriptless browser must find the
+ form exactly as it was. Scriptless it is `hidden` and empty, so it
+ is not on the page at all.
+
+ drop.js unhides it — still empty — at its ready step, for the same
+ reason it unhides the note there: [rst-tray-status] is the live
+ region that announces what a drop did, and a live region that
+ enters the accessibility tree in the same tick as its first
+ message is commonly not announced at all. Only [rst-tray-status]
+ is live. The rows are not, and must not be: five files uploading
+ is five rows changing several times a second, and a live region
+ over that is a screen reader nobody can use.
+
+ Every word here comes from dropZone in handlers.go, like the data
+ attributes above — the tray and the flash sentence a scriptless
+ browser sees are the same strings, so they cannot drift.
+
+ The document list is on the index, not here, so once anything is
+ stored the tray points there with a real link. Nothing follows it
+ on its own: someone mid-drop does not want the ground moving under
+ them. When a drop settles drop.js re-fetches this page and swaps
+ its one rst-refresh region, the passage count in the footer. */}}
+ {{with .Drop}}
+ <div rst-tray hidden role="region" aria-labelledby="tray-title">
+ <div rst-tray-head>
+ <span rst-tray-title id="tray-title">{{.TrayTitle}}</span>
+ <button type="button" rst-btn="quiet compact" rst-tray-dismiss>{{.TrayDismiss}}</button>
+ </div>
+ <ul rst-tray-list></ul>
+ <p rst-tray-status role="status" aria-live="polite"></p>
+ <p rst-tray-listed hidden>{{.Listed}} <a href="/">{{.ViewDocs}}</a></p>
+ </div>
+ {{end}}
+</section>
+{{end}}
diff --git a/internal/memoria/templates/index.html b/internal/memoria/templates/index.html
index 6ced7c3..efd4d7f 100644
--- a/internal/memoria/templates/index.html
+++ b/internal/memoria/templates/index.html
@@ -7,34 +7,11 @@
<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.
-
- search.js writes into this SAME region — one region, two writers,
- deliberately. Two divs would have meant two answers to "what is on
- screen" and a drop landing mid-search repainting one of them.
- Because drop.js replaces the node rather than its contents,
- search.js re-queries this element every time instead of holding a
- reference, and it keeps location.search in step so that drop's
- re-fetch of pathname+search re-renders the search actually on
- screen.
+{{/* The results region. search.js writes live results into it, and it
+ is here rather than around the {{if}} because an absent region has
+ nothing to write into: on a page with no search this empty div is
+ where the first results go. Nothing the person is typing into is
+ inside it.
rst-search-results-for is the query these results were rendered
from, written by the server. search.js reads it instead of assuming
@@ -46,133 +23,13 @@
rst-search-results-for="{{.Query}}"
rst-search-stale-note="{{template "searchFailed"}}">{{template "results" .}}</div>
-<section rst-section>
- <h2>Add something</h2>
- <form method="post" action="/documents" enctype="multipart/form-data" rst-stack>
- <label for="title">Title <span rst-cell-mut>{{.Drop.TitleHint}}</span></label>
- <input type="text" id="title" name="title">
- <label for="body">Paste text</label>
- <textarea id="body" name="body" rows="8" placeholder="Paste a document, notes, minutes…"></textarea>
- <label for="file">{{.Drop.FileLabel}}</label>
- {{/* The drop zone WRAPS the file input, it does not replace it. With
- no script the div is an unstyled box, the hint and the note stay
- hidden, and this is the same form it was: choose files, press
- Store it. static/drop.js adds rst-dropzone="ready", which is what
- the styling keys off — so a page whose script did not load never
- draws a drop target that cannot take a drop.
-
- What a DROP does has changed: it no longer fills this input and
- wait for Store it. Each dropped file is posted on its own, in
- the background, to this form's own action, and the tray below
- the form says how each one went. Choosing files here and
- pressing Store it is untouched and still posts the whole form.
-
- The data attributes are the handler's own limits and the
- handler's own words (see dropZone in handlers.go). The browser
- reads them from here rather than carrying a second copy, so it
- cannot come to refuse what the server would accept.
-
- The input is `multiple`, and that is a plain HTML affordance
- with no script behind it: createDocument iterates every part
- named "file" and stores one document each, so a scriptless
- chooser that hands it five files gets five documents. It was
- deliberately single-file while the handler read only the first
- part — four of five would have vanished without a word. */}}
- {{with .Drop}}
- <div rst-dropzone
- data-max-bytes="{{.MaxBytes}}"
- data-max-total-bytes="{{.MaxTotalBytes}}"
- data-max-files="{{.MaxFiles}}"
- data-sniff-bytes="{{.SniffBytes}}"
- data-max-in-flight="{{.MaxInFlight}}"
- data-msg-toomany="{{.TooManyFiles}}"
- data-msg-toototal="{{.TooMuchAtOnce}}"
- data-msg-folder="{{.Folder}}"
- data-msg-blocked="{{.Blocked}}"
- data-msg-waiting="{{.Waiting}}"
- data-msg-uploading="{{.Uploading}}"
- data-msg-storing="{{.Storing}}"
- data-msg-row-stored="{{.RowStored}}"
- 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}}"
- data-msg-unsure-many="{{.UnsureMany}}"
- data-reason-toobig="{{.ReasonTooBig}}"
- data-reason-binary="{{.ReasonBinary}}"
- data-reason-empty="{{.ReasonEmpty}}"
- data-reason-unreadable="{{.ReasonUnreadable}}"
- data-reason-noanswer="{{.ReasonNoAnswer}}">
- <input type="file" id="file" name="file" multiple accept=".txt,.md,.markdown,.csv,text/plain,text/markdown">
- {{/* No rst-cell-mut here: that utility is a class, `.rst-cell-mut`,
- and spelled as an attribute it styles nothing at all — the hint
- would have looked muted in the markup and read as body text on
- the screen. The class itself is wrong for a sentence anyway
- (nowrap plus an ellipsis, written for a table cell), so the
- muting lives in memoria.css on [rst-dropzone-hint].
-
- Both paragraphs are `hidden` so a scriptless page promises no
- drop target. drop.js unhides the note while it is still EMPTY,
- before it ever writes into it: a live region that enters the
- accessibility tree in the same tick as its first message is
- commonly not announced at all, and a refusal nobody hears is
- the one outcome this feature must not have. */}}
- <p rst-dropzone-hint hidden>{{.Hint}}</p>
- <p rst-dropzone-note role="status" aria-live="polite" hidden></p>
- </div>
- {{end}}
- <div rst-form-bar><button rst-btn="primary">Store it</button></div>
- </form>
- {{if .Flash}}<div rst-callout rst-tone="{{.FlashKind | tone}}"><p rst-callout-body>{{.Flash}}</p></div>{{end}}
+{{/* Adding content lives at /add now. The one message that still lands
+ on this page is a failed delete, which redirects here. */}}
+{{if .Flash}}<div rst-callout rst-tone="{{.FlashKind | tone}}"><p rst-callout-body>{{.Flash}}</p></div>{{end}}
- {{/* The upload tray. It lives OUTSIDE the form on purpose: it is not
- something the form submits, and a scriptless browser must find the
- form exactly as it was. Scriptless it is `hidden` and empty, so it
- is not on the page at all.
-
- drop.js unhides it — still empty — at its ready step, for the same
- reason it unhides the note there: [rst-tray-status] is the live
- region that announces what a drop did, and a live region that
- enters the accessibility tree in the same tick as its first
- message is commonly not announced at all. Only [rst-tray-status]
- is live. The rows are not, and must not be: five files uploading
- is five rows changing several times a second, and a live region
- over that is a screen reader nobody can use.
-
- Every word here comes from dropZone in handlers.go, like the data
- attributes above — the tray and the flash sentence a scriptless
- browser sees are the same strings, so they cannot drift.
-
- 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.
-
- 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>
- <span rst-tray-title id="tray-title">{{.TrayTitle}}</span>
- <button type="button" rst-btn="quiet compact" rst-tray-dismiss>{{.TrayDismiss}}</button>
- </div>
- <ul rst-tray-list></ul>
- <p rst-tray-status role="status" aria-live="polite"></p>
- <p rst-tray-stale hidden>{{.Stale}} <a rst-tray-reload href="/">{{.Reload}}</a></p>
- </div>
- {{end}}
-</section>
-
-<section rst-section rst-refresh="documents">
+<section rst-section>
<h2>{{len .Documents}} document{{if ne (len .Documents) 1}}s{{end}}</h2>
+ <p><a rst-btn="primary" href="/add">Add content</a></p>
{{if not .Documents}}
<p rst-cell-mut>Nothing stored yet.</p>
{{else}}
diff --git a/internal/memoria/templates/layout.html b/internal/memoria/templates/layout.html
index 159c0c1..9dafaa4 100644
--- a/internal/memoria/templates/layout.html
+++ b/internal/memoria/templates/layout.html
@@ -46,9 +46,9 @@
{{template "content" .}}
</main>
{{block "footer" .}}<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.
+ {{/* An upload that never navigates cannot change the passage count,
+ so it carries rst-refresh and drop.js swaps it out of a re-fetch
+ of the page. On /add it is the only region that does.
The span wraps ONLY the count: the egress claim beside it is not
derived from anything and has no business travelling with it.
diff --git a/internal/memoriatest/app_test.go b/internal/memoriatest/app_test.go
index 283606d..c28b0b4 100644
--- a/internal/memoriatest/app_test.go
+++ b/internal/memoriatest/app_test.go
@@ -120,7 +120,7 @@ func TestEmptySubmissionIsRefusedWithCopy(t *testing.T) {
if rec := store(t, h, "", " "); rec.Code != http.StatusSeeOther {
t.Fatalf("empty store: status %d, want 303", rec.Code)
}
- if !strings.Contains(get(t, h, "/?err=empty").Body.String(), "nothing to store") {
+ if !strings.Contains(get(t, h, "/add?err=empty").Body.String(), "nothing to store") {
t.Error("the empty-submission page does not say what went wrong")
}
}
diff --git a/internal/memoriatest/dom_test.go b/internal/memoriatest/dom_test.go
new file mode 100644
index 0000000..377a783
--- /dev/null
+++ b/internal/memoriatest/dom_test.go
@@ -0,0 +1,92 @@
+package memoriatest
+
+import (
+ "strings"
+ "testing"
+
+ "golang.org/x/net/html"
+)
+
+// The helpers in this file read a page the way a browser does, through
+// an HTML parser, so a check cannot be passed by spelling the same markup
+// differently: rst-refresh='x', RST-REFRESH="x" and rst-refresh=x are one
+// attribute to a browser and three strings to a regex.
+
+func parsePage(t *testing.T, body string) *html.Node {
+ t.Helper()
+ doc, err := html.Parse(strings.NewReader(body))
+ if err != nil {
+ t.Fatalf("parsing the page: %v", err)
+ }
+ return doc
+}
+
+func attrOf(n *html.Node, key string) (string, bool) {
+ for _, a := range n.Attr {
+ if a.Key == key {
+ return a.Val, true
+ }
+ }
+ return "", false
+}
+
+func findAll(root *html.Node, match func(*html.Node) bool) []*html.Node {
+ var out []*html.Node
+ var walk func(*html.Node)
+ walk = func(n *html.Node) {
+ if n.Type == html.ElementNode && match(n) {
+ out = append(out, n)
+ }
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ walk(c)
+ }
+ }
+ walk(root)
+ return out
+}
+
+func hasAttr(key string) func(*html.Node) bool {
+ return func(n *html.Node) bool { _, ok := attrOf(n, key); return ok }
+}
+
+func elementWith(tag, key, val string) func(*html.Node) bool {
+ return func(n *html.Node) bool {
+ v, ok := attrOf(n, key)
+ return n.Data == tag && ok && v == val
+ }
+}
+
+// within reports whether n is inside ancestor, or is it.
+func within(n, ancestor *html.Node) bool {
+ for ; n != nil; n = n.Parent {
+ if n == ancestor {
+ return true
+ }
+ }
+ return false
+}
+
+func textOf(n *html.Node) string {
+ var b strings.Builder
+ var walk func(*html.Node)
+ walk = func(n *html.Node) {
+ if n.Type == html.TextNode {
+ b.WriteString(n.Data)
+ }
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ walk(c)
+ }
+ }
+ walk(n)
+ return strings.Join(strings.Fields(b.String()), " ")
+}
+
+// region returns the one element carrying rst-refresh=name.
+func region(t *testing.T, doc *html.Node, name string) *html.Node {
+ t.Helper()
+ found := findAll(doc, func(n *html.Node) bool { v, ok := attrOf(n, "rst-refresh"); return ok && v == name })
+ if len(found) != 1 {
+ t.Fatalf("%d regions named %q on the page, want 1", len(found), name)
+ }
+ return found[0]
+}
diff --git a/internal/memoriatest/drop_test.go b/internal/memoriatest/drop_test.go
index d4127a5..99b5b75 100644
--- a/internal/memoriatest/drop_test.go
+++ b/internal/memoriatest/drop_test.go
@@ -130,7 +130,7 @@ func dropZoneTag(t *testing.T, body string) string {
// (d) writing rst-dropzone="ready" in the template → "the drop zone is
// pre-marked in the template"
func TestTheUploadFormStillWorksWithoutScript(t *testing.T) {
- body := get(t, newApp(t), "/").Body.String()
+ body := get(t, newApp(t), "/add").Body.String()
// Isolate the form that posts to /documents. Everything below is
// asserted inside it, never against the whole page.
@@ -468,7 +468,7 @@ func TestTheDropScriptPostsOnlyToTheFormsOwnAction(t *testing.T) {
// each of those halves.
func TestTheAdvertisedCapIsTheHandlersCap(t *testing.T) {
h := newApp(t)
- cap := atoi(t, dropZoneAttr(t, get(t, h, "/").Body.String(), "data-max-bytes"))
+ cap := atoi(t, dropZoneAttr(t, get(t, h, "/add").Body.String(), "data-max-bytes"))
if cap < 1024 {
t.Fatalf("data-max-bytes is %d, which no real cap is; the tests below would prove nothing", cap)
}
@@ -477,7 +477,7 @@ func TestTheAdvertisedCapIsTheHandlersCap(t *testing.T) {
// tested, but a document it cannot split is not a fair sample.
filler := "The quarterly figure was recorded in full and then again.\n\n"
at := strings.Repeat(filler, cap/len(filler)+1)[:cap]
- if rec := upload(t, h, "at-the-cap.txt", at); rec.Header().Get("Location") != "/?stored=1" {
+ if rec := upload(t, h, "at-the-cap.txt", at); rec.Header().Get("Location") != "/add?stored=1" {
t.Errorf("a file of exactly the advertised cap (%d bytes) was refused with %q; "+
"the page tells the browser it will be taken", cap, rec.Header().Get("Location"))
}
@@ -487,7 +487,7 @@ func TestTheAdvertisedCapIsTheHandlersCap(t *testing.T) {
// the whole of what a person is told and it is the half that can
// silently become "stored".
rec := upload(t, h, "over-the-cap.txt", at+"x")
- if loc := rec.Header().Get("Location"); loc != "/?stored=0&refused=1" {
+ if loc := rec.Header().Get("Location"); loc != "/add?stored=0&refused=1" {
t.Errorf("the page advertises a cap of %d bytes but the handler answered %q for a "+
"file 1 byte over it; the browser would refuse a file this server accepts, "+
"or the other way round", cap, loc)
@@ -524,7 +524,7 @@ func TestTheAdvertisedCapIsTheHandlersCap(t *testing.T) {
// stays green on purpose.
func TestTheAdvertisedSniffWindowIsTheHandlersWindow(t *testing.T) {
h := newApp(t)
- window := atoi(t, dropZoneAttr(t, get(t, h, "/").Body.String(), "data-sniff-bytes"))
+ window := atoi(t, dropZoneAttr(t, get(t, h, "/add").Body.String(), "data-sniff-bytes"))
if window < 16 {
t.Fatalf("data-sniff-bytes is %d; the tests below would prove nothing", window)
}
@@ -532,7 +532,7 @@ func TestTheAdvertisedSniffWindowIsTheHandlersWindow(t *testing.T) {
// A NUL at the last byte the window covers: refused, by name.
inside := strings.Repeat("a", window-1) + "\x00" + strings.Repeat("b", 64)
rec := upload(t, h, "inside.txt", inside)
- if loc := rec.Header().Get("Location"); loc != "/?stored=0&refused=1" {
+ if loc := rec.Header().Get("Location"); loc != "/add?stored=0&refused=1" {
t.Errorf("a NUL at byte %d — the last byte inside the advertised %d-byte window — "+
"was answered %q, want a refusal", window-1, window, loc)
} else if said := outcome(t, h, rec); !strings.Contains(said, "inside.txt") {
@@ -543,9 +543,9 @@ func TestTheAdvertisedSniffWindowIsTheHandlersWindow(t *testing.T) {
// looks that far. If this now fails, the handler reads further than
// the page says it does and the browser's check has gone blind.
outside := strings.Repeat("a", window) + "\x00" + strings.Repeat("b", 64)
- if loc := upload(t, h, "outside.txt", outside).Header().Get("Location"); loc != "/?stored=1" {
+ if loc := upload(t, h, "outside.txt", outside).Header().Get("Location"); loc != "/add?stored=1" {
t.Errorf("a NUL at byte %d — one past the advertised %d-byte window — was answered "+
- "%q, want /?stored=1; the handler inspects more than the page tells the "+
+ "%q, want /add?stored=1; the handler inspects more than the page tells the "+
"browser it does", window, window, loc)
}
}
@@ -576,7 +576,7 @@ func TestTheAdvertisedSniffWindowIsTheHandlersWindow(t *testing.T) {
// says so from the page's side
func TestTheAdvertisedFileCountCapIsTheHandlersCap(t *testing.T) {
h := newApp(t)
- maxFiles := atoi(t, dropZoneAttr(t, get(t, h, "/").Body.String(), "data-max-files"))
+ maxFiles := atoi(t, dropZoneAttr(t, get(t, h, "/add").Body.String(), "data-max-files"))
if maxFiles < 2 || maxFiles > 200 {
t.Fatalf("data-max-files is %d; the drop built below would prove nothing", maxFiles)
}
@@ -628,7 +628,7 @@ func TestTheAdvertisedFileCountCapIsTheHandlersCap(t *testing.T) {
// fails here rather than passing quietly.
func TestTheAdvertisedTotalCapIsTheHandlersCap(t *testing.T) {
h := newApp(t)
- body := get(t, h, "/").Body.String()
+ body := get(t, h, "/add").Body.String()
total := atoi(t, dropZoneAttr(t, body, "data-max-total-bytes"))
per := atoi(t, dropZoneAttr(t, body, "data-max-bytes"))
if total <= per {
@@ -675,7 +675,7 @@ func TestTheAdvertisedTotalCapIsTheHandlersCap(t *testing.T) {
// green, which is what sharing the sentence is for.
func TestTheBrowserRefusesInTheHandlersWords(t *testing.T) {
h := newApp(t)
- page := get(t, h, "/").Body.String()
+ page := get(t, h, "/add").Body.String()
for _, c := range []struct {
attr, name, body string
@@ -703,7 +703,7 @@ func TestTheBrowserRefusesInTheHandlersWords(t *testing.T) {
// what ships is what the asset route serves.
func servedDropScript(t *testing.T, h http.Handler) string {
t.Helper()
- body := get(t, h, "/").Body.String()
+ body := get(t, h, "/add").Body.String()
m := regexp.MustCompile(`<script[^>]+src="([^"]*drop[^"]*\.js[^"]*)"`).FindStringSubmatch(body)
if m == nil {
t.Fatalf("the page links no drop script:\n%s", body)
@@ -783,7 +783,7 @@ func TestTheLiveRegionExistsBeforeItHasAnythingToSay(t *testing.T) {
}
// The rendered note is still a live region at all.
- tag := regexp.MustCompile(`<p rst-dropzone-note[^>]*>`).FindString(get(t, h, "/").Body.String())
+ tag := regexp.MustCompile(`<p rst-dropzone-note[^>]*>`).FindString(get(t, h, "/add").Body.String())
if !strings.Contains(tag, `aria-live="polite"`) || !strings.Contains(tag, `role="status"`) {
t.Errorf("the note is not a polite live region, so unhiding it early buys nothing: %s", tag)
}
@@ -830,7 +830,7 @@ func TestTheLiveRegionExistsBeforeItHasAnythingToSay(t *testing.T) {
// the point: one number, said everywhere from one place.
func TestTheRefusalsNameTheCapsTheyEnforce(t *testing.T) {
h := newApp(t)
- body := get(t, h, "/").Body.String()
+ body := get(t, h, "/add").Body.String()
capBytes := atoi(t, dropZoneAttr(t, body, "data-max-bytes"))
totalBytes := atoi(t, dropZoneAttr(t, body, "data-max-total-bytes"))
@@ -945,7 +945,10 @@ func TestEveryFileInADropBecomesItsOwnDocument(t *testing.T) {
}
rec := submit(t, h, "", "", files...)
- titles := storedTitles(t, follow(t, h, rec).Body.String())
+ // The redirect lands on /add, which carries the outcome; the document
+ // list stayed on the index when adding moved to its own page.
+ follow(t, h, rec)
+ titles := storedTitles(t, get(t, h, "/").Body.String())
if len(titles) != len(files) {
t.Fatalf("%d files stored %d documents: %v", len(files), len(titles), titles)
}
@@ -999,7 +1002,10 @@ func TestAPartialDropStoresTheGoodOnesAndNamesTheBad(t *testing.T) {
)
page := follow(t, h, rec).Body.String()
- titles := storedTitles(t, page)
+ // The outcome is on /add, where the redirect lands; the list stayed
+ // on the index when adding moved to its own page.
+ list := get(t, h, "/").Body.String()
+ titles := storedTitles(t, list)
if len(titles) != 4 {
t.Fatalf("4 good files among 5 stored %d documents: %v", len(titles), titles)
}
@@ -1062,7 +1068,10 @@ func TestTheStoredCountIsTheNumberActuallyStored(t *testing.T) {
)
page := follow(t, h, rec).Body.String()
- rows := len(storedTitles(t, page))
+ // The outcome is on /add, where the redirect lands; the list stayed
+ // on the index when adding moved to its own page.
+ list := get(t, h, "/").Body.String()
+ rows := len(storedTitles(t, list))
said := flashText(t, page)
m := regexp.MustCompile(`Stored (\d+) documents?\.`).FindStringSubmatch(said)
@@ -1072,12 +1081,12 @@ func TestTheStoredCountIsTheNumberActuallyStored(t *testing.T) {
}
if got := atoi(t, m[1]); got != rows {
t.Errorf("the outcome says %d documents were stored but the list holds %d.\n %q\n %v",
- got, rows, said, storedTitles(t, page))
+ got, rows, said, storedTitles(t, list))
}
// Three files and the paste box: four. Stated so a change to the
// fixture cannot quietly make the comparison above trivial.
if rows != 4 {
- t.Errorf("3 good files and a paste stored %d documents, want 4: %v", rows, storedTitles(t, page))
+ t.Errorf("3 good files and a paste stored %d documents, want 4: %v", rows, storedTitles(t, list))
}
}
@@ -1146,8 +1155,11 @@ func TestThePasteBoxIsOneMoreDocumentAndIsNeverDiscarded(t *testing.T) {
file{"costs.txt", "Hosting costs fell after the migration completed."},
)
- page := follow(t, h, rec).Body.String()
- titles := storedTitles(t, page)
+ follow(t, h, rec)
+ // The outcome is on /add, where the redirect lands; the list stayed
+ // on the index when adding moved to its own page.
+ list := get(t, h, "/").Body.String()
+ titles := storedTitles(t, list)
if len(titles) != 3 {
t.Fatalf("a paste submitted alongside 2 files stored %d documents, want 3: %v",
len(titles), titles)
@@ -1194,7 +1206,7 @@ func TestThePasteBoxIsOneMoreDocumentAndIsNeverDiscarded(t *testing.T) {
// document"
func TestTheScriptlessFormStoresEveryFileItSubmits(t *testing.T) {
h := newApp(t)
- body := get(t, h, "/").Body.String()
+ body := get(t, h, "/add").Body.String()
i := strings.Index(body, `action="/documents"`)
if i < 0 {
diff --git a/internal/memoriatest/gates_test.go b/internal/memoriatest/gates_test.go
index bcf1481..15d1c80 100644
--- a/internal/memoriatest/gates_test.go
+++ b/internal/memoriatest/gates_test.go
@@ -52,8 +52,8 @@ func TestPlaintextNeverReachesTheDatabase(t *testing.T) {
if rec.Code != 303 {
t.Fatalf("store: status %d, want 303", rec.Code)
}
- if loc := rec.Header().Get("Location"); loc != "/?stored=1" {
- t.Fatalf("store redirected to %q, want /?stored=1 — the document was not stored, "+
+ if loc := rec.Header().Get("Location"); loc != "/add?stored=1" {
+ t.Fatalf("store redirected to %q, want /add?stored=1 — the document was not stored, "+
"so the scan below would pass vacuously", loc)
}
if got := get(t, h, "/?q=zebrafinch").Body.String(); !strings.Contains(got, "<mark>") {
@@ -123,7 +123,7 @@ func TestAMultiFileDropNeverSpillsPlaintextToATempFile(t *testing.T) {
// The vacuity guard. A submission that was refused would leave the
// directory empty and this gate would pass having watched nothing.
- if loc := rec.Header().Get("Location"); loc != "/?stored=12" {
+ if loc := rec.Header().Get("Location"); loc != "/add?stored=12" {
t.Fatalf("the drop answered %q rather than storing all 12; the scan below would "+
"prove nothing", loc)
}
diff --git a/internal/memoriatest/notice_test.go b/internal/memoriatest/notice_test.go
new file mode 100644
index 0000000..b8f0b09
--- /dev/null
+++ b/internal/memoriatest/notice_test.go
@@ -0,0 +1,303 @@
+package memoriatest
+
+import (
+ "net/http"
+ "net/http/cookiejar"
+ "net/http/httptest"
+ "net/url"
+ "regexp"
+ "strings"
+ "testing"
+
+ "golang.org/x/net/html"
+)
+
+// browser carries cookies from one request to the next the way a browser
+// does, deletions included, so a test can visit pages in an order and see
+// what each visit spent.
+type browser struct {
+ t *testing.T
+ h http.Handler
+ jar *cookiejar.Jar
+}
+
+func newBrowser(t *testing.T, h http.Handler) *browser {
+ jar, err := cookiejar.New(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return &browser{t: t, h: h, jar: jar}
+}
+
+var browserBase = &url.URL{Scheme: "http", Host: "example.com", Path: "/"}
+
+func (b *browser) keep(rec *httptest.ResponseRecorder) *httptest.ResponseRecorder {
+ b.jar.SetCookies(browserBase, rec.Result().Cookies())
+ return rec
+}
+
+// visit GETs path with the jar's cookies. A non-empty header marks it as
+// drop.js's background re-fetch.
+func (b *browser) visit(path, header string) string {
+ b.t.Helper()
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ for _, c := range b.jar.Cookies(browserBase) {
+ req.AddCookie(c)
+ }
+ if header != "" {
+ req.Header.Set(header, "1")
+ }
+ rec := httptest.NewRecorder()
+ b.h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ b.t.Fatalf("GET %s answered %d, want 200", path, rec.Code)
+ }
+ return b.keep(rec).Body.String()
+}
+
+// An upload's notice is spent on /add and nowhere else.
+//
+// The sentence naming a refused file travels in a cookie, because a
+// filename must not go in a URL. With one cookie shared by every page, a
+// visit to the index spent it, and so did /add's own background refresh.
+// Either way /add was left with the bare counts in its URL, and the
+// refresh case showed the sentence to nobody.
+//
+// MUTATION-TESTED 2026-09-29, each reverted:
+//
+// (a) index() takes addNotice instead of indexNotice → "a visit to the
+// index showed the upload's notice, which belongs on /add"
+// (b) the refresh-header check deleted from notice.take → "drop.js's
+// background refresh showed the upload's notice, which belongs on
+// /add"
+// (c) the drop zone's data-refresh-header hard-coded to a name notice.go
+// does not check → the same line as (b). The refresh is sent with the
+// header the PAGE names, which is what drop.js sends.
+func TestAnUploadNoticeWaitsForTheAddPage(t *testing.T) {
+ for _, first := range []struct {
+ what string
+ path string
+ refresh bool
+ }{
+ {"a visit to the index", "/", false},
+ {"drop.js's background refresh", "/add", true},
+ } {
+ h := newApp(t)
+ b := newBrowser(t, h)
+ rec := b.keep(submit(t, h, "", "",
+ file{"notes.txt", "Revenue was 100 across the first region we serve."},
+ file{"scan.png", "\x89PNG\x00\x00\x00 binary payload that is not text"}))
+ loc := rec.Header().Get("Location")
+ if !strings.HasPrefix(loc, "/add?") {
+ t.Fatalf("the submission redirected to %q, want /add", loc)
+ }
+
+ header := ""
+ if first.refresh {
+ header = refreshHeaderOn(t, get(t, h, "/add").Body.String())
+ }
+ if page := b.visit(first.path, header); strings.Contains(page, "scan.png") {
+ t.Errorf("%s showed the upload's notice, which belongs on /add", first.what)
+ }
+ if said := flashText(t, b.visit(loc, "")); !strings.Contains(said, "scan.png") {
+ t.Errorf("after %s, /add no longer names the refused file: %q", first.what, said)
+ }
+ if page := b.visit("/add", ""); strings.Contains(page, "scan.png") {
+ t.Error("the notice was shown twice; it is one-shot")
+ }
+ }
+}
+
+// refreshHeaderOn is the header name /add tells drop.js to send with its
+// re-fetch. Tests send what the page names, never a name of their own, so
+// a page and a server that disagree are caught here.
+func refreshHeaderOn(t *testing.T, addPage string) string {
+ t.Helper()
+ zones := findAll(parsePage(t, addPage), hasAttr("rst-dropzone"))
+ if len(zones) != 1 {
+ t.Fatalf("%d drop zones on /add, want 1", len(zones))
+ }
+ name, _ := attrOf(zones[0], "data-refresh-header")
+ if name == "" {
+ t.Fatal("the drop zone names no refresh header, so drop.js stops before it starts")
+ }
+ return name
+}
+
+// jsFunction returns the body of the named function declaration in src,
+// braces matched. drop.js writes no braces inside strings in the functions
+// this is used on; a body that does not close is a fatal, not a pass.
+func jsFunction(t *testing.T, src, name string) string {
+ t.Helper()
+ at := strings.Index(src, "function "+name+"(")
+ if at < 0 {
+ t.Fatalf("drop.js has no function %s", name)
+ }
+ open := strings.Index(src[at:], "{")
+ if open < 0 {
+ t.Fatalf("function %s has no body", name)
+ }
+ depth := 0
+ for i := at + open; i < len(src); i++ {
+ switch src[i] {
+ case '{':
+ depth++
+ case '}':
+ depth--
+ if depth == 0 {
+ return src[at+open : i+1]
+ }
+ }
+ }
+ t.Fatalf("function %s never closes", name)
+ return ""
+}
+
+// assignments counts every assignment to name, compound ones included —
+// the same rule drop_test.go applies to `endpoint`.
+func assignments(src, name string) int {
+ return len(regexp.MustCompile(`\b`+name+`\s*(?:>>>|\*\*|<<|>>|\|\||&&|\?\?|[-+*/%&|^])?=[^=]`).FindAllString(src, -1))
+}
+
+// drop.js sends the header the page names, and no other.
+//
+// Go cannot run the script, so this reads it, and what it can hold is
+// structure: there is no header name in drop.js to drift, the one
+// variable holding the page's name is assigned once — at the read — and
+// the re-fetch in refresh() is the one place it is sent. The behaviour
+// (the header a real re-fetch carries, and the notice surviving it) was
+// watched in Chromium by Astra on 2026-09-29; TestAnUploadNoticeWaitsForTheAddPage
+// pins that the server honours the name the page publishes.
+//
+// MUTATION-TESTED 2026-09-29, each reverted:
+//
+// (a) setRequestHeader(refreshHeader, "1") → setRequestHeader(
+// "X-Memoria-Refresh-Count", "1"), Astra's divergence → "drop.js
+// does not send the header it read off the page"
+// (b) the attribute read changed to data-refresh → "drop.js does not
+// read its refresh header off the drop zone"
+// (c) Astra's: `refreshHeader += "-Count";` after the read → "drop.js
+// assigns refreshHeader 2 times"
+// (d) the setRequestHeader line deleted from refresh() → "drop.js sends
+// refreshHeader in 0 places, want 1"
+// (e) Astra's: that line moved above req.open in refresh(). Chromium
+// throws InvalidStateError and the footer is never refreshed →
+// "req.setRequestHeader comes before req.open"
+func TestTheDropScriptSendsTheHeaderThePageNames(t *testing.T) {
+ h := newApp(t)
+ rec := get(t, h, "/static/drop.js")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET /static/drop.js: status %d, want 200", rec.Code)
+ }
+ js := rec.Body.String()
+ if !strings.Contains(js, `var refreshHeader = zone.getAttribute("data-refresh-header");`) {
+ t.Error("drop.js does not read its refresh header off the drop zone")
+ }
+ if n := assignments(js, "refreshHeader"); n != 1 {
+ t.Errorf("drop.js assigns refreshHeader %d times; once, at the read, or what it sends "+
+ "is not what the page named", n)
+ }
+ if n := strings.Count(js, "setRequestHeader(refreshHeader"); n != 1 {
+ t.Errorf("drop.js sends refreshHeader in %d places, want 1", n)
+ }
+ if !strings.Contains(js, `req.setRequestHeader(refreshHeader, "1");`) {
+ t.Error("drop.js does not send the header it read off the page")
+ }
+ if !strings.Contains(jsFunction(t, js, "refresh"), `req.setRequestHeader(refreshHeader, "1");`) {
+ t.Error("refresh() does not send the header; the re-fetch is the request that needs it")
+ }
+ if strings.Contains(strings.ToLower(js), "x-memoria") {
+ t.Error("drop.js spells a header name of its own; the page is where it reads one")
+ }
+
+ // A header set before open() throws, and after send() is too late.
+ // Every request in the file, not just this one: the upload's Accept
+ // header is what gets it a judgement instead of a redirect.
+ setters := regexp.MustCompile(`(\w+)\.setRequestHeader\(`).FindAllStringSubmatchIndex(js, -1)
+ if len(setters) == 0 {
+ t.Fatal("drop.js sets no request headers, so the order check below proves nothing")
+ }
+ for _, m := range setters {
+ v, at := js[m[2]:m[3]], m[0]
+ open, send := strings.Index(js, v+".open("), strings.Index(js, v+".send(")
+ if open < 0 || send < 0 {
+ t.Errorf("%s.setRequestHeader has no %s.open and %s.send around it", v, v, v)
+ continue
+ }
+ if at < open {
+ t.Errorf("%s.setRequestHeader comes before %s.open; browsers throw InvalidStateError", v, v)
+ }
+ if at > send {
+ t.Errorf("%s.setRequestHeader comes after %s.send; the request has already gone", v, v)
+ }
+ }
+}
+
+// The tray points at the document list, which is not on /add, and keeps
+// pointing there.
+//
+// Astra, 2026-09-29: the link went back to /add, which has no list, and
+// changing it back passed make ci. Staying visible after the refresh is
+// the script's, and Go cannot run it, so the script is read: `listed` is
+// touched in exactly four places — found, checked, shown, hidden — and the
+// hiding is in hideTray(), which is dismissal. Watched in Chromium by
+// Astra on 2026-09-29: the link stayed after a real refresh.
+//
+// MUTATION-TESTED 2026-09-29, each reverted:
+//
+// (a) href="/" → href="/add" → "the tray's link goes to "/add""
+// (b) `listed.hidden = true;` added to put() → "drop.js mentions
+// `listed` 5 times"
+// (c) Astra's: that line MOVED from hideTray() into put() → "hideTray()
+// does not hide the pointer" and "put() touches the pointer"
+// (d) Astra's: the condition cut to `if (batch.stored > 0)`, so a batch
+// with only an unconfirmed file hides the link to the one place its
+// fate can be checked → "the pointer is not shown for an unconfirmed
+// upload"
+func TestTheTrayPointsAtTheDocumentList(t *testing.T) {
+ h := newApp(t)
+ doc := parsePage(t, get(t, h, "/add").Body.String())
+ listed := findAll(doc, hasAttr("rst-tray-listed"))
+ if len(listed) != 1 {
+ t.Fatalf("%d tray pointers on /add, want 1", len(listed))
+ }
+ links := findAll(listed[0], func(n *html.Node) bool { return n.Data == "a" })
+ if len(links) != 1 {
+ t.Fatalf("the tray pointer holds %d links, want 1", len(links))
+ }
+ if href, _ := attrOf(links[0], "href"); href != "/" {
+ t.Errorf("the tray's link goes to %q; the document list is on \"/\"", href)
+ }
+
+ js := get(t, h, "/static/drop.js").Body.String()
+ if n := len(regexp.MustCompile(`(^|[^-\w])listed\b`).FindAllString(js, -1)); n != 4 {
+ t.Errorf("drop.js mentions `listed` %d times, want 4: found, checked, shown on "+
+ "announce, hidden on dismiss. Anything else can hide it after a refresh.", n)
+ }
+ if !strings.Contains(jsFunction(t, js, "hideTray"), "listed.hidden = true;") {
+ t.Error("hideTray() does not hide the pointer")
+ }
+ if strings.Contains(jsFunction(t, js, "put"), "listed") {
+ t.Error("put() touches the pointer; it is true whether or not the refresh arrives")
+ }
+ if strings.Count(js, "listed.hidden = false;") != 1 {
+ t.Error("drop.js does not show the pointer in exactly one place")
+ }
+ // Shown when a file was stored OR its fate is unknown. An unconfirmed
+ // file is the case that most needs the list: it is the only place to
+ // find out whether the file landed.
+ shown := regexp.MustCompile(`if \(([^)]*)\) \{\s*listed\.hidden = false;`).FindStringSubmatch(js)
+ if shown == nil {
+ t.Fatal("the pointer is not shown under an if, so its condition cannot be checked")
+ }
+ for _, want := range []string{"batch.stored > 0", "batch.unsure.length > 0"} {
+ if !strings.Contains(shown[1], want) {
+ t.Errorf("the pointer is not shown for %s: its condition is %q",
+ map[string]string{"batch.stored > 0": "a stored upload",
+ "batch.unsure.length > 0": "an unconfirmed upload"}[want], shown[1])
+ }
+ }
+ if !strings.Contains(shown[1], "||") || strings.Contains(shown[1], "&&") {
+ t.Errorf("the pointer's condition is %q; either case alone must show it", shown[1])
+ }
+}
diff --git a/internal/memoriatest/refresh_test.go b/internal/memoriatest/refresh_test.go
index 5f0969d..98c3cab 100644
--- a/internal/memoriatest/refresh_test.go
+++ b/internal/memoriatest/refresh_test.go
@@ -1,13 +1,22 @@
package memoriatest
import (
- "regexp"
+ "net/http"
+ "net/http/httptest"
"strings"
"testing"
+
+ "golang.org/x/net/html"
)
// The tests in this file cover the list that stops being stale.
//
+// Since 2026-09-29 the drop zone and the tray live on /add, and the
+// document list stays on the index. So the page drop.js re-fetches is /add,
+// and its only refreshable region is the passage count in the footer. The
+// browser runs recorded below were made on the index, when the drop zone
+// was there; they are kept as the record of how the swap itself behaves.
+//
// 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
@@ -76,171 +85,118 @@ import (
// 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.
+// The regions the tray swaps hold NOTHING the person is in the middle of
+// using.
//
-// 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.
+// 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.
//
-// 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.
+// Each page is checked for the things it protects BEFORE checking they
+// sit outside every region. A page that had lost its form would otherwise
+// pass the containment check for the wrong reason, which is what happened
+// when the form moved to /add and this test kept reading the index.
//
-// 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.
+// Names are checked for uniqueness because the script pairs each region
+// on screen with the one of that name in the answer.
//
-// MUTATION-TESTED 2026-09-05, each reverted:
+// MUTATION-TESTED 2026-09-29, 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"
+// (a) rst-refresh="upload" added to /add's <section rst-section> → "the
+// "upload" region on /add contains the upload form", and the same
+// for the drop zone, the tray and the paste box. Astra's
+// single-quoted rst-refresh='upload' passed the regex this test
+// used to be; it reads the parsed page now, and is red on it too.
+// (b) the rst-dropzone attribute deleted from add.html → "/add has no
+// drop zone", the presence check, which fatals before any
+// containment line runs
+// (c) rst-refresh deleted from the footer count → "/add has no
+// "passages" region"
+// (d) Astra's: rst-refresh="title" on the title input, a region INSIDE
+// the form rather than around it → "the "title" region on /add is
+// inside the upload form"
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")
+ type kept struct {
+ what string
+ match func(*html.Node) bool
+ }
+ for _, pg := range []struct {
+ path string
+ want []string
+ keep []kept
+ }{
+ {"/add", []string{"passages"}, []kept{
+ {"upload form", elementWith("form", "action", "/documents")},
+ {"drop zone", hasAttr("rst-dropzone")},
+ {"tray", hasAttr("rst-tray")},
+ {"paste box", elementWith("textarea", "id", "body")},
+ }},
+ // search.js writes into the results region, so the search box
+ // must not be inside it. A searched page, so the region has
+ // something in it to over-reach with.
+ {"/?q=revenue", []string{"results", "passages"}, []kept{
+ {"search form", hasAttr("rst-search")},
+ {"search box", elementWith("input", "type", "search")},
+ }},
+ } {
+ doc := parsePage(t, get(t, h, pg.path).Body.String())
+ protected := map[string][]*html.Node{}
+ for _, k := range pg.keep {
+ protected[k.what] = findAll(doc, k.match)
+ if len(protected[k.what]) == 0 {
+ t.Fatalf("%s has no %s, so the containment check below would pass for a page "+
+ "without it", pg.path, k.what)
+ }
}
- 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])
+
+ regions := findAll(doc, hasAttr("rst-refresh"))
+ seen := map[string]bool{}
+ for _, r := range regions {
+ name, _ := attrOf(r, "rst-refresh")
+ if name == "" {
+ t.Errorf("%s: a region carries an empty rst-refresh; the script pairs regions "+
+ "by name and an unnamed one can be paired with anything", pg.path)
+ }
+ if seen[name] {
+ t.Errorf("%s: two regions are both named %q, so a swap puts one region's "+
+ "content into the other's place", pg.path, name)
+ }
+ seen[name] = true
}
- 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)
+ for _, want := range pg.want {
+ if !seen[want] {
+ t.Errorf("%s has no %q region", pg.path, 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)
+ for _, r := range regions {
+ name, _ := attrOf(r, "rst-refresh")
+ for what, nodes := range protected {
+ for _, n := range nodes {
+ if within(n, r) {
+ t.Errorf("the %q region on %s contains the %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, pg.path, what)
+ }
+ // The other direction: a region INSIDE the form is a
+ // control the swap replaces with the server's empty one.
+ if within(r, n) && r != n {
+ t.Errorf("the %q region on %s is inside the %s, so a swap replaces "+
+ "something in it with the server's copy — a typed title, say, with an "+
+ "empty field.", name, pg.path, 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")
+ count := textOf(region(t, parsePage(t, get(t, h, "/add").Body.String()), "passages"))
if !strings.Contains(count, "indexed") {
t.Errorf("the %q region does not hold the passage count: %q", "passages", count)
}
@@ -250,76 +206,33 @@ func TestTheRefreshedRegionsCannotTakeTheFormOrTheTrayWithThem(t *testing.T) {
}
}
-// 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.
+// The re-fetch drop.js makes of /add brings the passage count up to date.
//
-// MUTATION-TESTED 2026-09-05, each reverted:
+// It is made the way drop.js makes it, with the header the page tells the
+// script to send.
//
-// (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) {
+// MUTATION-TESTED 2026-09-29, reverted: `Chunks: a.chunkCount()` →
+// `Chunks: 0` in addContent → "the passage count on /add did not change
+// after 1 document was stored".
+func TestARefetchOfAddMovesThePassageCount(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")
+ add := get(t, h, "/add").Body.String()
+ before := textOf(region(t, parsePage(t, add), "passages"))
+ if !strings.Contains(before, "indexed") {
+ t.Fatalf("/add's passage region holds no count to compare: %q", before)
+ }
- // 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)
+ req := httptest.NewRequest(http.MethodGet, "/add", nil)
+ req.Header.Set(refreshHeaderOn(t, add), "1")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("the re-fetch of /add answered %d, want 200", rec.Code)
+ }
+ if after := textOf(region(t, parsePage(t, rec.Body.String()), "passages")); after == before {
+ t.Errorf("the passage count on /add did not change after 1 document was stored: %q. "+
+ "The tray's re-fetch is the only thing that brings it up to date.", after)
}
}
diff --git a/internal/memoriatest/routes_test.go b/internal/memoriatest/routes_test.go
index d7ac622..5548133 100644
--- a/internal/memoriatest/routes_test.go
+++ b/internal/memoriatest/routes_test.go
@@ -263,6 +263,8 @@ var declaredChiRoutes = map[string]bool{
"GET /auth/finish": true,
"GET /auth/local/complete": true,
"GET /": true,
+ // Adding content, moved off the index to its own page 2026-09-29.
+ "GET /add": true,
// GET /search renders the results region alone, from the same
// template GET /?q= renders it inside a page, so live search can
// replace that region on a keystroke. Same session gate, no passage
diff --git a/internal/memoriatest/tray_test.go b/internal/memoriatest/tray_test.go
index ca27b1e..cded210 100644
--- a/internal/memoriatest/tray_test.go
+++ b/internal/memoriatest/tray_test.go
@@ -216,7 +216,7 @@ func TestTheJSONPathSetsNoFlashCookie(t *testing.T) {
// tooManyFiles builds a drop one file over the cap the page advertises.
func tooManyFiles(t *testing.T, h http.Handler) []file {
t.Helper()
- n := atoi(t, dropZoneAttr(t, get(t, h, "/").Body.String(), "data-max-files"))
+ n := atoi(t, dropZoneAttr(t, get(t, h, "/add").Body.String(), "data-max-files"))
if n < 2 || n > 200 {
t.Fatalf("data-max-files is %d; the drop built here would prove nothing", n)
}
@@ -260,7 +260,7 @@ func tooManyFiles(t *testing.T, h http.Handler) []file {
// inside the sniff window was answered stored=1" and "1 documents
// exist after a binary upload on the JSON path"
func TestTheJSONPathStillEnforcesEveryServerCheck(t *testing.T) {
- page := get(t, newApp(t), "/").Body.String()
+ page := get(t, newApp(t), "/add").Body.String()
capBytes := atoi(t, dropZoneAttr(t, page, "data-max-bytes"))
window := atoi(t, dropZoneAttr(t, page, "data-sniff-bytes"))
if capBytes < 1024 || window < 16 {
@@ -315,7 +315,7 @@ func TestTheJSONPathStillEnforcesEveryServerCheck(t *testing.T) {
t.Run("the total-size cap", func(t *testing.T) {
h := newApp(t)
- total := atoi(t, dropZoneAttr(t, get(t, h, "/").Body.String(), "data-max-total-bytes"))
+ total := atoi(t, dropZoneAttr(t, get(t, h, "/add").Body.String(), "data-max-total-bytes"))
if total <= capBytes {
t.Fatalf("the advertised total (%d) is not larger than the per-file cap (%d)", total, capBytes)
}
@@ -369,7 +369,7 @@ func TestTheJSONPathStillEnforcesEveryServerCheck(t *testing.T) {
// scan that stripped comments first would risk skipping a real string.
func TestEveryWordTheTrayWritesComesFromGo(t *testing.T) {
h := newApp(t)
- tag := dropZoneTag(t, get(t, h, "/").Body.String())
+ tag := dropZoneTag(t, get(t, h, "/add").Body.String())
src := servedDropScript(t, h)
pairs := regexp.MustCompile(`\bdata-(msg|reason)-([a-z-]+)="([^"]*)"`).FindAllStringSubmatch(tag, -1)
@@ -471,7 +471,7 @@ func TestTheTrayIsALiveRegionThatExistsBeforeItSpeaks(t *testing.T) {
}
}
- body := get(t, h, "/").Body.String()
+ body := get(t, h, "/add").Body.String()
stat := regexp.MustCompile(`<p rst-tray-status[^>]*>`).FindString(body)
if stat == "" {
t.Fatalf("no tray status element on the page:\n%s", body)
@@ -525,7 +525,7 @@ func TestTheTrayIsALiveRegionThatExistsBeforeItSpeaks(t *testing.T) {
// (d) writing rst-tray="open" in the template → "the tray is
// pre-marked open in the template"
func TestTheTrayIsOutsideTheFormAndAbsentWithoutScript(t *testing.T) {
- body := get(t, newApp(t), "/").Body.String()
+ body := get(t, newApp(t), "/add").Body.String()
i := strings.Index(body, `action="/documents"`)
if i < 0 {