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.
24 files changed,
+997
−517
README.md+3 −2go.mod+1 −0go.sum+2 −8internal/memoria/app.go+1 −0internal/memoria/delete_test.go+31 −1internal/memoria/handlers.go+51 −43internal/memoria/lag_test.go+1 −1internal/memoria/notice.go+74 −0internal/memoria/notice_test.go+86 −0internal/memoria/render.go+1 −1internal/memoria/static/drop.js+31 −45internal/memoria/static/memoria.css+1 −1internal/memoria/static/search.js+5 −13internal/memoria/templates/add.html+128 −0internal/memoria/templates/index.html+10 −153internal/memoria/templates/layout.html+3 −3internal/memoriatest/app_test.go+1 −1internal/memoriatest/dom_test.go+92 −0internal/memoriatest/drop_test.go+34 −22internal/memoriatest/gates_test.go+3 −3internal/memoriatest/notice_test.go+303 −0internal/memoriatest/refresh_test.go+127 −214internal/memoriatest/routes_test.go+2 −0internal/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 { |