feat(udgaver): urd — noter-udgaven siger sit eget navn
Verdande og Urd er to af nornerne i Völuspá, og digtet har dem skære
mærker i træ: *skáru á skíði*. Verdande er det der bliver; Urd er det der
blev, og blev gemt. En instans startet uden opgaveruterne er et andet
produkt for den der bruger den, så den siger et andet navn.
Navnet læses fra udgaven i ÉT sted — Config.ProductName/ProductSlug —
frem for at stå skrevet i teksterne. Frontend-bygget er byte-identisk i
begge udgaver; serveren omdøber de to filer, browseren læser før noget
JavaScript kører.
Der er to titler, og det blev først klart, da e2e-prøven sagde det:
skallens <title>, som serveren omdøber, og rutens egen i <svelte:head>,
som appen sætter bagefter og som overskriver den første. Go-prøven var
grøn over de bytes, serveren sendte, mens fanen sagde "Noter · verdande".
Begge prøver spørger nu om begge.
Omdøbningen er ankret på hele strenge frem for på ordet "verdande", fordi
app.html også indeholder localStorage.getItem('verdande:theme'). Omdøbes
præfikset der og ikke i resten af appen, bliver temaet sat fra én nøgle og
læst fra en anden, og det eneste symptom er det hvide blink, det
indlejrede script findes for at undgå. Tre plantede fejl bekræfter, at det
er de rigtige prøver, der fanger det: et flyttet anker i app.html, en bred
søg-erstat, og en applyRebrands uden fejlretur.
Mailen var det vigtigste sted. appName var et felt med en værdi og ingen
læser, og kommentaren ved siden af sagde, at det stod i hver besked — de
tre steder havde ordet skrevet ud i stedet. En invitation fra en
urd-instans går til et andet menneskes indbakke.
Også omdøbt: web-manifestet (og sendt som application/manifest+json — Go
kender ikke .webmanifest og sniffer den til text/plain, hvorefter
browseren ignorerer manifestet uden at sige noget, og installationsknappen
bare ikke er der), mærket i menuen (ᚢ uruz frem for ᚹ wunjo),
opstartsloggen, passkey-promptens visningsnavn, ICS-kalendernavnet og
udseendet, der er opkaldt efter programmet.
Ikonet er IKKE med: icon.svg er stadig ᚹ, og de tre PNG'er genereres fra
den. Det rører binære, genererede filer og hører i sin egen ændring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7e3d300183
commit
6154e6b247
@@ -36,7 +36,18 @@ func scriptHashes(web fs.FS) []string {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return scriptHashesIn(raw)
|
||||
}
|
||||
|
||||
// scriptHashesIn is the same thing over bytes that are already in hand.
|
||||
//
|
||||
// It exists because the notes edition does not serve the index.html that is on
|
||||
// disk — it serves a renamed copy of it (see shell.go) — and the policy has to
|
||||
// describe the bytes that actually leave the server. Hashing the file while
|
||||
// serving something else is the shape of mistake that cannot be noticed locally:
|
||||
// the policy is not enforced for a page opened from a file, so the page is blank
|
||||
// in production and nowhere else.
|
||||
func scriptHashesIn(raw []byte) []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, match := range inlineScript.FindAllStringSubmatchIndex(string(raw), -1) {
|
||||
|
||||
@@ -358,7 +358,7 @@ func (s *Server) handleExportAllICS(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
cal := ics.Calendar{Name: "Verdande", Domain: feedDomain(s.cfg.BaseURL)}
|
||||
cal := ics.Calendar{Name: s.cfg.ProductName(), Domain: feedDomain(s.cfg.BaseURL)}
|
||||
for _, t := range tasks {
|
||||
if t.DueDate == "" {
|
||||
continue
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
@@ -77,6 +78,11 @@ type Server struct {
|
||||
// Computed once from the built index.html; see csp.go.
|
||||
csp string
|
||||
|
||||
// The shell and the manifest with this edition's name in them, or nil to serve
|
||||
// the built files unchanged. Only the notes edition needs them; see shell.go.
|
||||
shell []byte
|
||||
manifest []byte
|
||||
|
||||
router chi.Router
|
||||
}
|
||||
|
||||
@@ -86,7 +92,7 @@ type Server struct {
|
||||
func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server {
|
||||
s := &Server{
|
||||
cfg: cfg, db: db, log: log, web: web,
|
||||
mail: mail.New(cfg.SMTP, cfg.BaseURL, log),
|
||||
mail: mail.New(cfg.SMTP, cfg.BaseURL, cfg.ProductSlug(), log),
|
||||
hub: realtime.NewHub(log),
|
||||
loginLimiter: newLimiter(10, 15*time.Minute),
|
||||
// Generous on purpose: the caller is the mail server, one address delivering
|
||||
@@ -101,8 +107,36 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// generous for a person and hopeless for a search through a million.
|
||||
totpLimiter: newLimiter(10, time.Hour),
|
||||
}
|
||||
s.csp = contentSecurityPolicy(scriptHashes(web))
|
||||
if w, err := auth.NewWebAuthn(cfg.BaseURL, "verdande"); err == nil {
|
||||
// Before the CSP, deliberately: the policy has to describe the bytes that go
|
||||
// out, and in the notes edition those are the renamed ones.
|
||||
//
|
||||
// A failure here is logged rather than fatal, and that is a judgement about
|
||||
// this particular failure rather than a habit: an unknown VERDANDE_EDITION
|
||||
// refuses to start, because serving task routes on an instance set up without
|
||||
// them is a different kind of wrong. A shell whose title says the sibling's
|
||||
// name is cosmetic — it must be loud, and it must not take the instance down.
|
||||
// What makes it loud is not this line: the end-to-end suite reads the served
|
||||
// title, so an anchor that falls out of app.html goes red there.
|
||||
if web != nil && cfg.NotesOnly() {
|
||||
shell, manifest, err := rebrandedAssets(web, cfg.ProductSlug(), notesShellDescription, notesManifestDescription)
|
||||
if err != nil {
|
||||
log.Error("the frontend could not be renamed for this edition; it will call itself verdande",
|
||||
"edition", cfg.Edition, "err", err)
|
||||
} else {
|
||||
s.shell, s.manifest = shell, manifest
|
||||
}
|
||||
}
|
||||
|
||||
if s.shell != nil {
|
||||
s.csp = contentSecurityPolicy(scriptHashesIn(s.shell))
|
||||
} else {
|
||||
s.csp = contentSecurityPolicy(scriptHashes(web))
|
||||
}
|
||||
// The second argument is WebAuthn's display name, not its relying party id —
|
||||
// the id comes from BaseURL. So this is what the operating system's passkey
|
||||
// prompt calls the site, and the slug keeps it identical to what every existing
|
||||
// verdande instance already registered.
|
||||
if w, err := auth.NewWebAuthn(cfg.BaseURL, cfg.ProductSlug()); err == nil {
|
||||
s.passkeys = w
|
||||
} else {
|
||||
log.Warn("passkeys are unavailable", "err", err, "base_url", cfg.BaseURL)
|
||||
@@ -685,7 +719,7 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
|
||||
if s.web == nil {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("verdande — API is running. The web interface was not built into this binary.\n"))
|
||||
w.Write([]byte(s.cfg.ProductSlug() + " — API is running. The web interface was not built into this binary.\n"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -706,6 +740,24 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Only here is `name` settled, and that matters: every client-side route —
|
||||
// /noter and all the rest — arrives as a path with no file behind it and is
|
||||
// resolved to index.html by the fallback above. Asking before the fallback
|
||||
// would have renamed the shell for a request to "/" and handed back the built
|
||||
// one for every other route in the app.
|
||||
//
|
||||
// These two assets never reach the filesystem: they are the built files with
|
||||
// this edition's name in them, held in memory since startup. See shell.go.
|
||||
if b := s.renamed(name); b != nil {
|
||||
w.Header().Set("Content-Type", contentTypeFor(name))
|
||||
// A zero modification time tells ServeContent to send no Last-Modified and
|
||||
// to answer no conditional request — right for a shell that must never be
|
||||
// cached, and the same thing the built shell gets below.
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(b))
|
||||
return
|
||||
}
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil || info.IsDir() {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
// The product name is in the built frontend as a literal in four places, because
|
||||
// all four are read before any JavaScript runs: the window title and the meta
|
||||
// description in the HTML shell, and the two names in the web manifest that decide
|
||||
// what an installed PWA is called on a home screen.
|
||||
//
|
||||
// They cannot be filled in by the app at runtime. The shell is parsed before the
|
||||
// first script executes, the manifest is fetched by the browser rather than by us,
|
||||
// and the edition is not known until /auth/setup-state has answered — so a frontend
|
||||
// that renamed itself after loading would show the sibling's name first and correct
|
||||
// itself a moment later, on every load, in the tab and on the install prompt.
|
||||
//
|
||||
// So the server does it, once at startup, on the way out. One frontend build, two
|
||||
// names: the build is identical in both editions and the bytes served differ.
|
||||
//
|
||||
// Each replacement is REQUIRED and the anchors are exact. A rewrite that quietly
|
||||
// matched nothing is the whole failure mode here — it leaves an Urd instance
|
||||
// calling itself verdande, which is cosmetic enough that nobody files it and
|
||||
// nothing else notices.
|
||||
type rebrand struct {
|
||||
old, new string
|
||||
}
|
||||
|
||||
// shellRebrands returns the replacements for index.html.
|
||||
//
|
||||
// Note what is NOT in here: `localStorage.getItem('verdande:theme')` and its two
|
||||
// neighbours in the inline theme script. Those keys are storage, not a name — the
|
||||
// rest of the app reads the same three, so renaming them here and nowhere else
|
||||
// would set the theme from one key and read it from another, and the only symptom
|
||||
// is the white flash that script exists to prevent. This is why the replacements
|
||||
// are written out as whole anchored strings rather than as a replace of the word
|
||||
// "verdande".
|
||||
func shellRebrands(slug, description string) []rebrand {
|
||||
return []rebrand{
|
||||
{"<title>verdande</title>", "<title>" + slug + "</title>"},
|
||||
{
|
||||
`<meta name="description" content="verdande — opgaver og projekter, delt." />`,
|
||||
`<meta name="description" content="` + description + `" />`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// manifestRebrands returns the replacements for manifest.webmanifest. The
|
||||
// manifest's own description is a sentence rather than the shell's "name — sentence"
|
||||
// form, so it is passed separately rather than derived.
|
||||
func manifestRebrands(slug, description string) []rebrand {
|
||||
return []rebrand{
|
||||
{`"name": "verdande"`, `"name": "` + slug + `"`},
|
||||
{`"short_name": "verdande"`, `"short_name": "` + slug + `"`},
|
||||
{`"description": "Opgaver og projekter, delt."`, `"description": "` + description + `"`},
|
||||
}
|
||||
}
|
||||
|
||||
// applyRebrands replaces every pair exactly once and fails on the first anchor it
|
||||
// cannot find, naming it. Failing loudly rather than returning what it managed is
|
||||
// the point: a partial rename is a page whose tab and whose install prompt disagree
|
||||
// about which program this is.
|
||||
func applyRebrands(raw []byte, pairs []rebrand) ([]byte, error) {
|
||||
out := raw
|
||||
for _, p := range pairs {
|
||||
old := []byte(p.old)
|
||||
if bytes.Count(out, old) != 1 {
|
||||
return nil, fmt.Errorf("found %d occurrences of %q, want exactly 1", bytes.Count(out, old), p.old)
|
||||
}
|
||||
out = bytes.Replace(out, old, []byte(p.new), 1)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// rebrandedAssets reads index.html and manifest.webmanifest out of the built
|
||||
// frontend and returns them renamed for this edition.
|
||||
//
|
||||
// Both or neither: an instance serving a renamed shell beside the sibling's
|
||||
// manifest installs under the wrong name on a phone, which is the half nobody looks
|
||||
// at until it is on their home screen.
|
||||
func rebrandedAssets(web fs.FS, slug, shellDesc, manifestDesc string) (shell, manifest []byte, err error) {
|
||||
raw, err := fs.ReadFile(web, "index.html")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("index.html: %w", err)
|
||||
}
|
||||
shell, err = applyRebrands(raw, shellRebrands(slug, shellDesc))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("index.html: %w", err)
|
||||
}
|
||||
|
||||
raw, err = fs.ReadFile(web, "manifest.webmanifest")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err)
|
||||
}
|
||||
manifest, err = applyRebrands(raw, manifestRebrands(slug, manifestDesc))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err)
|
||||
}
|
||||
return shell, manifest, nil
|
||||
}
|
||||
|
||||
// The descriptions for the notes edition. Kept beside the replacements they feed
|
||||
// rather than in the locale files, because these two strings are read by a browser
|
||||
// and a home screen before any locale has been chosen — the shell is in Danish for
|
||||
// the same reason the built app.html is.
|
||||
const (
|
||||
notesShellDescription = "urd — noter, delt."
|
||||
notesManifestDescription = "Noter, delt."
|
||||
)
|
||||
|
||||
// renamed returns the renamed bytes for `name`, or nil to serve the built file.
|
||||
//
|
||||
// Nil for everything in the full edition, which is what keeps that path exactly as
|
||||
// it was: one comparison per request against a nil slice.
|
||||
func (s *Server) renamed(name string) []byte {
|
||||
switch name {
|
||||
case "index.html":
|
||||
return s.shell
|
||||
case "manifest.webmanifest":
|
||||
return s.manifest
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// contentTypeFor names the type for the two assets served from memory.
|
||||
//
|
||||
// http.ServeContent sniffs from the extension, and it gets .html right and
|
||||
// .webmanifest wrong — Go's table does not have it, so the sniffer reads the first
|
||||
// bytes, sees JSON-ish text and answers text/plain. A manifest served as text/plain
|
||||
// is ignored by the browser without an error anywhere: the install prompt simply
|
||||
// does not appear.
|
||||
func contentTypeFor(name string) string {
|
||||
if name == "manifest.webmanifest" {
|
||||
return "application/manifest+json"
|
||||
}
|
||||
return "text/html; charset=utf-8"
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// serverWithWeb builds the real router around a frontend, which newTestServerWith
|
||||
// cannot do — it passes nil for the web filesystem, because every other test here
|
||||
// is about the API. The renaming only happens when there is something to rename.
|
||||
func serverWithWeb(t *testing.T, edition string, web fs.FS) *Server {
|
||||
t.Helper()
|
||||
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
cfg := &config.Config{
|
||||
BaseURL: "http://localhost",
|
||||
DataDir: t.TempDir(),
|
||||
SessionTTL: 24 * time.Hour,
|
||||
Edition: edition,
|
||||
}
|
||||
return New(cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil)), web)
|
||||
}
|
||||
|
||||
// The shell as the frontend build produces it, cut down to the parts that matter
|
||||
// here: the two anchors that get renamed, and the inline theme script that must
|
||||
// not be touched. TestAppHTMLStillCarriesTheAnchors below is what keeps this
|
||||
// honest against the real file.
|
||||
const builtShell = `<!doctype html>
|
||||
<html lang="da" data-theme="dark">
|
||||
<head>
|
||||
<meta name="description" content="verdande — opgaver og projekter, delt." />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>verdande</title>
|
||||
<script>
|
||||
try {
|
||||
const stored = localStorage.getItem('verdande:theme');
|
||||
const look = localStorage.getItem('verdande:look');
|
||||
if (look && look !== 'verdande') document.documentElement.dataset.look = look;
|
||||
} catch (e) {}
|
||||
</script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
`
|
||||
|
||||
const builtManifest = `{
|
||||
"name": "verdande",
|
||||
"short_name": "verdande",
|
||||
"description": "Opgaver og projekter, delt.",
|
||||
"start_url": "/"
|
||||
}
|
||||
`
|
||||
|
||||
func builtWeb() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"index.html": {Data: []byte(builtShell)},
|
||||
"manifest.webmanifest": {Data: []byte(builtManifest)},
|
||||
"_app/immutable/app.js": {Data: []byte("// app")},
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenamingTheShellLeavesTheStorageKeysAlone is the one this file exists for.
|
||||
//
|
||||
// The product name and the localStorage prefix are the same word in app.html, and
|
||||
// they are not the same thing: the prefix is read back by the app itself, so
|
||||
// renaming it here and nowhere else sets the theme from one key and reads it from
|
||||
// another. The symptom is the white flash on load that the inline script exists to
|
||||
// prevent — which is to say, no error, nothing in a log, and nothing a test that
|
||||
// merely checked the title would see.
|
||||
func TestRenamingTheShellLeavesTheStorageKeysAlone(t *testing.T) {
|
||||
shell, manifest, err := rebrandedAssets(builtWeb(), "urd", "urd — noter, delt.", "Noter, delt.")
|
||||
if err != nil {
|
||||
t.Fatalf("rebrandedAssets: %v", err)
|
||||
}
|
||||
|
||||
got := string(shell)
|
||||
for _, want := range []string{
|
||||
"<title>urd</title>",
|
||||
`content="urd — noter, delt."`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("the shell does not contain %q", want)
|
||||
}
|
||||
}
|
||||
// Named values rather than "no occurrences of verdande": the three keys are
|
||||
// what has to survive, so the assertion says the three keys.
|
||||
for _, want := range []string{
|
||||
`localStorage.getItem('verdande:theme')`,
|
||||
`localStorage.getItem('verdande:look')`,
|
||||
`look !== 'verdande'`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("renaming the shell changed a storage key: %q is gone", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "urd:theme") || strings.Contains(got, "urd:look") {
|
||||
t.Error("renaming the shell rewrote a localStorage prefix; the app reads the verdande: ones")
|
||||
}
|
||||
|
||||
gotManifest := string(manifest)
|
||||
for _, want := range []string{`"name": "urd"`, `"short_name": "urd"`, `"description": "Noter, delt."`} {
|
||||
if !strings.Contains(gotManifest, want) {
|
||||
t.Errorf("the manifest does not contain %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenamingTheShellKeepsTheCSPHashValid pins the relationship between the two
|
||||
// files that is easy to break and impossible to notice: the policy names a hash of
|
||||
// the inline script, and the renamed shell is what gets served. If a replacement
|
||||
// ever reached inside the <script>, the hash would change, the browser would refuse
|
||||
// to run it, and the page would be blank — in production only, because the policy
|
||||
// is not enforced for a page opened from a file.
|
||||
func TestRenamingTheShellKeepsTheCSPHashValid(t *testing.T) {
|
||||
shell, _, err := rebrandedAssets(builtWeb(), "urd", "urd — noter, delt.", "Noter, delt.")
|
||||
if err != nil {
|
||||
t.Fatalf("rebrandedAssets: %v", err)
|
||||
}
|
||||
|
||||
before := scriptHashesIn([]byte(builtShell))
|
||||
after := scriptHashesIn(shell)
|
||||
if len(before) != 1 {
|
||||
t.Fatalf("the fixture should have exactly one inline script, found %d", len(before))
|
||||
}
|
||||
if len(after) != len(before) || after[0] != before[0] {
|
||||
t.Errorf("renaming changed the inline script: policy had %v, served shell hashes to %v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenamingRefusesAMissingAnchor is the direction that matters more than the
|
||||
// happy path. A replacement that silently matched nothing leaves an Urd instance
|
||||
// calling itself verdande, which is cosmetic enough that nobody reports it — so the
|
||||
// rewrite has to say so rather than return what it managed.
|
||||
func TestRenamingRefusesAMissingAnchor(t *testing.T) {
|
||||
cases := map[string]fstest.MapFS{
|
||||
"no title": {
|
||||
"index.html": {Data: []byte(strings.Replace(builtShell, "<title>verdande</title>", "<title>something else</title>", 1))},
|
||||
"manifest.webmanifest": {Data: []byte(builtManifest)},
|
||||
},
|
||||
"no description": {
|
||||
"index.html": {Data: []byte(strings.Replace(builtShell, `content="verdande — opgaver og projekter, delt."`, `content="other"`, 1))},
|
||||
"manifest.webmanifest": {Data: []byte(builtManifest)},
|
||||
},
|
||||
"no manifest name": {
|
||||
"index.html": {Data: []byte(builtShell)},
|
||||
"manifest.webmanifest": {Data: []byte(strings.Replace(builtManifest, `"name": "verdande"`, `"name": "other"`, 1))},
|
||||
},
|
||||
"no index.html at all": {
|
||||
"manifest.webmanifest": {Data: []byte(builtManifest)},
|
||||
},
|
||||
"no manifest at all": {
|
||||
"index.html": {Data: []byte(builtShell)},
|
||||
},
|
||||
}
|
||||
for name, web := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, _, err := rebrandedAssets(web, "urd", "d", "m"); err == nil {
|
||||
t.Error("renaming reported success over a shell it could not rename")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppHTMLStillCarriesTheAnchors is what stops the fixture above from becoming a
|
||||
// frozen copy of a file that has moved on.
|
||||
//
|
||||
// The anchors are exact strings in web/src/app.html, and that file is edited for
|
||||
// unrelated reasons — a meta tag, a new theme attribute. An edit that reformats the
|
||||
// title line or rewrites the description breaks the rename and nothing in Go would
|
||||
// know, because the Go build graph has never heard of app.html.
|
||||
//
|
||||
// Note the caching trap that comes with reading a non-Go file from a test: `go
|
||||
// test` will serve a cached green over a changed app.html. CI passes -count=1 in
|
||||
// both workflows; a local run needs it typed.
|
||||
func TestAppHTMLStillCarriesTheAnchors(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../web/src/app.html")
|
||||
if err != nil {
|
||||
t.Skipf("app.html is not reachable from here: %v", err)
|
||||
}
|
||||
src := string(raw)
|
||||
|
||||
for _, pair := range shellRebrands("urd", "d") {
|
||||
if n := strings.Count(src, pair.old); n != 1 {
|
||||
t.Errorf("web/src/app.html contains %d occurrences of the anchor %q, want exactly 1 — "+
|
||||
"the notes edition renames the shell by replacing it", n, pair.old)
|
||||
}
|
||||
}
|
||||
// And the keys the rename must not touch, asserted here too: app.html is where
|
||||
// somebody would rename them, and if they are renamed there they must be
|
||||
// renamed in stores.svelte.js in the same commit.
|
||||
if !strings.Contains(src, `localStorage.getItem('verdande:theme')`) {
|
||||
t.Error("web/src/app.html no longer reads verdande:theme; check that the app reads the same key")
|
||||
}
|
||||
t.Logf("app.html: %d bytes, %d anchors found", len(raw), len(shellRebrands("urd", "d")))
|
||||
}
|
||||
|
||||
// TestTheFullEditionServesTheBuiltFilesUnchanged is the control. Everything above
|
||||
// is about the renamed path; this says the other edition does not go through it.
|
||||
func TestTheFullEditionServesTheBuiltFilesUnchanged(t *testing.T) {
|
||||
for _, edition := range []string{"full", "notes"} {
|
||||
t.Run(edition, func(t *testing.T) {
|
||||
srv := serverWithWeb(t, edition, builtWeb())
|
||||
if edition == "full" {
|
||||
if srv.renamed("index.html") != nil || srv.renamed("manifest.webmanifest") != nil {
|
||||
t.Error("the full edition renamed the shell")
|
||||
}
|
||||
return
|
||||
}
|
||||
if srv.renamed("index.html") == nil || srv.renamed("manifest.webmanifest") == nil {
|
||||
t.Fatal("the notes edition did not rename the shell")
|
||||
}
|
||||
if !strings.Contains(string(srv.renamed("index.html")), "<title>urd</title>") {
|
||||
t.Error("the notes edition's shell does not say urd")
|
||||
}
|
||||
if srv.renamed("_app/immutable/app.js") != nil {
|
||||
t.Error("a build asset was served from memory; only the shell and the manifest are renamed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user