package server import ( "fmt" "strings" "testing" "github.com/danyel/go-loose/web" ) // The theme origin is substituted into every HTML page. These tests cover the two // branches and the failure mode in between, because a wrong result here is invisible // until a user reports that the theme stopped applying. const pageWithThemeLines = ` ` func TestThemeOriginIsSubstituted(t *testing.T) { got := string(applyThemeOrigin([]byte(pageWithThemeLines), "https://themes.example.com")) if strings.Contains(got, themeOriginPlaceholder) { t.Error("the placeholder survived substitution") } if !strings.Contains(got, `href="https://themes.example.com/rt/v1/contract.css"`) { t.Errorf("the contract stylesheet was not rewritten:\n%s", got) } if !strings.Contains(got, `src="https://themes.example.com/rt/v1/components.js"`) { t.Errorf("the component module was not rewritten:\n%s", got) } // The application's own stylesheet must be untouched. if !strings.Contains(got, `href="/assets/style.css"`) { t.Error("the local stylesheet was rewritten") } } func TestThemeOriginTrailingSlashIsTrimmed(t *testing.T) { // A trailing slash would produce a double slash, and the service answers 404 for // "//rt/v1/contract.css" because the route is matched on the clean path. for _, base := range []string{ "https://themes.example.com/", "https://themes.example.com///", } { got := string(applyThemeOrigin([]byte(pageWithThemeLines), base)) if strings.Contains(got, "com//") { t.Errorf("base %q produced a doubled slash:\n%s", base, got) } } } func TestThemeLinesAreDroppedWhenNoOriginIsConfigured(t *testing.T) { for _, base := range []string{"", " ", "/"} { got := string(applyThemeOrigin([]byte(pageWithThemeLines), base)) if strings.Contains(got, themeOriginPlaceholder) { t.Errorf("base %q left the placeholder in place:\n%s", base, got) } // An empty href would be a same-origin request to this application, which 404s // and looks like a styling bug in the wrong service. if strings.Contains(got, `href="/rt/v1/contract.css"`) { t.Errorf("base %q emitted an empty contract href:\n%s", base, got) } if strings.Contains(got, themeMarkerContract) || strings.Contains(got, themeMarkerScript) { t.Errorf("base %q left a theme line behind:\n%s", base, got) } // The rest of the page must survive. if !strings.Contains(got, `href="/assets/style.css"`) { t.Errorf("base %q removed the local stylesheet:\n%s", base, got) } if !strings.Contains(got, "") || !strings.Contains(got, "") { t.Errorf("base %q damaged the page structure:\n%s", base, got) } } } func TestThemeOriginLeavesNonThemePagesAlone(t *testing.T) { // A page with no theme lines must come back byte-identical, so that a deployment // without the contract serving its login and install pages is a no-op. plain := []byte("\n\nno theme here\n\n") if got := applyThemeOrigin(plain, "https://themes.example.com"); string(got) != string(plain) { t.Errorf("a page without theme lines was modified:\n%s", got) } if got := applyThemeOrigin(plain, ""); string(got) != string(plain) { t.Errorf("a page without theme lines was modified:\n%s", got) } } // contractBackedTokens maps this application's own colour token onto the shared // contract role that carries the same meaning. The right-hand side is a bare role // name because the variable prefix belongs to the contract. var contractBackedTokens = map[string]string{ "--bg": "bg", "--surface": "surface", // go-loose's second surface step is the contract's raised surface. "--surface2": "surface-raised", "--text": "text", "--muted": "muted", "--accent": "accent", // go-loose's secondary accent is the contract's secondary role: for every // palette the two agree, tokyo's #bb9af7 and catppuccin's #cba6f7 among them. "--accent2": "secondary", "--good": "good", "--danger": "danger", "--border": "border", } // TestLocalTokensAdoptTheContractWhereItIsServed covers the difference between a // theme selector that is present in the page and one that works. // // Every palette this application names has to resolve through the contract's // variables, with this application's own value as the fallback. The fallback is // what keeps the install and login pages styled as they always were: they never load // the contract, so the variable is undefined there and the literal applies. Loading // the contract on a console page therefore adopts the shared palette without // changing anything for the pages that do not ask for it. // // Both directions are asserted. Adopting the role is not enough on its own: a single // declaration left holding a bare literal overrides the contract for whichever // palette that block names, and the console then renders half shared and half local. func TestLocalTokensAdoptTheContractWhereItIsServed(t *testing.T) { css, err := web.Files.ReadFile("style.css") if err != nil { t.Fatalf("style.css must be embedded: %v", err) } stylesheet := string(css) for token, role := range contractBackedTokens { if !strings.Contains(stylesheet, fmt.Sprintf("%s:var(--bn-%s,", token, role)) { t.Errorf("token %s does not adopt --bn-%s, so selecting a palette outside this application's own four changes nothing:\n%s", token, role, firstLines(css, 8)) } // A literal assignment anywhere would win for the palette it sits under, and // the contract's value for that one palette would be discarded. if literal := token + ":#"; strings.Contains(stylesheet, literal) { t.Errorf("token %s is still assigned a bare literal, which overrides the contract for the palette that block names:\n%s", token, firstLines(css, 8)) } } } // TestTheShadowTokenIsNotMapped guards the one token deliberately left alone. // The contract's shadow family holds complete box-shadow values, and this token // holds a colour, so wiring them together would substitute a shadow for a colour. func TestTheShadowTokenIsNotMapped(t *testing.T) { css, err := web.Files.ReadFile("style.css") if err != nil { t.Fatalf("style.css must be embedded: %v", err) } if strings.Contains(string(css), "--shadow:var(--bn-") { t.Error("--shadow is a colour here and the contract's shadow roles are box-shadow values; mapping them would break every elevation") } } // firstLines trims a stylesheet for an error message, since the token blocks sit at // the top of the file and the whole of it is noise in a test failure. func firstLines(css []byte, n int) string { lines := strings.SplitN(string(css), "\n", n+1) if len(lines) > n { lines = lines[:n] } return strings.Join(lines, "\n") }