carlos / todomvc-bakeoff Public
test: reference fixture app implementing the contract
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+519
−0
harness/go.mod+3 −0harness/refapp/app.js+57 −0harness/refapp/main.go+251 −0harness/refapp/page.html.tmpl+82 −0harness/refapp/refapp_test.go+126 −0
diff --git a/harness/go.mod b/harness/go.mod| new file mode 100644 |
| index 0000000..d07f8b2 |
| --- /dev/null |
| +++ b/harness/go.mod |
| @@ -0,0 +1,3 @@ |
| +module bakeoff/harness |
| + |
| +go 1.26 |
diff --git a/harness/refapp/app.js b/harness/refapp/app.js| new file mode 100644 |
| index 0000000..88e4fbc |
| --- /dev/null |
| +++ b/harness/refapp/app.js |
| @@ -0,0 +1,57 @@ |
| +// Progressive enhancement for the reference fixture: every form in the app |
| +// is submitted with fetch, and the server's re-rendered .todoapp replaces |
| +// the current one. Editing in place is client-side until it saves. |
| +const FETCH = { 'X-Requested-With': 'fetch' }; |
| + |
| +async function send(form) { |
| + const res = await fetch(form.action, { |
| + method: 'POST', |
| + headers: FETCH, |
| + body: new URLSearchParams(new FormData(form)), |
| + }); |
| + const doc = new DOMParser().parseFromString(await res.text(), 'text/html'); |
| + document.querySelector('.todoapp').replaceWith(doc.querySelector('.todoapp')); |
| +} |
| + |
| +document.addEventListener('submit', async (e) => { |
| + const form = e.target; |
| + if (!form.closest('.todoapp')) return; |
| + e.preventDefault(); |
| + // Leave editing mode first, so the focusout that follows doesn't save twice. |
| + form.closest('li')?.classList.remove('editing'); |
| + const adding = form.querySelector('.new-todo'); |
| + await send(form); |
| + if (adding) document.querySelector('.new-todo').focus(); |
| +}); |
| + |
| +document.addEventListener('change', (e) => { |
| + if (e.target.matches('.toggle, .toggle-all')) e.target.form.requestSubmit(); |
| +}); |
| + |
| +document.addEventListener('dblclick', (e) => { |
| + const label = e.target.closest('.todo-list label'); |
| + if (!label) return; |
| + const li = label.closest('li'); |
| + li.classList.add('editing'); |
| + const input = li.querySelector('.edit'); |
| + input.focus(); |
| + input.setSelectionRange(input.value.length, input.value.length); |
| +}); |
| + |
| +document.addEventListener('keydown', (e) => { |
| + if (!e.target.matches('.edit')) return; |
| + const li = e.target.closest('li'); |
| + if (e.key === 'Escape') { |
| + e.target.value = li.querySelector('label').textContent; |
| + li.classList.remove('editing'); |
| + } |
| +}); |
| + |
| +document.addEventListener('focusout', (e) => { |
| + if (!e.target.matches('.edit')) return; |
| + const li = e.target.closest('li'); |
| + if (li.classList.contains('editing')) { |
| + li.classList.remove('editing'); |
| + e.target.form.requestSubmit(); |
| + } |
| +}); |
diff --git a/harness/refapp/main.go b/harness/refapp/main.go| new file mode 100644 |
| index 0000000..3a695d6 |
| --- /dev/null |
| +++ b/harness/refapp/main.go |
| @@ -0,0 +1,251 @@ |
| +// Command refapp is the bakeoff's reference fixture: a minimal, in-memory |
| +// implementation of CONTRACT.md used only to prove the behaviour suite and |
| +// the harness before any builder starts. It is not a contestant, and |
| +// builders never see it. |
| +package main |
| + |
| +import ( |
| + "crypto/rand" |
| + _ "embed" |
| + "encoding/hex" |
| + "flag" |
| + "html/template" |
| + "log" |
| + "net" |
| + "net/http" |
| + "os" |
| + "path/filepath" |
| + "strconv" |
| + "strings" |
| + "sync" |
| +) |
| + |
| +//go:embed page.html.tmpl |
| +var pageSrc string |
| + |
| +//go:embed app.js |
| +var appJS []byte |
| + |
| +var page = template.Must(template.New("page").Parse(pageSrc)) |
| + |
| +const cookieName = "todo" |
| + |
| +type item struct { |
| + ID int |
| + Title string |
| + Completed bool |
| +} |
| + |
| +type list struct { |
| + next int |
| + items []item |
| +} |
| + |
| +type app struct { |
| + origin string |
| + shared string |
| + mu sync.Mutex |
| + lists map[string]*list |
| +} |
| + |
| +// NewApp returns the reference app's handler. origin is the app's own |
| +// origin for the Origin check; shared is the directory holding base.css |
| +// and index.css. |
| +func NewApp(origin, shared string) http.Handler { |
| + a := &app{origin: origin, shared: shared, lists: map[string]*list{}} |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte("ok")) }) |
| + mux.HandleFunc("GET /api/version", func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte("refapp")) }) |
| + mux.HandleFunc("GET /static/{file}", a.static) |
| + mux.HandleFunc("GET /{$}", a.show("all")) |
| + mux.HandleFunc("GET /active", a.show("active")) |
| + mux.HandleFunc("GET /completed", a.show("completed")) |
| + mux.HandleFunc("POST /todos", a.act(func(l *list, r *http.Request) int { |
| + if t := strings.TrimSpace(r.FormValue("title")); t != "" { |
| + l.next++ |
| + l.items = append(l.items, item{ID: l.next, Title: t}) |
| + } |
| + return 0 |
| + })) |
| + mux.HandleFunc("POST /todos/toggle-all", a.act(func(l *list, _ *http.Request) int { |
| + all := true |
| + for _, it := range l.items { |
| + all = all && it.Completed |
| + } |
| + for i := range l.items { |
| + l.items[i].Completed = !all |
| + } |
| + return 0 |
| + })) |
| + mux.HandleFunc("POST /todos/clear-completed", a.act(func(l *list, _ *http.Request) int { |
| + kept := l.items[:0] |
| + for _, it := range l.items { |
| + if !it.Completed { |
| + kept = append(kept, it) |
| + } |
| + } |
| + l.items = kept |
| + return 0 |
| + })) |
| + mux.HandleFunc("POST /todos/{id}/{op}", a.act(func(l *list, r *http.Request) int { |
| + i := l.find(r.PathValue("id")) |
| + if i < 0 { |
| + return http.StatusNotFound |
| + } |
| + switch r.PathValue("op") { |
| + case "toggle": |
| + l.items[i].Completed = !l.items[i].Completed |
| + case "edit": |
| + if t := strings.TrimSpace(r.FormValue("title")); t != "" { |
| + l.items[i].Title = t |
| + } else { |
| + l.items = append(l.items[:i], l.items[i+1:]...) |
| + } |
| + case "destroy": |
| + l.items = append(l.items[:i], l.items[i+1:]...) |
| + default: |
| + return http.StatusNotFound |
| + } |
| + return 0 |
| + })) |
| + return mux |
| +} |
| + |
| +func (l *list) find(id string) int { |
| + n, err := strconv.Atoi(id) |
| + if err != nil { |
| + return -1 |
| + } |
| + for i, it := range l.items { |
| + if it.ID == n { |
| + return i |
| + } |
| + } |
| + return -1 |
| +} |
| + |
| +func (a *app) static(w http.ResponseWriter, r *http.Request) { |
| + switch f := r.PathValue("file"); f { |
| + case "base.css", "index.css": |
| + w.Header().Set("Content-Type", "text/css; charset=utf-8") |
| + http.ServeFile(w, r, filepath.Join(a.shared, f)) |
| + case "app.js": |
| + w.Header().Set("Content-Type", "text/javascript; charset=utf-8") |
| + w.Write(appJS) |
| + default: |
| + http.NotFound(w, r) |
| + } |
| +} |
| + |
| +// listFor returns the caller's list, minting a fresh one (and its cookie) |
| +// when the cookie is missing or unknown. Caller holds a.mu. |
| +func (a *app) listFor(w http.ResponseWriter, r *http.Request) *list { |
| + if c, err := r.Cookie(cookieName); err == nil { |
| + if l, ok := a.lists[c.Value]; ok { |
| + return l |
| + } |
| + } |
| + b := make([]byte, 16) |
| + rand.Read(b) |
| + key := hex.EncodeToString(b) |
| + l := &list{} |
| + a.lists[key] = l |
| + http.SetCookie(w, &http.Cookie{Name: cookieName, Value: key, Path: "/", HttpOnly: true, |
| + SameSite: http.SameSiteLaxMode, Secure: strings.HasPrefix(a.origin, "https://")}) |
| + return l |
| +} |
| + |
| +func filterPath(f string) string { |
| + switch f { |
| + case "active", "completed": |
| + return "/" + f |
| + } |
| + return "/" |
| +} |
| + |
| +func (a *app) act(f func(*list, *http.Request) int) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + if o := r.Header.Get("Origin"); o != "" && o != a.origin { |
| + http.Error(w, "cross-site request refused", http.StatusForbidden) |
| + return |
| + } |
| + r.ParseForm() |
| + a.mu.Lock() |
| + status := f(a.listFor(w, r), r) |
| + a.mu.Unlock() |
| + if status != 0 { |
| + http.Error(w, http.StatusText(status), status) |
| + return |
| + } |
| + filter := r.FormValue("filter") |
| + if r.Header.Get("X-Requested-With") == "fetch" { |
| + a.render(w, r, filter) |
| + return |
| + } |
| + http.Redirect(w, r, filterPath(filter), http.StatusSeeOther) |
| + } |
| +} |
| + |
| +func (a *app) show(filter string) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { a.render(w, r, filter) } |
| +} |
| + |
| +type view struct { |
| + Filter, Path string |
| + Items []item |
| + Total, Left, Done int |
| + AllDone bool |
| + EditID int |
| +} |
| + |
| +func (a *app) render(w http.ResponseWriter, r *http.Request, filter string) { |
| + if filter != "active" && filter != "completed" { |
| + filter = "all" |
| + } |
| + a.mu.Lock() |
| + l := a.listFor(w, r) |
| + v := view{Filter: filter, Path: filterPath(filter), Total: len(l.items)} |
| + for _, it := range l.items { |
| + if it.Completed { |
| + v.Done++ |
| + } else { |
| + v.Left++ |
| + } |
| + if filter == "all" || (filter == "completed") == it.Completed { |
| + v.Items = append(v.Items, it) |
| + } |
| + } |
| + a.mu.Unlock() |
| + v.AllDone = v.Total > 0 && v.Left == 0 |
| + v.EditID, _ = strconv.Atoi(r.URL.Query().Get("edit")) |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + if err := page.Execute(w, v); err != nil { |
| + log.Print(err) |
| + } |
| +} |
| + |
| +func main() { |
| + listen := flag.String("listen", "", "TCP address, e.g. 127.0.0.1:8080") |
| + socket := flag.String("socket", "", "unix socket path") |
| + flag.String("db", "", "ignored: refapp keeps data in memory") |
| + shared := flag.String("shared", "shared", "directory holding base.css and index.css") |
| + flag.Parse() |
| + var ln net.Listener |
| + var err error |
| + origin := "" |
| + switch { |
| + case *socket != "": |
| + os.Remove(*socket) |
| + ln, err = net.Listen("unix", *socket) |
| + origin = "https://" + os.Getenv("CARLOS_HOST") |
| + case *listen != "": |
| + ln, err = net.Listen("tcp", *listen) |
| + origin = "http://" + *listen |
| + default: |
| + log.Fatal("need --listen or --socket") |
| + } |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + log.Fatal(http.Serve(ln, NewApp(origin, *shared))) |
| +} |
diff --git a/harness/refapp/page.html.tmpl b/harness/refapp/page.html.tmpl| new file mode 100644 |
| index 0000000..e2117f1 |
| --- /dev/null |
| +++ b/harness/refapp/page.html.tmpl |
| @@ -0,0 +1,82 @@ |
| +<!doctype html> |
| +<html lang="en"> |
| + <head> |
| + <meta charset="utf-8"> |
| + <meta name="viewport" content="width=device-width, initial-scale=1"> |
| + <title>TodoMVC</title> |
| + <link rel="stylesheet" href="/static/base.css"> |
| + <link rel="stylesheet" href="/static/index.css"> |
| + <style>.js .nojs{display:none}.nojs{font-size:12px}.todo-list .nojs[data-action=edit]{position:absolute;right:60px;top:22px}.todo-list button.nojs{position:absolute;right:100px;top:20px}</style> |
| + <script>document.documentElement.classList.add('js')</script> |
| + <script type="module" src="/static/app.js"></script> |
| + </head> |
| + <body> |
| + <section class="todoapp"> |
| + <header class="header"> |
| + <h1>todos</h1> |
| + <form method="post" action="/todos"> |
| + <input type="hidden" name="filter" value="{{.Filter}}"> |
| + <input class="new-todo" name="title" placeholder="What needs to be done?" autofocus> |
| + </form> |
| + </header> |
| + {{- if .Total}} |
| + <section class="main"> |
| + <form method="post" action="/todos/toggle-all"> |
| + <input type="hidden" name="filter" value="{{.Filter}}"> |
| + <input id="toggle-all" class="toggle-all" type="checkbox"{{if .AllDone}} checked{{end}}> |
| + <label for="toggle-all">Mark all as complete</label> |
| + <button class="nojs" data-action="toggle-all">Toggle all</button> |
| + </form> |
| + <ul class="todo-list"> |
| + {{- range .Items}} |
| + <li data-id="{{.ID}}"{{if or .Completed (eq .ID $.EditID)}} class="{{if .Completed}}completed{{end}}{{if and .Completed (eq .ID $.EditID)}} {{end}}{{if eq .ID $.EditID}}editing{{end}}"{{end}}> |
| + <div class="view"> |
| + <form method="post" action="/todos/{{.ID}}/toggle"> |
| + <input type="hidden" name="filter" value="{{$.Filter}}"> |
| + <input class="toggle" type="checkbox"{{if .Completed}} checked{{end}}> |
| + <label>{{.Title}}</label> |
| + <button class="nojs" data-action="toggle">Toggle</button> |
| + </form> |
| + <a class="nojs" data-action="edit" href="{{$.Path}}?edit={{.ID}}">Edit</a> |
| + <form method="post" action="/todos/{{.ID}}/destroy"> |
| + <input type="hidden" name="filter" value="{{$.Filter}}"> |
| + <button class="destroy"></button> |
| + </form> |
| + </div> |
| + <form method="post" action="/todos/{{.ID}}/edit"> |
| + <input type="hidden" name="filter" value="{{$.Filter}}"> |
| + <input class="edit" name="title" value="{{.Title}}"{{if eq .ID $.EditID}} autofocus{{end}}> |
| + </form> |
| + </li> |
| + {{- end}} |
| + </ul> |
| + </section> |
| + <footer class="footer"> |
| + <span class="todo-count"><strong>{{.Left}}</strong> {{if eq .Left 1}}item{{else}}items{{end}} left</span> |
| + <ul class="filters"> |
| + <li> |
| + <a{{if eq .Filter "all"}} class="selected"{{end}} href="/">All</a> |
| + </li> |
| + <li> |
| + <a{{if eq .Filter "active"}} class="selected"{{end}} href="/active">Active</a> |
| + </li> |
| + <li> |
| + <a{{if eq .Filter "completed"}} class="selected"{{end}} href="/completed">Completed</a> |
| + </li> |
| + </ul> |
| + {{- if .Done}} |
| + <form method="post" action="/todos/clear-completed"> |
| + <input type="hidden" name="filter" value="{{.Filter}}"> |
| + <button class="clear-completed">Clear completed</button> |
| + </form> |
| + {{- end}} |
| + </footer> |
| + {{- end}} |
| + </section> |
| + <footer class="info"> |
| + <p>Double-click to edit a todo</p> |
| + <p>Created by <a href="https://bakeoff.bfj.oncarlos.com">the CARLOS bakeoff</a> · Reference fixture</p> |
| + <p>Part of <a href="http://todomvc.com">TodoMVC</a></p> |
| + </footer> |
| + </body> |
| +</html> |
diff --git a/harness/refapp/refapp_test.go b/harness/refapp/refapp_test.go| new file mode 100644 |
| index 0000000..3715d21 |
| --- /dev/null |
| +++ b/harness/refapp/refapp_test.go |
| @@ -0,0 +1,126 @@ |
| +package main |
| + |
| +import ( |
| + "io" |
| + "net/http" |
| + "net/http/cookiejar" |
| + "net/http/httptest" |
| + "net/url" |
| + "regexp" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func newTestServer(t *testing.T) *httptest.Server { |
| + t.Helper() |
| + srv := httptest.NewUnstartedServer(nil) |
| + srv.Start() |
| + srv.Config.Handler = NewApp(srv.URL, "../../shared") |
| + t.Cleanup(srv.Close) |
| + return srv |
| +} |
| + |
| +func client(t *testing.T) *http.Client { |
| + t.Helper() |
| + jar, _ := cookiejar.New(nil) |
| + return &http.Client{ |
| + Jar: jar, |
| + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, |
| + } |
| +} |
| + |
| +func post(t *testing.T, c *http.Client, srv *httptest.Server, path string, form url.Values, origin string) *http.Response { |
| + t.Helper() |
| + req, _ := http.NewRequest("POST", srv.URL+path, strings.NewReader(form.Encode())) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + if origin != "" { |
| + req.Header.Set("Origin", origin) |
| + } |
| + res, err := c.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + res.Body.Close() |
| + return res |
| +} |
| + |
| +func get(t *testing.T, c *http.Client, srv *httptest.Server, path string) (int, string) { |
| + t.Helper() |
| + res, err := c.Get(srv.URL + path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer res.Body.Close() |
| + b, _ := io.ReadAll(res.Body) |
| + return res.StatusCode, string(b) |
| +} |
| + |
| +var liRe = regexp.MustCompile(`<li[^>]*data-id="([^"]+)"`) |
| + |
| +func ids(body string) []string { |
| + var out []string |
| + for _, m := range liRe.FindAllStringSubmatch(body, -1) { |
| + out = append(out, m[1]) |
| + } |
| + return out |
| +} |
| + |
| +func TestAddRedirectsToFilter(t *testing.T) { |
| + srv := newTestServer(t) |
| + c := client(t) |
| + get(t, c, srv, "/") |
| + res := post(t, c, srv, "/todos", url.Values{"title": {" a "}, "filter": {"active"}}, srv.URL) |
| + if res.StatusCode != 303 || res.Header.Get("Location") != "/active" { |
| + t.Fatalf("got %d %q, want 303 /active", res.StatusCode, res.Header.Get("Location")) |
| + } |
| + _, body := get(t, c, srv, "/") |
| + if n := len(ids(body)); n != 1 { |
| + t.Fatalf("want 1 item, got %d", n) |
| + } |
| + if !strings.Contains(body, "<label>a</label>") { |
| + t.Fatalf("want trimmed label 'a'") |
| + } |
| +} |
| + |
| +func TestForeignOriginForbidden(t *testing.T) { |
| + srv := newTestServer(t) |
| + c := client(t) |
| + get(t, c, srv, "/") |
| + res := post(t, c, srv, "/todos", url.Values{"title": {"x"}}, "https://evil.example") |
| + if res.StatusCode != 403 { |
| + t.Fatalf("got %d, want 403", res.StatusCode) |
| + } |
| +} |
| + |
| +func TestOtherListIDNotFound(t *testing.T) { |
| + srv := newTestServer(t) |
| + a, b := client(t), client(t) |
| + get(t, a, srv, "/") |
| + get(t, b, srv, "/") |
| + post(t, a, srv, "/todos", url.Values{"title": {"mine"}}, srv.URL) |
| + _, body := get(t, a, srv, "/") |
| + id := ids(body)[0] |
| + res := post(t, b, srv, "/todos/"+id+"/toggle", nil, srv.URL) |
| + if res.StatusCode != 404 { |
| + t.Fatalf("got %d, want 404", res.StatusCode) |
| + } |
| + _, body = get(t, a, srv, "/completed") |
| + if len(ids(body)) != 0 { |
| + t.Fatalf("A's item was toggled by B") |
| + } |
| +} |
| + |
| +func TestGarbledCookieGetsEmptyList(t *testing.T) { |
| + srv := newTestServer(t) |
| + req, _ := http.NewRequest("GET", srv.URL+"/", nil) |
| + req.Header.Set("Cookie", "todo=%%%") |
| + res, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b, _ := io.ReadAll(res.Body) |
| + res.Body.Close() |
| + if res.StatusCode != 200 || len(ids(string(b))) != 0 { |
| + t.Fatalf("got %d with %d items", res.StatusCode, len(ids(string(b)))) |
| + } |
| +} |