feat: measurement harness

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 24a410029d4089ba26da60bc7cb68c0114058d1e parent a02281f
33 files changed, +1627 −219
  • harness/apps.go +32 −0
  • harness/behaviour.go +51 −0
  • harness/buildstats.go +87 −0
  • harness/cmd/bakeoff/main.go +251 −0
  • harness/codestats.go +93 −0
  • harness/harness_test.go +207 −0
  • harness/load.go +85 −0
  • harness/pageweight.go +33 −0
  • harness/refapp/main.go +5 −217
  • harness/refapp/{ => ref}/app.js +0 −0
  • harness/refapp/{ => ref}/page.html.tmpl +0 −0
  • harness/refapp/ref/ref.go +222 −0
  • harness/refapp/{refapp_test.go => ref/ref_test.go} +2 −2
  • harness/results.go +73 −0
  • harness/session.go +137 −0
  • harness/testdata/buildtree/Makefile +11 −0
  • harness/testdata/claude.jsonl +2 −0
  • harness/testdata/codetree/DEPS +2 −0
  • harness/testdata/codetree/README.md +1 −0
  • harness/testdata/codetree/go.sum +1 −0
  • harness/testdata/codetree/main.go +4 −0
  • harness/testdata/codetree/main_test.go +3 −0
  • harness/testdata/codetree/static/app.js +5 −0
  • harness/testdata/codetree/static/index.css +1 −0
  • harness/testdata/codetree/static/van-1.5.2.min.js +1 −0
  • harness/testdata/codetree/vendor/x.go +1 −0
  • harness/testdata/codetree/views/index.html +3 −0
  • harness/testdata/codex.jsonl +4 −0
  • harness/testdata/oha.json +113 −0
  • harness/testdata/playwright.json +4 −0
  • harness/tokens.go +111 −0
  • harness/ttfb.go +62 −0
  • harness/wake.go +20 −0
