// Package config is the squint.yaml file: what application to run, how to // reach it, and which screens to photograph. // // The shape is deliberately small. Everything an application needs to be // started, seeded and reached is a shell command or a URL, so squint knows // nothing about any particular framework, language or database -- and a // project that cannot express itself in these four fields is telling you // its test fixture is the problem, not the config format. package config import ( "fmt" "os" "strconv" "strings" "time" "gopkg.in/yaml.v3" ) // Config is a whole squint.yaml. type Config struct { // App is how to build, start and seed the application under test. App App `yaml:"app"` // Auth is how to log in, for scenarios that name a user. Auth *Auth `yaml:"auth"` // Viewports are the screen sizes to shoot at, by name. A scenario that // names none is shot at every one of them, which is how mobile stops // being an afterthought. Viewports map[string]Viewport `yaml:"viewports"` // Themes are variants of the same page, by name -- usually light and // dark. Each says how to ask for itself: a cookie, or nothing at all // for the default. Themes map[string]Theme `yaml:"themes"` // Rubric is the design guide review mode reads. A path, relative to // the project. Rubric string `yaml:"rubric"` // Scale is how many image pixels are captured per CSS pixel. Two is a // retina-class rendering: the file comes out twice the width of the // viewport, and a browser showing it at CSS size has two real pixels // for every one it draws, which is what makes text crisp rather than // soft. One was the old behaviour and is why screenshots looked // blurry on any display that is not 1x. // // Determinism does not require a small scale, only the same scale // everywhere, and this is set explicitly through the protocol rather // than inherited from whatever screen the machine happens to have. Scale float64 `yaml:"scale"` // Mask hides elements on every screen before the shutter. Some of // what an application shows is genuinely new every run -- a token // minted from real randomness, an id, a clock -- and no amount of // seeding fixes it from outside the process. Hiding those elements // keeps their space, so the layout is unchanged and only the volatile // text goes; what is left is a function of the code alone. Mask []string `yaml:"mask"` // Scenarios are the screens. Scenarios []Scenario `yaml:"scenarios"` } // App is the application's lifecycle. Every field is a shell command run // with the project as its working directory, except Ready which is a URL. type App struct { // Build runs once per revision, before Run. Empty is allowed: a // project that needs no build step should not have to invent one. Build string `yaml:"build"` // Run starts the application and is expected to stay in the // foreground. squint kills it when the shoot is over. Run string `yaml:"run"` // Ready is polled until it answers 200, so a shoot never races a // listener that has not bound yet. Ready string `yaml:"ready"` // Setup runs after Ready and before the first screenshot. This is // where an application seeds itself, and it is the single most // important field in the file: a screenshot of undetermined data is // a screenshot that differs for reasons that are not the code's. Setup string `yaml:"setup"` // Env is added to the environment of Build, Run and Setup. Env map[string]string `yaml:"env"` // StartTimeout bounds the wait on Ready. Default 60s. StartTimeout time.Duration `yaml:"start_timeout"` } // Auth is a form login, which is what almost every server-rendered // application has. A scenario naming a user gets a session before its // page is opened. type Auth struct { // Path is the login form's address, e.g. /login. Path string `yaml:"path"` // Form is the field names and values posted to it. A value may use // {{.User}} for the scenario's user. Form map[string]string `yaml:"form"` } // Viewport is a screen size in CSS pixels. type Viewport struct { Width int `yaml:"width"` Height int `yaml:"height"` } // Theme is a variant of every page. type Theme struct { // Cookies are set before the page loads, so it is drawn in the right // theme on the first and only paint rather than flipping afterwards. Cookies map[string]string `yaml:"cookies"` } // Scenario is one screen, in one state. type Scenario struct { // Name is the screenshot's file name and how a report refers to it. Name string `yaml:"name"` // Path is the address to open, relative to the running application. Path string `yaml:"path"` // Login is the user to be signed in as, or empty for signed out. Login string `yaml:"login"` // Viewports and Themes narrow the matrix for this scenario alone -- // a mobile drawer has no desktop rendering worth shooting. Empty // means every one the file declares. Viewports []string `yaml:"viewports"` Themes []string `yaml:"themes"` // WaitFor is a selector that must be visible before the shot, for a // panel that arrives after the first paint. WaitFor string `yaml:"wait_for"` // Mask adds to the file's own mask, for this screen alone. Mask []string `yaml:"mask"` // Steps are actions performed before the shot. Steps []Step `yaml:"steps"` } // Step is one action. Exactly one field is set; the vocabulary is // deliberately short, and JS is the escape hatch for everything else so // that the list does not have to grow forever. type Step struct { // Click is a selector to click. Click string `yaml:"click"` // Type is text to type into the selector named by Into. Type string `yaml:"type"` Into string `yaml:"into"` // WaitFor is a selector to wait for. WaitFor string `yaml:"wait_for"` // Wait is a fixed pause. A last resort: prefer WaitFor. Wait time.Duration `yaml:"wait"` // JS is raw JavaScript evaluated in the page. JS string `yaml:"js"` } // Load reads and checks a squint.yaml. func Load(path string) (*Config, error) { b, err := os.ReadFile(path) if err != nil { return nil, err } var c Config if err := yaml.Unmarshal(b, &c); err != nil { return nil, fmt.Errorf("%s: %w", path, err) } c.applyDefaults() return &c, c.check() } func (c *Config) applyDefaults() { if c.App.StartTimeout == 0 { c.App.StartTimeout = 60 * time.Second } if c.Scale == 0 { c.Scale = 2 } if len(c.Viewports) == 0 { c.Viewports = map[string]Viewport{"desktop": {Width: 1280, Height: 900}} } // A file that declares no themes still has one rendering, and naming // it keeps every screenshot's name the same shape. if len(c.Themes) == 0 { c.Themes = map[string]Theme{"default": {}} } } func (c *Config) check() error { if c.App.Run == "" { return fmt.Errorf("app.run is required: squint has to be able to start the application") } if c.App.Ready == "" { return fmt.Errorf("app.ready is required: without it a shoot races the listener") } if len(c.Scenarios) == 0 { return fmt.Errorf("no scenarios: there is nothing to photograph") } if c.Scale < 1 || c.Scale > 4 { return fmt.Errorf("scale %g is not between 1 and 4", c.Scale) } seen := map[string]bool{} for i, s := range c.Scenarios { switch { case s.Name == "": return fmt.Errorf("scenario %d has no name", i) case seen[s.Name]: return fmt.Errorf("two scenarios are called %q; names are file names", s.Name) case s.Path == "": return fmt.Errorf("scenario %q has no path", s.Name) } seen[s.Name] = true for _, v := range s.Viewports { if _, ok := c.Viewports[v]; !ok { return fmt.Errorf("scenario %q names viewport %q, which is not declared", s.Name, v) } } for _, t := range s.Themes { if _, ok := c.Themes[t]; !ok { return fmt.Errorf("scenario %q names theme %q, which is not declared", s.Name, t) } } if s.Login != "" && c.Auth == nil { return fmt.Errorf("scenario %q logs in as %q, but the file declares no auth", s.Name, s.Login) } } return nil } // Shot is one scenario at one viewport in one theme: the unit squint // actually renders, and one file on disk. type Shot struct { Scenario Scenario Viewport Viewport Theme Theme // Name is "..", the file's stem. Name string } // Matrix expands every scenario across the viewports and themes it did not // rule out. This is why a project gets its phone and dark renderings for // free rather than having to remember them: narrowing is opt-in, coverage // is the default. func (c *Config) Matrix() []Shot { var out []Shot for _, s := range c.Scenarios { for _, vn := range pick(s.Viewports, keys(c.Viewports)) { for _, tn := range pick(s.Themes, keys(c.Themes)) { out = append(out, Shot{ Scenario: s, Viewport: c.Viewports[vn], Theme: c.Themes[tn], Name: s.Name + "." + vn + "." + tn, }) } } } return out } func pick(narrowed, all []string) []string { if len(narrowed) > 0 { return narrowed } return all } // keys is sorted so a matrix is in the same order every run, which keeps a // report's rows and a log's lines stable. func keys[V any](m map[string]V) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } sortStrings(out) return out } func sortStrings(s []string) { for i := 1; i < len(s); i++ { for j := i; j > 0 && s[j] < s[j-1]; j-- { s[j], s[j-1] = s[j-1], s[j] } } } // Expand replaces {{.BaseURL}}, {{.Port}}, {{.TmpDir}} and {{.User}} in a // command or value. Deliberately not text/template: these are four names in // shell commands written by hand, and a template engine's error messages // are worse than a replacement's. func Expand(s string, vars map[string]string) string { for k, v := range vars { s = strings.ReplaceAll(s, "{{."+k+"}}", v) s = strings.ReplaceAll(s, "{{ ."+k+" }}", v) } return s } // PortString is a small helper so callers do not repeat strconv. func PortString(p int) string { return strconv.Itoa(p) }