// Package bakeoff is the bakeoff's measurement harness: it drives any app // that keeps CONTRACT.md and records what it measures in results/.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 // ToggleID is the item load tests toggle, chosen once by Seed so a // load step never has to fetch the page from an app under strain. ToggleID string } // 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(`]*\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 } } s.ToggleID = ids[0] return ids, nil }