diff --git a/harness/apps.go b/harness/apps.go
new file mode 100644
index 0000000..c1a6f61
--- /dev/null
+++ b/harness/apps.go
@@ -0,0 +1,32 @@
+package bakeoff
+
+// App is one contestant.
+type App struct{ Slug, Language string }
+
+// Apps lists the contestants in report order.
+var Apps = []App{
+ {"go", "Go"},
+ {"go-rastrillo", "Go + Rastrillo"},
+ {"rust", "Rust"},
+ {"zig", "Zig"},
+ {"ocaml", "OCaml"},
+ {"haskell", "Haskell"},
+ {"swift", "Swift"},
+ {"mojo", "Mojo"},
+ {"spinel", "Spinel"},
+}
+
+// Language names a slug's setup, or returns the slug itself.
+func Language(slug string) string {
+ for _, a := range Apps {
+ if a.Slug == slug {
+ return a.Language
+ }
+ }
+ return slug
+}
+
+// AppName and Host are the slug's Carloku app and hostname.
+func AppName(slug string) string { return "todo-" + slug }
+func Host(slug string) string { return AppName(slug) + ".bfj.oncarlos.com" }
+func URL(slug string) string { return "https://" + Host(slug) }
diff --git a/harness/behaviour.go b/harness/behaviour.go
new file mode 100644
index 0000000..25a5559
--- /dev/null
+++ b/harness/behaviour.go
@@ -0,0 +1,51 @@
+package bakeoff
+
+import (
+ "encoding/json"
+ "io"
+)
+
+// ParsePlaywright counts the shared suite's results per project from
+// Playwright's JSON reporter. A flaky test (passed only on retry) counts
+// as failed: the suite runs without retries, so it shouldn't happen.
+func ParsePlaywright(r io.Reader) (map[string]Behaviour, error) {
+ type test struct {
+ Project string `json:"projectName"`
+ Status string `json:"status"`
+ }
+ type suite struct {
+ Specs []struct {
+ Tests []test `json:"tests"`
+ } `json:"specs"`
+ Suites []suite `json:"suites"`
+ }
+ var report struct {
+ Suites []suite `json:"suites"`
+ }
+ if err := json.NewDecoder(r).Decode(&report); err != nil {
+ return nil, err
+ }
+ out := map[string]Behaviour{}
+ var walk func([]suite)
+ walk = func(ss []suite) {
+ for _, s := range ss {
+ for _, sp := range s.Specs {
+ for _, t := range sp.Tests {
+ b := out[t.Project]
+ switch t.Status {
+ case "expected":
+ b.Passed++
+ case "skipped":
+ b.Skipped++
+ default:
+ b.Failed++
+ }
+ out[t.Project] = b
+ }
+ }
+ walk(s.Suites)
+ }
+ }
+ walk(report.Suites)
+ return out, nil
+}
diff --git a/harness/buildstats.go b/harness/buildstats.go
new file mode 100644
index 0000000..96e3735
--- /dev/null
+++ b/harness/buildstats.go
@@ -0,0 +1,87 @@
+package bakeoff
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "time"
+)
+
+// Build is the three build times for the shipped arm64 binary, as medians
+// in seconds, and its size.
+type Build struct {
+ ColdSeconds, WarmSeconds, EditSeconds float64
+ Bytes int64
+ Host string
+}
+
+func mk(dir string, targets ...string) error {
+ cmd := exec.Command("make", targets...)
+ cmd.Dir = dir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("make %v: %w\n%s", targets, err, out)
+ }
+ return nil
+}
+
+func timed(dir string, targets ...string) (float64, error) {
+ start := time.Now()
+ err := mk(dir, targets...)
+ return time.Since(start).Seconds(), err
+}
+
+func median(v []float64) float64 {
+ sort.Float64s(v)
+ return v[len(v)/2]
+}
+
+// BuildStats measures cold (clean-deps; COLD=1 for Go, which passes -a),
+// warm (clean) and one-line-change (bump) builds, runs times each.
+func BuildStats(dir string, goCold bool, runs int) (Build, error) {
+ coldTarget := []string{"build-arm64"}
+ if goCold {
+ coldTarget = append(coldTarget, "COLD=1")
+ }
+ var cold, warm, edit []float64
+ for i := 0; i < runs; i++ {
+ if err := mk(dir, "clean", "clean-deps"); err != nil {
+ return Build{}, err
+ }
+ s, err := timed(dir, coldTarget...)
+ if err != nil {
+ return Build{}, err
+ }
+ cold = append(cold, s)
+
+ if err := mk(dir, "clean"); err != nil {
+ return Build{}, err
+ }
+ if s, err = timed(dir, "build-arm64"); err != nil {
+ return Build{}, err
+ }
+ warm = append(warm, s)
+
+ if err := mk(dir, "bump"); err != nil {
+ return Build{}, err
+ }
+ s, err = timed(dir, "build-arm64")
+ if uerr := mk(dir, "unbump"); err == nil {
+ err = uerr
+ }
+ if err != nil {
+ return Build{}, err
+ }
+ edit = append(edit, s)
+ }
+ if err := mk(dir, "build-arm64"); err != nil {
+ return Build{}, err
+ }
+ fi, err := os.Stat(filepath.Join(dir, "dist", "app-linux-arm64"))
+ if err != nil {
+ return Build{}, err
+ }
+ host, _ := os.Hostname()
+ return Build{ColdSeconds: median(cold), WarmSeconds: median(warm), EditSeconds: median(edit), Bytes: fi.Size(), Host: host}, nil
+}
diff --git a/harness/cmd/bakeoff/main.go b/harness/cmd/bakeoff/main.go
new file mode 100644
index 0000000..5ad5a20
--- /dev/null
+++ b/harness/cmd/bakeoff/main.go
@@ -0,0 +1,251 @@
+// Command bakeoff measures one app and records the result in
+// results/<slug>.json. Run it from the repo root.
+//
+// bakeoff <ttfb|wake|load|weight|code|build|tokens|behaviour> --slug <s> [flags]
+package main
+
+import (
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ b "bakeoff/harness"
+)
+
+func main() {
+ if len(os.Args) < 2 {
+ usage()
+ }
+ cmd := os.Args[1]
+ fs := flag.NewFlagSet(cmd, flag.ExitOnError)
+ slug := fs.String("slug", "", "app slug, e.g. rust")
+ url := fs.String("url", "", "app base URL (default https://todo-<slug>.bfj.oncarlos.com)")
+ results := fs.String("results", "results", "results directory")
+ dir := fs.String("dir", "", "app directory (default apps/<slug>)")
+ runs := fs.Int("runs", 3, "build: runs per mode")
+ dur := fs.Duration("duration", 30*time.Second, "load: length of each run")
+ rest := fs.Duration("rest", 60*time.Second, "load: pause between runs")
+ levels := fs.String("levels", "1,16,64", "load: concurrency levels")
+ phase := fs.String("phase", "", "tokens: build|fix|security|review")
+ logs := fs.String("logs", "builders/logs", "tokens: log directory")
+ file := fs.String("file", "", "behaviour: Playwright JSON report")
+ fs.Parse(os.Args[2:])
+ if *slug == "" {
+ usage()
+ }
+ b.ResultsDir = *results
+ if *url == "" {
+ *url = b.URL(*slug)
+ }
+ if *dir == "" {
+ *dir = filepath.Join("apps", *slug)
+ }
+ var err error
+ switch cmd {
+ case "ttfb":
+ err = ttfb(*slug, *url)
+ case "wake":
+ err = wake(*slug, *url)
+ case "load":
+ err = load(*slug, *url, *levels, *dur, *rest)
+ case "weight":
+ err = weight(*slug, *url)
+ case "code":
+ err = code(*slug, *dir)
+ case "build":
+ err = build(*slug, *dir, *runs)
+ case "tokens":
+ err = tokens(*slug, *logs, *phase)
+ case "behaviour":
+ err = behaviour(*slug, *file)
+ default:
+ usage()
+ }
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "bakeoff %s %s: %v\n", cmd, *slug, err)
+ os.Exit(1)
+ }
+}
+
+func usage() {
+ fmt.Fprintln(os.Stderr, "usage: bakeoff <ttfb|wake|load|weight|code|build|tokens|behaviour> --slug <s> [flags]")
+ os.Exit(2)
+}
+
+func seeded(url string, n int) (*b.Session, error) {
+ s, err := b.NewSession(url)
+ if err != nil {
+ return nil, err
+ }
+ if n > 0 {
+ _, err = b.Seed(s, n)
+ }
+ return s, err
+}
+
+func ttfb(slug, url string) error {
+ empty, err := seeded(url, 0)
+ if err != nil {
+ return err
+ }
+ e, err := b.MeasureTTFB(empty, "/", 3, 30)
+ if err != nil {
+ return err
+ }
+ full, err := seeded(url, 50)
+ if err != nil {
+ return err
+ }
+ f, err := b.MeasureTTFB(full, "/", 3, 30)
+ if err != nil {
+ return err
+ }
+ fmt.Printf("%s ttfb empty p50 %.1fms, 50 items p50 %.1fms\n", slug, e.P50, f.P50)
+ return b.Update(slug, func(r *b.AppResult) { r.TTFBEmpty, r.TTFB50 = e, f })
+}
+
+// wake takes one sample if the instance is asleep, and exits 3 if it is
+// awake, so a polling loop can tell "not asleep yet" from failure.
+func wake(slug, url string) error {
+ asleep, err := b.IsAsleep(b.AppName(slug), b.Host(slug))
+ if err != nil {
+ return err
+ }
+ if !asleep {
+ fmt.Printf("%s is awake; no sample\n", slug)
+ os.Exit(3)
+ }
+ // A fresh visitor, without first fetching / (that would wake it).
+ s, err := b.SessionNoFetch(url)
+ if err != nil {
+ return err
+ }
+ ms, err := b.MeasureWake(s, "/")
+ if err != nil {
+ return err
+ }
+ fmt.Printf("%s wake %.1fms\n", slug, ms)
+ return b.Update(slug, func(r *b.AppResult) { r.Wake = append(r.Wake, ms) })
+}
+
+func load(slug, url, levels string, d, rest time.Duration) error {
+ s, err := seeded(url, 50)
+ if err != nil {
+ return err
+ }
+ var out []b.LoadResult
+ first := true
+ for _, sc := range []string{"read", "toggle"} {
+ for _, l := range strings.Split(levels, ",") {
+ c, err := strconv.Atoi(strings.TrimSpace(l))
+ if err != nil {
+ return err
+ }
+ if !first {
+ time.Sleep(rest)
+ }
+ first = false
+ r, err := b.RunLoad(s, sc, c, d)
+ if err != nil {
+ return err
+ }
+ fmt.Printf("%s %s c=%d: %.0f rps, p50 %.1fms, p99 %.1fms, errors %.2f%%\n", slug, sc, c, r.RPS, r.P50Ms, r.P99Ms, r.ErrorRate*100)
+ out = append(out, r)
+ }
+ }
+ return b.Update(slug, func(r *b.AppResult) { r.Load = out })
+}
+
+func weight(slug, url string) error {
+ s, err := seeded(url, 50)
+ if err != nil {
+ return err
+ }
+ w, err := b.PageWeight(s, "/")
+ if err != nil {
+ return err
+ }
+ fmt.Printf("%s page weight (50 items): html %d bytes, js %d bytes\n", slug, w.HTMLBytes, w.JSBytes)
+ return b.Update(slug, func(r *b.AppResult) { r.Weight = w })
+}
+
+func code(slug, dir string) error {
+ c, err := b.CountCode(dir)
+ if err != nil {
+ return err
+ }
+ fmt.Printf("%s code: %+v\n", slug, c)
+ return b.Update(slug, func(r *b.AppResult) { r.Code = c })
+}
+
+func build(slug, dir string, runs int) error {
+ st, err := b.BuildStats(dir, strings.HasPrefix(slug, "go"), runs)
+ if err != nil {
+ return err
+ }
+ fmt.Printf("%s build: cold %.1fs, warm %.1fs, edit %.1fs, %d bytes on %s\n", slug, st.ColdSeconds, st.WarmSeconds, st.EditSeconds, st.Bytes, st.Host)
+ return b.Update(slug, func(r *b.AppResult) { r.Build = st })
+}
+
+func tokens(slug, logs, phase string) error {
+ parse := b.ParseClaudeLog
+ if phase == "review" {
+ parse = b.ParseCodexLog
+ } else if phase != "build" && phase != "fix" && phase != "security" {
+ return errors.New("--phase must be build, fix, security or review")
+ }
+ files, _ := filepath.Glob(filepath.Join(logs, slug+"."+phase+"*.jsonl"))
+ if len(files) == 0 {
+ return fmt.Errorf("no %s logs for %s in %s", phase, slug, logs)
+ }
+ var total b.Tokens
+ for _, f := range files {
+ fh, err := os.Open(f)
+ if err != nil {
+ return err
+ }
+ t, err := parse(fh)
+ fh.Close()
+ if err != nil {
+ return fmt.Errorf("%s: %w", f, err)
+ }
+ total = total.Add(t)
+ }
+ fmt.Printf("%s %s tokens: %+v\n", slug, phase, total)
+ return b.Update(slug, func(r *b.AppResult) {
+ switch phase {
+ case "build":
+ r.TokensBuild = total
+ case "fix":
+ r.TokensFix = total
+ case "security":
+ r.TokensSecurity = total
+ case "review":
+ r.TokensReview = total
+ }
+ })
+}
+
+func behaviour(slug, file string) error {
+ var in io.Reader = os.Stdin
+ if file != "" {
+ f, err := os.Open(file)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ in = f
+ }
+ got, err := b.ParsePlaywright(in)
+ if err != nil {
+ return err
+ }
+ fmt.Printf("%s behaviour: js-on %+v, js-off %+v\n", slug, got["js-on"], got["js-off"])
+ return b.Update(slug, func(r *b.AppResult) { r.BehaviourJSOn, r.BehaviourJSOff = got["js-on"], got["js-off"] })
+}
diff --git a/harness/codestats.go b/harness/codestats.go
new file mode 100644
index 0000000..7d85213
--- /dev/null
+++ b/harness/codestats.go
@@ -0,0 +1,93 @@
+package bakeoff
+
+import (
+ "bufio"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+// CodeStats counts non-blank lines by role, plus direct dependencies.
+type CodeStats struct{ Server, Templates, JS, Tests, Deps int }
+
+var (
+ skipDirs = map[string]bool{"dist": true, "vendor": true, ".cache": true, "node_modules": true, ".git": true, "_build": true, "dist-newstyle": true, "target": true, "zig-out": true, ".zig-cache": true, ".build": true}
+ skipExts = map[string]bool{".css": true, ".md": true, ".sum": true, ".lock": true, ".json": true, ".db": true, ".png": true, ".ico": true, ".svg": true}
+ sourceExts = map[string]bool{".go": true, ".rs": true, ".zig": true, ".ml": true, ".mli": true, ".hs": true, ".swift": true, ".mojo": true, ".🔥": true, ".rb": true, ".c": true, ".h": true, ".js": true, ".mjs": true, ".html": true, ".tmpl": true, ".erb": true, ".mustache": true, ".eex": true, ".sql": true}
+ templateExt = map[string]bool{".html": true, ".tmpl": true, ".erb": true, ".mustache": true, ".eex": true}
+ testName = regexp.MustCompile(`(_test\.|\.test\.|_spec\.|Spec\.hs$|Test\.hs$|Tests\.swift$)`)
+ vanName = regexp.MustCompile(`^van[-.]`)
+)
+
+// CountCode classifies every source file under dir. Build output, vendored
+// code, lockfiles, CSS and docs are skipped; DEPS is counted as Deps.
+func CountCode(dir string) (CodeStats, error) {
+ var c CodeStats
+ err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ rel, _ := filepath.Rel(dir, p)
+ if d.IsDir() {
+ if p != dir && skipDirs[d.Name()] {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if rel == "DEPS" {
+ n, err := lines(p)
+ c.Deps = n
+ return err
+ }
+ ext := strings.ToLower(filepath.Ext(p))
+ if skipExts[ext] || !sourceExts[ext] || vanName.MatchString(d.Name()) {
+ return nil
+ }
+ n, err := lines(p)
+ if err != nil {
+ return err
+ }
+ parts := strings.Split(filepath.ToSlash(rel), "/")
+ inDir := func(names ...string) bool {
+ for _, seg := range parts[:len(parts)-1] {
+ for _, n := range names {
+ if seg == n {
+ return true
+ }
+ }
+ }
+ return false
+ }
+ switch {
+ case inDir("test", "tests") || testName.MatchString(d.Name()):
+ c.Tests += n
+ case ext == ".js" || ext == ".mjs":
+ c.JS += n
+ case templateExt[ext] || inDir("templates", "views"):
+ c.Templates += n
+ default:
+ c.Server += n
+ }
+ return nil
+ })
+ return c, err
+}
+
+func lines(path string) (int, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return 0, err
+ }
+ defer f.Close()
+ n := 0
+ sc := bufio.NewScanner(f)
+ sc.Buffer(make([]byte, 1<<20), 1<<24)
+ for sc.Scan() {
+ if strings.TrimSpace(sc.Text()) != "" {
+ n++
+ }
+ }
+ return n, sc.Err()
+}
diff --git a/harness/harness_test.go b/harness/harness_test.go
new file mode 100644
index 0000000..0c7ba3b
--- /dev/null
+++ b/harness/harness_test.go
@@ -0,0 +1,207 @@
+package bakeoff
+
+import (
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "bakeoff/harness/refapp/ref"
+)
+
+func refServer(t *testing.T) *httptest.Server {
+ t.Helper()
+ srv := httptest.NewUnstartedServer(nil)
+ srv.Start()
+ srv.Config.Handler = ref.NewApp(srv.URL, "../shared")
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func TestSeed(t *testing.T) {
+ srv := refServer(t)
+ s, err := NewSession(srv.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ids, err := Seed(s, 50)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(ids) != 50 {
+ t.Fatalf("want 50 ids, got %d", len(ids))
+ }
+ done, err := s.IDs("/completed")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(done) != 25 {
+ t.Fatalf("want 25 completed, got %d", len(done))
+ }
+ if !strings.Contains(s.CookieHeader(), "=") {
+ t.Fatalf("no cookie: %q", s.CookieHeader())
+ }
+}
+
+func TestParseOha(t *testing.T) {
+ b, _ := os.ReadFile("testdata/oha.json")
+ r, err := ParseOha(b, 200)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if r.RPS <= 0 || r.P99Ms < r.P50Ms || r.P50Ms <= 0 {
+ t.Fatalf("bad result %+v", r)
+ }
+ if r.ErrorRate != 0 {
+ t.Fatalf("deadline aborts must not count as errors, got %v", r.ErrorRate)
+ }
+}
+
+func TestParseOhaCountsErrors(t *testing.T) {
+ b := []byte(`{"summary":{"requestsPerSec":10},"latencyPercentiles":{"p50":0.001,"p95":0.002,"p99":0.003},
+ "statusCodeDistribution":{"303":80,"500":10},"errorDistribution":{"connection refused":10,"aborted due to deadline":3}}`)
+ r, err := ParseOha(b, 303)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if r.ErrorRate != 0.2 {
+ t.Fatalf("want 0.2, got %v", r.ErrorRate)
+ }
+ if r.P50Ms != 1 || r.P99Ms != 3 {
+ t.Fatalf("want ms, got %+v", r)
+ }
+}
+
+func TestParseClaudeLog(t *testing.T) {
+ f, _ := os.Open("testdata/claude.jsonl")
+ defer f.Close()
+ tk, err := ParseClaudeLog(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if tk.Output != 4 || tk.CacheWrite != 32396 || tk.Turns != 1 || tk.CostUSD < 0.25 || tk.Seconds <= 0 {
+ t.Fatalf("bad tokens %+v", tk)
+ }
+}
+
+func TestParseCodexLog(t *testing.T) {
+ f, _ := os.Open("testdata/codex.jsonl")
+ defer f.Close()
+ tk, err := ParseCodexLog(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if tk.Input != 16532 || tk.CacheRead != 12032 || tk.Output != 5 || tk.Turns != 1 {
+ t.Fatalf("bad tokens %+v", tk)
+ }
+}
+
+func TestCountCode(t *testing.T) {
+ c, err := CountCode("testdata/codetree")
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := CodeStats{Server: 3, Templates: 2, JS: 4, Tests: 2, Deps: 2}
+ if c != want {
+ t.Fatalf("got %+v, want %+v", c, want)
+ }
+}
+
+func TestPageWeight(t *testing.T) {
+ srv := refServer(t)
+ s, _ := NewSession(srv.URL)
+ w, err := PageWeight(s, "/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ js, _ := os.ReadFile("refapp/ref/app.js")
+ if w.JSBytes != len(js) || w.HTMLBytes <= 0 {
+ t.Fatalf("got %+v, want JSBytes %d", w, len(js))
+ }
+}
+
+func TestMeasureTTFB(t *testing.T) {
+ srv := refServer(t)
+ s, _ := NewSession(srv.URL)
+ st, err := MeasureTTFB(s, "/", 1, 5)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if st.N != 5 || st.Min > st.P50 || st.P50 > st.Max || st.Min <= 0 {
+ t.Fatalf("bad stats %+v", st)
+ }
+}
+
+func TestBuildStats(t *testing.T) {
+ dir := t.TempDir()
+ mk, _ := os.ReadFile("testdata/buildtree/Makefile")
+ os.WriteFile(filepath.Join(dir, "Makefile"), mk, 0o644)
+ b, err := BuildStats(dir, false, 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !(b.ColdSeconds > b.WarmSeconds && b.WarmSeconds > b.EditSeconds && b.EditSeconds > 0) {
+ t.Fatalf("want cold > warm > edit > 0, got %+v", b)
+ }
+ if b.Bytes != 6 {
+ t.Fatalf("want 6 bytes, got %d", b.Bytes)
+ }
+ if _, err := os.Stat(filepath.Join(dir, ".unbumped")); err != nil {
+ t.Fatal("unbump was not run")
+ }
+}
+
+func TestUpdateRoundTrip(t *testing.T) {
+ dir := t.TempDir()
+ if err := UpdateIn(dir, "rust", func(r *AppResult) { r.Language = "Rust"; r.Wake = append(r.Wake, 12.5) }); err != nil {
+ t.Fatal(err)
+ }
+ if err := UpdateIn(dir, "rust", func(r *AppResult) { r.Wake = append(r.Wake, 13) }); err != nil {
+ t.Fatal(err)
+ }
+ r, err := LoadIn(dir, "rust")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if r.Slug != "rust" || r.Language != "Rust" || len(r.Wake) != 2 {
+ t.Fatalf("got %+v", r)
+ }
+}
+
+func TestRunLoadAgainstRefapp(t *testing.T) {
+ if testing.Short() {
+ t.Skip("runs oha")
+ }
+ if _, err := exec.LookPath("oha"); err != nil {
+ t.Skip("oha not installed")
+ }
+ srv := refServer(t)
+ s, _ := NewSession(srv.URL)
+ if _, err := Seed(s, 10); err != nil {
+ t.Fatal(err)
+ }
+ for _, sc := range []string{"read", "toggle"} {
+ r, err := RunLoad(s, sc, 2, time.Second)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if r.RPS <= 0 || r.ErrorRate != 0 || r.Scenario != sc || r.Concurrency != 2 {
+ t.Fatalf("%s: bad result %+v", sc, r)
+ }
+ }
+}
+
+func TestParsePlaywright(t *testing.T) {
+ f, _ := os.Open("testdata/playwright.json")
+ defer f.Close()
+ got, err := ParsePlaywright(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got["js-on"] != (Behaviour{Passed: 1, Failed: 1}) || got["js-off"] != (Behaviour{Failed: 1, Skipped: 1}) {
+ t.Fatalf("got %+v (flaky counts as failed)", got)
+ }
+}
diff --git a/harness/load.go b/harness/load.go
new file mode 100644
index 0000000..07f00e1
--- /dev/null
+++ b/harness/load.go
@@ -0,0 +1,85 @@
+package bakeoff
+
+import (
+ "encoding/json"
+ "fmt"
+ "os/exec"
+ "strconv"
+ "time"
+)
+
+// LoadResult is one oha run.
+type LoadResult struct {
+ Scenario string
+ Concurrency int
+ RPS, P50Ms, P95Ms, P99Ms float64
+ ErrorRate float64
+}
+
+// ParseOha reads oha's JSON output. Any status other than ok, and any
+// transport error except oha's own end-of-run deadline aborts, is an error.
+func ParseOha(b []byte, ok int) (LoadResult, error) {
+ var o struct {
+ Summary struct {
+ RequestsPerSec float64 `json:"requestsPerSec"`
+ } `json:"summary"`
+ Latency map[string]float64 `json:"latencyPercentiles"`
+ Status map[string]int `json:"statusCodeDistribution"`
+ Errors map[string]int `json:"errorDistribution"`
+ }
+ if err := json.Unmarshal(b, &o); err != nil {
+ return LoadResult{}, err
+ }
+ var good, bad int
+ for code, n := range o.Status {
+ if code == strconv.Itoa(ok) {
+ good += n
+ } else {
+ bad += n
+ }
+ }
+ for msg, n := range o.Errors {
+ if msg != "aborted due to deadline" {
+ bad += n
+ }
+ }
+ r := LoadResult{
+ RPS: o.Summary.RequestsPerSec,
+ P50Ms: o.Latency["p50"] * 1000,
+ P95Ms: o.Latency["p95"] * 1000,
+ P99Ms: o.Latency["p99"] * 1000,
+ }
+ if good+bad > 0 {
+ r.ErrorRate = float64(bad) / float64(good+bad)
+ }
+ return r, nil
+}
+
+// RunLoad drives the app with oha for d at the given concurrency.
+// "read" is GET / (200); "toggle" is POST /todos/<first id>/toggle (303).
+func RunLoad(s *Session, scenario string, concurrency int, d time.Duration) (LoadResult, error) {
+ args := []string{"--no-tui", "--output-format", "json", "-z", d.String(), "-c", strconv.Itoa(concurrency),
+ "--redirect", "0", "-H", "Cookie: " + s.CookieHeader(), "-H", "Origin: " + s.origin()}
+ ok := 200
+ target := s.Base.String() + "/"
+ switch scenario {
+ case "read":
+ case "toggle":
+ ids, err := s.IDs("/")
+ if err != nil || len(ids) == 0 {
+ return LoadResult{}, fmt.Errorf("toggle needs a seeded list: %v", err)
+ }
+ ok = 303
+ target = s.Base.String() + "/todos/" + ids[0] + "/toggle"
+ args = append(args, "-m", "POST", "-T", "application/x-www-form-urlencoded", "-d", "filter=all")
+ default:
+ return LoadResult{}, fmt.Errorf("unknown scenario %q", scenario)
+ }
+ out, err := exec.Command("oha", append(args, target)...).Output()
+ if err != nil {
+ return LoadResult{}, fmt.Errorf("oha: %w", err)
+ }
+ r, err := ParseOha(out, ok)
+ r.Scenario, r.Concurrency = scenario, concurrency
+ return r, err
+}
diff --git a/harness/pageweight.go b/harness/pageweight.go
new file mode 100644
index 0000000..ebec910
--- /dev/null
+++ b/harness/pageweight.go
@@ -0,0 +1,33 @@
+package bakeoff
+
+import (
+ "net/url"
+ "regexp"
+)
+
+// Weight is what a first visit to a page downloads, apart from the shared
+// CSS every app serves identically.
+type Weight struct{ HTMLBytes, JSBytes int }
+
+var scriptSrc = regexp.MustCompile(`<script\b[^>]*\bsrc="([^"]+)"`)
+
+// PageWeight sums the page's HTML and every same-origin script it loads.
+func PageWeight(s *Session, path string) (Weight, error) {
+ html, err := s.get(path)
+ if err != nil {
+ return Weight{}, err
+ }
+ w := Weight{HTMLBytes: len(html)}
+ for _, m := range scriptSrc.FindAllStringSubmatch(html, -1) {
+ u, err := s.Base.Parse(m[1])
+ if err != nil || u.Host != s.Base.Host {
+ continue
+ }
+ js, err := s.get((&url.URL{Path: u.Path, RawQuery: u.RawQuery}).String())
+ if err != nil {
+ return Weight{}, err
+ }
+ w.JSBytes += len(js)
+ }
+ return w, nil
+}
diff --git a/harness/refapp/main.go b/harness/refapp/main.go
index 3a695d6..826fb29 100644
--- a/harness/refapp/main.go
+++ b/harness/refapp/main.go
@@ -1,228 +1,16 @@
-// 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.
+// Command refapp serves the bakeoff's reference fixture (package ref).
+// 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)
- }
-}
+ "bakeoff/harness/refapp/ref"
+)
func main() {
listen := flag.String("listen", "", "TCP address, e.g. 127.0.0.1:8080")
@@ -247,5 +35,5 @@ func main() {
if err != nil {
log.Fatal(err)
}
- log.Fatal(http.Serve(ln, NewApp(origin, *shared)))
+ log.Fatal(http.Serve(ln, ref.NewApp(origin, *shared)))
}
diff --git a/harness/refapp/app.js b/harness/refapp/ref/app.js
similarity index 100%
rename from harness/refapp/app.js
rename to harness/refapp/ref/app.js
diff --git a/harness/refapp/page.html.tmpl b/harness/refapp/ref/page.html.tmpl
similarity index 100%
rename from harness/refapp/page.html.tmpl
rename to harness/refapp/ref/page.html.tmpl
diff --git a/harness/refapp/ref/ref.go b/harness/refapp/ref/ref.go
new file mode 100644
index 0000000..1d1907b
--- /dev/null
+++ b/harness/refapp/ref/ref.go
@@ -0,0 +1,222 @@
+// Package ref 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 ref
+
+import (
+ "crypto/rand"
+ _ "embed"
+ "encoding/hex"
+ "html/template"
+ "log"
+ "net/http"
+ "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)
+ }
+}
diff --git a/harness/refapp/refapp_test.go b/harness/refapp/ref/ref_test.go
similarity index 97%
rename from harness/refapp/refapp_test.go
rename to harness/refapp/ref/ref_test.go
index 3715d21..4745871 100644
--- a/harness/refapp/refapp_test.go
+++ b/harness/refapp/ref/ref_test.go
@@ -1,4 +1,4 @@
-package main
+package ref
import (
"io"
@@ -15,7 +15,7 @@ func newTestServer(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewUnstartedServer(nil)
srv.Start()
- srv.Config.Handler = NewApp(srv.URL, "../../shared")
+ srv.Config.Handler = NewApp(srv.URL, "../../../shared")
t.Cleanup(srv.Close)
return srv
}
diff --git a/harness/results.go b/harness/results.go
new file mode 100644
index 0000000..274b9fb
--- /dev/null
+++ b/harness/results.go
@@ -0,0 +1,73 @@
+package bakeoff
+
+import (
+ "encoding/json"
+ "errors"
+ "io/fs"
+ "os"
+ "path/filepath"
+)
+
+// ResultsDir is where results/<slug>.json live, relative to the repo root.
+var ResultsDir = "results"
+
+// Readability is Fable's blind rubric score (per-field median of 3 runs).
+type Readability struct {
+ Naming, Structure, Traceability, Errors, Comments, Overall float64
+ Notes string
+}
+
+// Behaviour is one project's run of the shared suite.
+type Behaviour struct{ Passed, Failed, Skipped int }
+
+// AppResult is everything the report shows for one app.
+type AppResult struct {
+ Slug, Language, Status, URL, Version, Stack string
+ BehaviourJSOn, BehaviourJSOff Behaviour
+ TTFBEmpty, TTFB50 Stats
+ Wake []float64
+ Load []LoadResult
+ Weight Weight
+ Code CodeStats
+ Build Build
+ TokensBuild, TokensFix, TokensSecurity Tokens
+ TokensReview Tokens
+ Readability Readability
+ ReviewFindings []string
+ Notes []string
+}
+
+// LoadIn reads dir/<slug>.json, or returns an empty result for slug.
+func LoadIn(dir, slug string) (AppResult, error) {
+ r := AppResult{Slug: slug}
+ b, err := os.ReadFile(filepath.Join(dir, slug+".json"))
+ if errors.Is(err, fs.ErrNotExist) {
+ return r, nil
+ }
+ if err != nil {
+ return r, err
+ }
+ err = json.Unmarshal(b, &r)
+ return r, err
+}
+
+// UpdateIn applies f to dir/<slug>.json and writes it back.
+func UpdateIn(dir, slug string, f func(*AppResult)) error {
+ r, err := LoadIn(dir, slug)
+ if err != nil {
+ return err
+ }
+ f(&r)
+ r.Slug = slug
+ b, err := json.MarshalIndent(r, "", " ")
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+ return os.WriteFile(filepath.Join(dir, slug+".json"), append(b, '\n'), 0o644)
+}
+
+// Update applies f to results/<slug>.json.
+func Update(slug string, f func(*AppResult)) error { return UpdateIn(ResultsDir, slug, f) }
diff --git a/harness/session.go b/harness/session.go
new file mode 100644
index 0000000..375742c
--- /dev/null
+++ b/harness/session.go
@@ -0,0 +1,137 @@
+// Package bakeoff is the bakeoff's measurement harness: it drives any app
+// that keeps CONTRACT.md and records what it measures in results/<slug>.json.
+package bakeoff
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/cookiejar"
+ "net/url"
+ "regexp"
+ "strings"
+ "time"
+)
+
+// Session is one visitor: a cookie jar (so one list), an Origin header
+// matching the app, and a client that never follows redirects.
+type Session struct {
+ Base *url.URL
+ Client *http.Client
+}
+
+// SessionNoFetch starts a visitor for the app at base without making any
+// request (so a sleeping instance stays asleep until the caller's first one).
+func SessionNoFetch(base string) (*Session, error) {
+ u, err := url.Parse(strings.TrimRight(base, "/"))
+ if err != nil {
+ return nil, err
+ }
+ jar, _ := cookiejar.New(nil)
+ return &Session{Base: u, Client: &http.Client{
+ Jar: jar,
+ Timeout: 60 * time.Second,
+ CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
+ }}, nil
+}
+
+// NewSession starts a visitor for the app at base and fetches / once so the
+// app sets its list cookie.
+func NewSession(base string) (*Session, error) {
+ s, err := SessionNoFetch(base)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := s.get("/"); err != nil {
+ return nil, err
+ }
+ return s, nil
+}
+
+func (s *Session) origin() string { return s.Base.Scheme + "://" + s.Base.Host }
+
+func (s *Session) get(path string) (string, error) {
+ res, err := s.Client.Get(s.Base.String() + path)
+ if err != nil {
+ return "", err
+ }
+ defer res.Body.Close()
+ b, err := io.ReadAll(res.Body)
+ if res.StatusCode != 200 {
+ return "", fmt.Errorf("GET %s: %s", path, res.Status)
+ }
+ return string(b), err
+}
+
+func (s *Session) post(path string, form url.Values) error {
+ req, _ := http.NewRequest("POST", s.Base.String()+path, strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", s.origin())
+ res, err := s.Client.Do(req)
+ if err != nil {
+ return err
+ }
+ io.Copy(io.Discard, res.Body)
+ res.Body.Close()
+ if res.StatusCode != http.StatusSeeOther {
+ return fmt.Errorf("POST %s: %s, want 303", path, res.Status)
+ }
+ return nil
+}
+
+// Add creates one item.
+func (s *Session) Add(title string) error {
+ return s.post("/todos", url.Values{"title": {title}, "filter": {"all"}})
+}
+
+// Toggle flips one item.
+func (s *Session) Toggle(id string) error {
+ return s.post("/todos/"+id+"/toggle", url.Values{"filter": {"all"}})
+}
+
+var liID = regexp.MustCompile(`<li\b[^>]*\bdata-id="([^"]+)"`)
+
+// IDs lists the item ids shown on the page at path, in order.
+func (s *Session) IDs(path string) ([]string, error) {
+ body, err := s.get(path)
+ if err != nil {
+ return nil, err
+ }
+ var ids []string
+ for _, m := range liID.FindAllStringSubmatch(body, -1) {
+ ids = append(ids, m[1])
+ }
+ return ids, nil
+}
+
+// CookieHeader is the Cookie header this visitor sends, for tools like oha.
+func (s *Session) CookieHeader() string {
+ var parts []string
+ for _, c := range s.Client.Jar.Cookies(s.Base) {
+ parts = append(parts, c.Name+"="+c.Value)
+ }
+ return strings.Join(parts, "; ")
+}
+
+// Seed adds "item 1".."item n" and completes every second one, returning
+// the ids in list order.
+func Seed(s *Session, n int) ([]string, error) {
+ for i := 1; i <= n; i++ {
+ if err := s.Add(fmt.Sprintf("item %d", i)); err != nil {
+ return nil, err
+ }
+ }
+ ids, err := s.IDs("/")
+ if err != nil {
+ return nil, err
+ }
+ if len(ids) != n {
+ return nil, fmt.Errorf("seeded %d items, page shows %d", n, len(ids))
+ }
+ for i := 1; i < n; i += 2 {
+ if err := s.Toggle(ids[i]); err != nil {
+ return nil, err
+ }
+ }
+ return ids, nil
+}
diff --git a/harness/testdata/buildtree/Makefile b/harness/testdata/buildtree/Makefile
new file mode 100644
index 0000000..b07318e
--- /dev/null
+++ b/harness/testdata/buildtree/Makefile
@@ -0,0 +1,11 @@
+clean:
+ touch .clean
+clean-deps:
+ touch .cold
+build-arm64:
+ @if [ -f .cold ]; then sleep 0.4; elif [ -f .clean ]; then sleep 0.2; else sleep 0.05; fi
+ @rm -f .cold .clean; mkdir -p dist; printf binary > dist/app-linux-arm64
+bump:
+ touch .bumped
+unbump:
+ rm -f .bumped; touch .unbumped
diff --git a/harness/testdata/claude.jsonl b/harness/testdata/claude.jsonl
new file mode 100644
index 0000000..f9b5ea1
--- /dev/null
+++ b/harness/testdata/claude.jsonl
@@ -0,0 +1,2 @@
+{"type": "system", "subtype": "init", "session_id": "3f18d62d-a39b-491d-a21c-033c0a23cf8c", "model": "claude-opus-5-5"}
+{"duration_api_ms":1009,"stop_reason":"end_turn","session_id":"3f18d62d-a39b-491d-a21c-033c0a23cf8c","total_cost_usd":0.259256,"usage":{"input_tokens":2,"cache_creation_input_tokens":32396,"cache_read_input_tokens":0,"output_tokens":4,"output_tokens_details":{"thinking_tokens":0},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":32396,"ephemeral_5m_input_tokens":0},"inference_geo":"global","iterations":[{"input_tokens":2,"output_tokens":4,"cache_read_input_tokens":0,"cache_creation_input_tokens":32396,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":32396},"type":"message"}],"speed":"standard","fallback_credit":null},"modelUsage":{"claude-opus-5-5":{"inputTokens":2,"outputTokens":4,"cacheReadInputTokens":0,"cacheCreationInputTokens":32396,"webSearchRequests":0,"costUSD":0.259256,"contextWindow":1000000,"maxOutputTokens":128000,"thinkingTokens":0,"canonicalModel":"claude-opus-5-5","provider":"firstParty","costBasis":"list"}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"safety_stops":0,"is_error":false,"num_turns":1,"subtype":"success","api_error_status":null,"result":"ok","ttft_ms":1050,"type":"result","duration_ms":1081,"uuid":"877244f9-f3f5-4ea4-b949-56bb8e95d0ac","ttft_stream_ms":1046,"time_to_request_ms":141,"first_content_frame_ms":1046,"queued_turn_count":0,"result_index":0}
diff --git a/harness/testdata/codetree/DEPS b/harness/testdata/codetree/DEPS
new file mode 100644
index 0000000..8f3b64f
--- /dev/null
+++ b/harness/testdata/codetree/DEPS
@@ -0,0 +1,2 @@
+modernc.org/sqlite
+github.com/x/y
diff --git a/harness/testdata/codetree/README.md b/harness/testdata/codetree/README.md
new file mode 100644
index 0000000..77ebc65
--- /dev/null
+++ b/harness/testdata/codetree/README.md
@@ -0,0 +1 @@
+# hi
diff --git a/harness/testdata/codetree/go.sum b/harness/testdata/codetree/go.sum
new file mode 100644
index 0000000..21c26a2
--- /dev/null
+++ b/harness/testdata/codetree/go.sum
@@ -0,0 +1 @@
+x v1
diff --git a/harness/testdata/codetree/main.go b/harness/testdata/codetree/main.go
new file mode 100644
index 0000000..da29a2c
--- /dev/null
+++ b/harness/testdata/codetree/main.go
@@ -0,0 +1,4 @@
+package main
+
+func main() {
+}
diff --git a/harness/testdata/codetree/main_test.go b/harness/testdata/codetree/main_test.go
new file mode 100644
index 0000000..02e0b75
--- /dev/null
+++ b/harness/testdata/codetree/main_test.go
@@ -0,0 +1,3 @@
+package main
+
+func TestX() {}
diff --git a/harness/testdata/codetree/static/app.js b/harness/testdata/codetree/static/app.js
new file mode 100644
index 0000000..ec1a904
--- /dev/null
+++ b/harness/testdata/codetree/static/app.js
@@ -0,0 +1,5 @@
+a()
+b()
+
+c()
+d()
diff --git a/harness/testdata/codetree/static/index.css b/harness/testdata/codetree/static/index.css
new file mode 100644
index 0000000..c0e1a88
--- /dev/null
+++ b/harness/testdata/codetree/static/index.css
@@ -0,0 +1 @@
+body{}
diff --git a/harness/testdata/codetree/static/van-1.5.2.min.js b/harness/testdata/codetree/static/van-1.5.2.min.js
new file mode 100644
index 0000000..0d583b2
--- /dev/null
+++ b/harness/testdata/codetree/static/van-1.5.2.min.js
@@ -0,0 +1 @@
+van()
diff --git a/harness/testdata/codetree/vendor/x.go b/harness/testdata/codetree/vendor/x.go
new file mode 100644
index 0000000..823aafd
--- /dev/null
+++ b/harness/testdata/codetree/vendor/x.go
@@ -0,0 +1 @@
+package x
diff --git a/harness/testdata/codetree/views/index.html b/harness/testdata/codetree/views/index.html
new file mode 100644
index 0000000..14dc35f
--- /dev/null
+++ b/harness/testdata/codetree/views/index.html
@@ -0,0 +1,3 @@
+<ul>
+
+</ul>
diff --git a/harness/testdata/codex.jsonl b/harness/testdata/codex.jsonl
new file mode 100644
index 0000000..0a303a8
--- /dev/null
+++ b/harness/testdata/codex.jsonl
@@ -0,0 +1,4 @@
+{"type":"thread.started","thread_id":"01a16a0a-5383-7e52-8256-b3587c7f3e34"}
+{"type":"turn.started"}
+{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"ok"}}
+{"type":"turn.completed","usage":{"input_tokens":16532,"cached_input_tokens":12032,"cache_write_input_tokens":0,"output_tokens":5,"reasoning_output_tokens":0}}
diff --git a/harness/testdata/oha.json b/harness/testdata/oha.json
new file mode 100644
index 0000000..2547003
--- /dev/null
+++ b/harness/testdata/oha.json
@@ -0,0 +1,113 @@
+{
+ "summary": {
+ "successRate": 1.0,
+ "total": 2.002044707,
+ "slowest": 0.001128352,
+ "fastest": 0.000024325,
+ "average": 0.00004434557004230867,
+ "requestsPerSec": 88072.95830282375,
+ "totalData": 202770300,
+ "sizePerRequest": 1150,
+ "sizePerSec": 101281604.39725885
+ },
+ "metrics": {
+ "success_rate": 1.0,
+ "requests_per_sec": 88072.95830282375,
+ "latency_ms": {
+ "min": 0.024,
+ "mean": 0.044,
+ "p50": 0.041,
+ "p95": 0.064,
+ "p99": 0.122,
+ "max": 1.128
+ }
+ },
+ "responseTimeHistogram": {
+ "0.000024325": 1,
+ "0.0001347277": 174835,
+ "0.0002451304": 1331,
+ "0.0003555331": 86,
+ "0.0004659358": 27,
+ "0.0005763385": 8,
+ "0.0006867411999999999": 9,
+ "0.0007971438999999999": 11,
+ "0.0009075466": 6,
+ "0.0010179492999999999": 3,
+ "0.001128352": 5
+ },
+ "latencyPercentiles": {
+ "p10": 0.00003236,
+ "p25": 0.000035586,
+ "p50": 0.000041016,
+ "p75": 0.000047889,
+ "p90": 0.000056946,
+ "p95": 0.00006431,
+ "p99": 0.000121646,
+ "p99.9": 0.000231642,
+ "p99.99": 0.000770035
+ },
+ "firstByteHistogram": {
+ "0.000024265": 1,
+ "0.0001346637": 174835,
+ "0.00024506239999999997": 1331,
+ "0.0003554611": 86,
+ "0.00046585979999999995": 27,
+ "0.0005762584999999999": 8,
+ "0.0006866572": 9,
+ "0.0007970558999999999": 11,
+ "0.0009074545999999999": 6,
+ "0.0010178533": 3,
+ "0.001128252": 5
+ },
+ "firstBytePercentiles": {
+ "p10": 0.0000323,
+ "p25": 0.000035516,
+ "p50": 0.000040927,
+ "p75": 0.000047789,
+ "p90": 0.000056836,
+ "p95": 0.000064199,
+ "p99": 0.000121566,
+ "p99.9": 0.000231512,
+ "p99.99": 0.000769834
+ },
+ "rps": {
+ "mean": 88106.24588366134,
+ "stddev": 5088.620854504324,
+ "max": 97430.19361700135,
+ "min": 71435.4391213481,
+ "percentiles": {
+ "p10": 80919.3882854328,
+ "p25": 84988.29155995119,
+ "p50": 89145.47310583276,
+ "p75": 91732.7394146948,
+ "p90": 93820.32148163307,
+ "p95": 94789.10426300664,
+ "p99": 96751.88803755454,
+ "p99.9": 97430.19361700135,
+ "p99.99": 97430.19361700135
+ }
+ },
+ "details": {
+ "DNSDialup": {
+ "average": 0.00011522725,
+ "fastest": 0.000056355,
+ "slowest": 0.000245667
+ },
+ "DNSLookup": {
+ "average": 9.272250000000001e-6,
+ "fastest": 3.637e-6,
+ "slowest": 0.000018875
+ },
+ "firstByte": {
+ "average": 0.00004425681693719456,
+ "fastest": 0.000024265,
+ "slowest": 0.001128252
+ }
+ },
+ "statusCodeDistribution": {
+ "200": 176322
+ },
+ "errorDistribution": {
+ "aborted due to deadline": 4
+ }
+}
\ No newline at end of file
diff --git a/harness/testdata/playwright.json b/harness/testdata/playwright.json
new file mode 100644
index 0000000..5759fa3
--- /dev/null
+++ b/harness/testdata/playwright.json
@@ -0,0 +1,4 @@
+{"stats":{"expected":2,"skipped":1,"unexpected":1},"suites":[{"title":"todomvc.spec.ts","specs":[
+ {"title":"a","tests":[{"projectName":"js-on","status":"expected"},{"projectName":"js-off","status":"unexpected"}]}],
+ "suites":[{"title":"Editing","specs":[
+ {"title":"b","tests":[{"projectName":"js-on","status":"flaky"},{"projectName":"js-off","status":"skipped"}]}]}]}]}
diff --git a/harness/tokens.go b/harness/tokens.go
new file mode 100644
index 0000000..6f583d2
--- /dev/null
+++ b/harness/tokens.go
@@ -0,0 +1,111 @@
+package bakeoff
+
+import (
+ "bufio"
+ "encoding/json"
+ "errors"
+ "io"
+)
+
+// Tokens is what one agent session cost.
+type Tokens struct {
+ Input, Output, CacheRead, CacheWrite int64
+ Turns int
+ Seconds float64
+ CostUSD float64
+}
+
+// Add sums two sessions.
+func (t Tokens) Add(o Tokens) Tokens {
+ return Tokens{t.Input + o.Input, t.Output + o.Output, t.CacheRead + o.CacheRead, t.CacheWrite + o.CacheWrite,
+ t.Turns + o.Turns, t.Seconds + o.Seconds, t.CostUSD + o.CostUSD}
+}
+
+func scanLines(r io.Reader, f func([]byte)) error {
+ sc := bufio.NewScanner(r)
+ sc.Buffer(make([]byte, 1<<20), 1<<26)
+ for sc.Scan() {
+ f(sc.Bytes())
+ }
+ return sc.Err()
+}
+
+// ParseClaudeLog reads `claude -p --output-format stream-json` output and
+// returns the final result event's totals. Per-model usage (which includes
+// any subagents) is summed when present.
+func ParseClaudeLog(r io.Reader) (Tokens, error) {
+ type usage struct {
+ Input int64 `json:"input_tokens"`
+ Output int64 `json:"output_tokens"`
+ CacheRead int64 `json:"cache_read_input_tokens"`
+ CacheWrite int64 `json:"cache_creation_input_tokens"`
+ }
+ type modelUsage struct {
+ Input int64 `json:"inputTokens"`
+ Output int64 `json:"outputTokens"`
+ CacheRead int64 `json:"cacheReadInputTokens"`
+ CacheWrite int64 `json:"cacheCreationInputTokens"`
+ }
+ var last *Tokens
+ err := scanLines(r, func(b []byte) {
+ var e struct {
+ Type string `json:"type"`
+ Usage usage `json:"usage"`
+ ModelUsage map[string]modelUsage `json:"modelUsage"`
+ Turns int `json:"num_turns"`
+ DurationMs float64 `json:"duration_ms"`
+ Cost float64 `json:"total_cost_usd"`
+ }
+ if json.Unmarshal(b, &e) != nil || e.Type != "result" {
+ return
+ }
+ t := Tokens{Input: e.Usage.Input, Output: e.Usage.Output, CacheRead: e.Usage.CacheRead, CacheWrite: e.Usage.CacheWrite,
+ Turns: e.Turns, Seconds: e.DurationMs / 1000, CostUSD: e.Cost}
+ if len(e.ModelUsage) > 0 {
+ t.Input, t.Output, t.CacheRead, t.CacheWrite = 0, 0, 0, 0
+ for _, m := range e.ModelUsage {
+ t.Input += m.Input
+ t.Output += m.Output
+ t.CacheRead += m.CacheRead
+ t.CacheWrite += m.CacheWrite
+ }
+ }
+ last = &t
+ })
+ if err != nil {
+ return Tokens{}, err
+ }
+ if last == nil {
+ return Tokens{}, errors.New("no result event in claude log")
+ }
+ return *last, nil
+}
+
+// ParseCodexLog sums the usage on every turn.completed event of
+// `codex exec --json` output. Codex's input count includes cached input.
+func ParseCodexLog(r io.Reader) (Tokens, error) {
+ var t Tokens
+ err := scanLines(r, func(b []byte) {
+ var e struct {
+ Type string `json:"type"`
+ Usage struct {
+ Input int64 `json:"input_tokens"`
+ Cached int64 `json:"cached_input_tokens"`
+ CacheWrite int64 `json:"cache_write_input_tokens"`
+ Output int64 `json:"output_tokens"`
+ } `json:"usage"`
+ }
+ if json.Unmarshal(b, &e) != nil || e.Type != "turn.completed" {
+ return
+ }
+ t.Input += e.Usage.Input
+ t.CacheRead += e.Usage.Cached
+ t.CacheWrite += e.Usage.CacheWrite
+ t.Output += e.Usage.Output
+ t.Turns++
+ })
+ if err == nil && t.Turns == 0 {
+ err = errors.New("no turn.completed event in codex log")
+ }
+ return t, err
+}
diff --git a/harness/ttfb.go b/harness/ttfb.go
new file mode 100644
index 0000000..a27d341
--- /dev/null
+++ b/harness/ttfb.go
@@ -0,0 +1,62 @@
+package bakeoff
+
+import (
+ "crypto/tls"
+ "io"
+ "net/http"
+ "net/http/httptrace"
+ "sort"
+ "time"
+)
+
+// Stats summarises a set of samples in milliseconds.
+type Stats struct {
+ N int
+ P50, P95, P99, Min, Max float64
+}
+
+func summarise(ms []float64) Stats {
+ sort.Float64s(ms)
+ pick := func(p float64) float64 { return ms[int(p*float64(len(ms)-1)+0.5)] }
+ return Stats{N: len(ms), P50: pick(0.5), P95: pick(0.95), P99: pick(0.99), Min: ms[0], Max: ms[len(ms)-1]}
+}
+
+// timeToFirstByte makes one GET on a fresh connection (DNS, TCP and TLS
+// included, as a new visitor would pay them) and returns the milliseconds
+// until the first response byte.
+func (s *Session) timeToFirstByte(path string) (float64, error) {
+ tr := &http.Transport{DisableKeepAlives: true, TLSClientConfig: &tls.Config{}}
+ defer tr.CloseIdleConnections()
+ c := &http.Client{Transport: tr, Jar: s.Client.Jar, CheckRedirect: s.Client.CheckRedirect, Timeout: s.Client.Timeout}
+ var first time.Time
+ req, _ := http.NewRequest("GET", s.Base.String()+path, nil)
+ req = req.WithContext(httptrace.WithClientTrace(req.Context(), &httptrace.ClientTrace{
+ GotFirstResponseByte: func() { first = time.Now() },
+ }))
+ start := time.Now()
+ res, err := c.Do(req)
+ if err != nil {
+ return 0, err
+ }
+ io.Copy(io.Discard, res.Body)
+ res.Body.Close()
+ return float64(first.Sub(start).Microseconds()) / 1000, nil
+}
+
+// MeasureTTFB discards warmup requests, then times n.
+func MeasureTTFB(s *Session, path string, warmup, n int) (Stats, error) {
+ for i := 0; i < warmup; i++ {
+ if _, err := s.timeToFirstByte(path); err != nil {
+ return Stats{}, err
+ }
+ }
+ ms := make([]float64, 0, n)
+ for i := 0; i < n; i++ {
+ v, err := s.timeToFirstByte(path)
+ if err != nil {
+ return Stats{}, err
+ }
+ ms = append(ms, v)
+ }
+ return summarise(ms), nil
+}
diff --git a/harness/wake.go b/harness/wake.go
new file mode 100644
index 0000000..5727373
--- /dev/null
+++ b/harness/wake.go
@@ -0,0 +1,20 @@
+package bakeoff
+
+import (
+ "os/exec"
+ "strings"
+)
+
+// IsAsleep asks the platform whether the instance at host is hibernating.
+func IsAsleep(app, host string) (bool, error) {
+ out, err := exec.Command("carlos", "instances", "list", "--account", "bfj", "--app", app, "--health", "asleep").CombinedOutput()
+ if err != nil {
+ return false, err
+ }
+ return strings.Contains(string(out), host), nil
+}
+
+// MeasureWake times the first request to a sleeping instance.
+func MeasureWake(s *Session, path string) (float64, error) {
+ return s.timeToFirstByte(path)
+}