diff --git a/AGENTS.md b/AGENTS.md index 3f6ab4b7..90296b57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ public/ — Frontend (vanilla JS, one file per page) — ACTIVE, NOT style.css — Main styles, CSS variables for theming live.css — Live page styles home.css — Home page styles - index.html — SPA shell, script/style tags with cache busters + index.html — SPA shell, script/style tags with __BUST__ placeholder (auto-replaced at server startup) test-fixtures/ — Real data SQLite fixture from staging (used for E2E tests) scripts/ — Tooling (coverage collector, fixture capture, frontend instrumentation) ``` @@ -84,12 +84,8 @@ Every change that touches logic MUST have tests. For Go backend: `cd cmd/server ### 2. No commit without browser validation After pushing, verify the change works in an actual browser. Use `browser profile=openclaw` against the running instance. Take a screenshot if the change is visual. If you can't validate it, say so — don't claim it works. -### 3. Cache busters — ALWAYS bump them -Every time you change a `.js` or `.css` file in `public/`, bump the cache buster in `index.html`. This has caused 7 separate production regressions. Use: -```bash -NEWV=$(date +%s) && sed -i "s/v=[0-9]*/v=$NEWV/g" public/index.html -``` -Do this in the SAME commit as the code change, not as a follow-up. +### 3. Cache busters are automatic — do NOT manually edit them +Cache busters are injected automatically by the Go server at startup. The `__BUST__` placeholder in `index.html` is replaced with a Unix timestamp when the server reads the file. No manual bumping needed — every server restart picks up new asset versions. Do NOT replace `__BUST__` with hardcoded timestamps. ### 4. Verify API response shape before building UI Before writing client code that consumes an API endpoint, check what the endpoint ACTUALLY returns. Use `curl` or check the server code. Don't assume fields exist — grouped packets (`groupByHash=true`) have different fields than raw packets. This has caused multiple breakages. @@ -351,7 +347,7 @@ One logical change per commit. Each commit is deployable. Each commit has its te | Pitfall | Times it happened | Prevention | |---------|-------------------|------------| -| Forgot cache busters | 7 | Always bump in same commit | +| Forgot cache busters | 7 | Now automatic — `__BUST__` replaced at server startup | | Grouped packets missing fields | 3 | curl the actual API first | | last_seen vs last_heard mismatch | 4 | Always use `last_heard \|\| last_seen` | | CSS selectors don't match SVG | 2 | Manipulate SVG in JS after generation | diff --git a/cmd/server/helpers_test.go b/cmd/server/helpers_test.go index 9df42610..7ec5c703 100644 --- a/cmd/server/helpers_test.go +++ b/cmd/server/helpers_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" ) @@ -326,6 +327,84 @@ func TestSpaHandler(t *testing.T) { t.Errorf("expected no-cache header for .html, got %s", cc) } }) + + t.Run("root path serves index.html", func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("expected 200, got %d", w.Code) + } + body := w.Body.String() + if body != "SPA" { + t.Errorf("expected SPA index.html content, got %s", body) + } + ct := w.Header().Get("Content-Type") + if ct != "text/html; charset=utf-8" { + t.Errorf("expected text/html content type, got %s", ct) + } + }) + + t.Run("/index.html serves pre-processed content", func(t *testing.T) { + req := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("expected 200, got %d", w.Code) + } + body := w.Body.String() + if body != "SPA" { + t.Errorf("expected SPA index.html content, got %s", body) + } + }) +} + +func TestSpaHandlerCacheBust(t *testing.T) { + dir := t.TempDir() + htmlWithBust := `` + os.WriteFile(filepath.Join(dir, "index.html"), []byte(htmlWithBust), 0644) + + fs := http.FileServer(http.Dir(dir)) + handler := spaHandler(dir, fs) + + t.Run("__BUST__ is replaced with a Unix timestamp", func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + body := w.Body.String() + if strings.Contains(body, "__BUST__") { + t.Errorf("__BUST__ placeholder was not replaced in response: %s", body) + } + // Verify it was replaced with digits (Unix timestamp) + if !strings.Contains(body, "v=") { + t.Errorf("expected v= query params in response, got: %s", body) + } + }) + + t.Run("SPA fallback also has busted values", func(t *testing.T) { + req := httptest.NewRequest("GET", "/nonexistent/route", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + body := w.Body.String() + if strings.Contains(body, "__BUST__") { + t.Errorf("__BUST__ placeholder was not replaced in SPA fallback: %s", body) + } + }) + + t.Run("/index.html also has busted values", func(t *testing.T) { + req := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + body := w.Body.String() + if strings.Contains(body, "__BUST__") { + t.Errorf("__BUST__ placeholder was not replaced for /index.html: %s", body) + } + }) } func TestWriteJSON(t *testing.T) { diff --git a/cmd/server/main.go b/cmd/server/main.go index a1bde04c..a1abc4f2 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -11,9 +11,9 @@ import ( "os" "os/exec" "os/signal" - "sync" "path/filepath" "strings" + "sync" "syscall" "time" @@ -242,11 +242,35 @@ func main() { } // spaHandler serves static files, falling back to index.html for SPA routes. +// It reads index.html once at creation time and replaces the __BUST__ placeholder +// with a Unix timestamp so browsers fetch fresh JS/CSS after each server restart. func spaHandler(root string, fs http.Handler) http.Handler { + // Pre-process index.html: replace __BUST__ with a cache-bust timestamp + indexPath := filepath.Join(root, "index.html") + rawHTML, err := os.ReadFile(indexPath) + if err != nil { + log.Printf("[static] warning: could not read index.html for cache-bust: %v", err) + rawHTML = []byte("

CoreScope

index.html not found

") + } + bustValue := fmt.Sprintf("%d", time.Now().Unix()) + indexHTML := []byte(strings.ReplaceAll(string(rawHTML), "__BUST__", bustValue)) + log.Printf("[static] cache-bust value: %s", bustValue) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Serve pre-processed index.html for root and /index.html + if r.URL.Path == "/" || r.URL.Path == "/index.html" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Write(indexHTML) + return + } + path := filepath.Join(root, r.URL.Path) if _, err := os.Stat(path); os.IsNotExist(err) { - http.ServeFile(w, r, filepath.Join(root, "index.html")) + // SPA fallback — serve pre-processed index.html + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Write(indexHTML) return } // Disable caching for JS/CSS/HTML diff --git a/public/index.html b/public/index.html index e429a784..2440282c 100644 --- a/public/index.html +++ b/public/index.html @@ -22,9 +22,9 @@ - - - + + + @@ -85,30 +85,30 @@
- - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + +