panel-redesign: the design contract as guard tests and a design-first CI step
Stage 0, step 2. The guards land before any template is restyled.
Guard tests (guard_*_test.go under internal/web) read their rules from the
accepted mockups instead of copying them - the Bulma subset and WIDTH_OK
from check.py, the shell's classes from build.py, the stylesheet from
theme.css + brand.css, the skeletons from outlines.json:
- TestPanelClassVocabulary, TestTemplatesUseComponents,
TestPanelPageStructure, TestPanelOutlinesMatchMockups,
TestRedesignedPagesLoadOnlyTheKit: hold every page that has left
legacy_pages.txt to its mockup, in source and as rendered.
- TestPanelCSSIsTheMockupStylesheet, TestPanelCSSContract: panel.css is
brand.css + theme.css rule for rule.
- TestOutlinePortMatchesCheckPy: the Go port of outline() agrees with
outlines.json on all 33 screens.
- TestLegacyRatchetMatchesTheEngine, TestVendoredAssetsArePinned,
TestStaticHoldsOnlyTheKnownAssets.
- TestRoutesFollowNavigation: the 2.0 route table, no old path answered,
no template link the router does not serve.
The kit tests are skipped while legacy_pages.txt lists @kit and the route
test while it lists @routes; both were run with the entry removed and fail
where expected (no kit yet, old route table).
.github/scripts/design-first.sh, run per commit in test.yml: a commit that
touches the mockups or a guard touches nothing else under internal/web;
legacy_pages.txt only shrinks; a changed template is not, whitespace aside,
any template from history. Exercised on a scratch clone for each case.
Plan: Enforcement and Against resurrected pages describe the guards as
built (the ratchet is a file beside the guards, not a list inside them).
Roadmap 7/27.
go test ./internal/web/...
ok github.com/mixeme/selfpost/internal/web
ok github.com/mixeme/selfpost/internal/web/auth
ok github.com/mixeme/selfpost/internal/web/handlers
ok github.com/mixeme/selfpost/internal/web/validate
ok github.com/mixeme/selfpost/internal/web/view
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,729 @@
|
||||
package view
|
||||
|
||||
// GUARD FILE — docs/plans/panel-redesign.md § The contract, rule 7. Not edited
|
||||
// together with internal/web, and never to make an implementation pass: a
|
||||
// failing guard means the implementation is wrong.
|
||||
//
|
||||
// These tests are the design contract as a program. They hold everything that
|
||||
// has left legacy_pages.txt to the accepted mockups under
|
||||
// docs/assets/panel-redesign/panel/, and they read their rules from that
|
||||
// directory — the Bulma subset and the width allow-list from check.py, the
|
||||
// shell's classes from build.py, the stylesheet from theme.css and brand.css,
|
||||
// the skeletons from outlines.json — so the mockups stay the one source and a
|
||||
// design change has one route: mockup first.
|
||||
//
|
||||
// What the guards expect of the implementation (it may not be renamed away):
|
||||
// - pageFiles (view.go) maps every page name to its template files; a
|
||||
// redesigned page carries the name of its mockup (out-domains, in-domain…);
|
||||
// - templates/components.html holds every component partial, layout.html the
|
||||
// shell; a page template calls them and writes no component markup itself;
|
||||
// - static/panel.css is theme.css + brand.css, rule for rule;
|
||||
// - Engine.Render renders a page with the fixture in fixtures_test.go.
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const mockupDir = "../../../docs/assets/panel-redesign/panel"
|
||||
|
||||
// Screens that are drawn but not part of 2.0: each belongs to a roadmap item
|
||||
// with its own plan (panel-redesign.md § Room for roadmap items). They are the
|
||||
// only keys of outlines.json the finished panel may lack.
|
||||
var deferredScreens = map[string]string{
|
||||
"in-log": "inbound-antispam-panel, 2.1.0",
|
||||
"in-filter-lists": "inbound-antispam-panel, 2.1.0",
|
||||
"in-queue": "queue as a table / inbound queue, undecided",
|
||||
"in-quarantine": "inbound-quarantine, candidate",
|
||||
"in-quarantine-message": "inbound-quarantine, candidate",
|
||||
"preflight": "preflight, candidate",
|
||||
}
|
||||
|
||||
// Pages whose 2.0 content is deliberately not the mockup's (decided
|
||||
// 2026-10-09: both queues ship the old content inside the new shell until the
|
||||
// queue is a table). They obey every rule except the outline comparison.
|
||||
var outlineExempt = map[string]string{
|
||||
"out-queue": "old postqueue output in the new shell until queue-as-a-table is decided",
|
||||
}
|
||||
|
||||
// Pages that are redesigned but have no outline: the kit page is the
|
||||
// vocabulary itself, and the signed-out screens have no shell to outline.
|
||||
var noOutline = map[string]bool{"components": true, "login": true, "setup": true}
|
||||
|
||||
// The classes a page template may write itself: text and table-cell utilities
|
||||
// and the form grid. Every other sp- class is a component's and is written in
|
||||
// components.html (or layout.html) only.
|
||||
var pageUtilities = set("sp-muted", "sp-small", "sp-mono", "sp-nowrap", "sp-clip", "sp-actions", "sp-form", "sp-row", "sp-pair")
|
||||
|
||||
// The partials of panel-redesign.md § Three layers. Each is a {{define}} in
|
||||
// components.html.
|
||||
var requiredPartials = []string{
|
||||
"page_head", "postmark", "box_head", "box_foot", "flash", "health_card", "dns_record", "facts",
|
||||
"side_menu", "timeline", "log_pane", "empty_state", "confirm_list", "help_topic",
|
||||
}
|
||||
|
||||
// The typed inputs of § Component kit. Each is declared in components.go with
|
||||
// a doc comment that says where it is used.
|
||||
var requiredInputs = []string{"Head", "Box", "Record", "Fact", "SideMenu", "Tag"}
|
||||
|
||||
// Vendored files, pinned. A change here is a deliberate upgrade (or, for the
|
||||
// icon subset, a design change that added an icon) and nothing else.
|
||||
var vendoredSHA256 = map[string]string{
|
||||
"bulma.min.css": "67fa26df1ca9e95d8f2adc7c04fa1b15fa3d24257470ebc10cc68b9aab914bee",
|
||||
"tabler-icons.css": "956cfe253a5588684af3e970bd98b666e97c7044d6c2eca3a1c34ebb10f4298e",
|
||||
"tabler-icons.woff2": "4a0e1ab8991d10497efa72e1c37c307db7b9509e96450198fc421d8d36b78187",
|
||||
}
|
||||
|
||||
// Everything /static may hold. A second framework, icon set or script does not
|
||||
// get in by being dropped into the directory.
|
||||
var staticFiles = set(
|
||||
"OFL.txt", "favicon.png", "favicon.svg", "htmx.min.js", "panel.js", "panel.css",
|
||||
"ibm-plex-sans.woff2", "ibm-plex-mono-400.woff2", "ibm-plex-mono-600.woff2",
|
||||
"bulma.min.css", "tabler-icons.css", "tabler-icons.woff2",
|
||||
"logo.svg", "wordmark.svg", "perf-edge.svg",
|
||||
)
|
||||
|
||||
// What only the old pages need; gone with the last of them.
|
||||
var legacyStaticFiles = set("legacy.css", "logo-compact.svg")
|
||||
|
||||
func set(items ...string) map[string]bool {
|
||||
m := make(map[string]bool, len(items))
|
||||
for _, i := range items {
|
||||
m[i] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func sorted(m map[string]bool) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ratchet is legacy_pages.txt: what is still exempt.
|
||||
type ratchet struct {
|
||||
pages map[string]bool
|
||||
kit bool // the kit is not complete
|
||||
routes bool // the old route table is still in place
|
||||
}
|
||||
|
||||
func readRatchet(t *testing.T) ratchet {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile("legacy_pages.txt")
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return ratchet{pages: map[string]bool{}}
|
||||
}
|
||||
t.Fatalf("read legacy_pages.txt: %v", err)
|
||||
}
|
||||
r := ratchet{pages: map[string]bool{}}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case line == "" || strings.HasPrefix(line, "#"):
|
||||
case line == "@kit":
|
||||
r.kit = true
|
||||
case line == "@routes":
|
||||
r.routes = true
|
||||
case strings.HasPrefix(line, "@"):
|
||||
t.Fatalf("legacy_pages.txt: unknown entry %q", line)
|
||||
default:
|
||||
r.pages[line] = true
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// skipUntilKit keeps the kit's own tests quiet while stage 0 is under way. The
|
||||
// entry can only be removed, so the quiet ends for good with the kit.
|
||||
func skipUntilKit(t *testing.T) ratchet {
|
||||
t.Helper()
|
||||
r := readRatchet(t)
|
||||
if r.kit {
|
||||
t.Skip("legacy_pages.txt still lists @kit: the component kit is not complete, so its contract is not enforced yet")
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func mockup(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join(mockupDir, name))
|
||||
if err != nil {
|
||||
t.Fatalf("the guard tests read the accepted mockups and cannot run without them: %v", err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func embedded(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
b, err := fs.ReadFile(assetsFS, name)
|
||||
if err != nil {
|
||||
t.Fatalf("%s is not embedded: %v", name, err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func quoted(src string) []string {
|
||||
var out []string
|
||||
for _, m := range regexp.MustCompile(`"([^"]*)"`).FindAllStringSubmatch(src, -1) {
|
||||
out = append(out, m[1])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// contract is the vocabulary check.py and build.py define.
|
||||
type contract struct {
|
||||
bulma map[string]bool // the Bulma subset a screen may use
|
||||
shellBulma map[string]bool // Bulma classes of the shell (navbar…), layout.html only
|
||||
shell map[string]bool // sp- classes of the shell, layout.html only
|
||||
signedOut map[string]bool // sp- classes of the signed-out screens
|
||||
widthOK []string
|
||||
}
|
||||
|
||||
func readContract(t *testing.T) contract {
|
||||
t.Helper()
|
||||
py := mockup(t, "check.py")
|
||||
grab := func(re string) string {
|
||||
m := regexp.MustCompile(re).FindStringSubmatch(py)
|
||||
if m == nil {
|
||||
t.Fatalf("check.py no longer matches %s — the guard reads its lists from there", re)
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
c := contract{
|
||||
bulma: set(strings.Fields(grab(`(?s)BULMA = set\("""(.*?)"""`))...),
|
||||
shell: set(quoted(grab(`SHELL = \{([^}]*)\}`))...),
|
||||
signedOut: set(quoted(grab(`SIGNED_OUT = \{([^}]*)\}`))...),
|
||||
widthOK: quoted(grab(`(?s)WIDTH_OK = \((.*?)\)\n`)),
|
||||
shellBulma: map[string]bool{},
|
||||
}
|
||||
for _, cls := range staticClasses(mockup(t, "build.py")) {
|
||||
if !strings.HasPrefix(cls, "sp-") && !strings.HasPrefix(cls, "ti") && !strings.ContainsAny(cls, "{}%") {
|
||||
c.shellBulma[cls] = true
|
||||
}
|
||||
}
|
||||
if len(c.bulma) < 40 || len(c.shell) < 4 || len(c.signedOut) < 3 || len(c.widthOK) < 5 || !c.shellBulma["navbar"] {
|
||||
t.Fatalf("check.py / build.py were read but the lists look wrong: %d Bulma, %d shell, %d signed-out, %d widths, %d shell Bulma",
|
||||
len(c.bulma), len(c.shell), len(c.signedOut), len(c.widthOK), len(c.shellBulma))
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
var spClass = regexp.MustCompile(`\.(sp-[a-z0-9-]+)`)
|
||||
|
||||
func definedSP(css string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, m := range spClass.FindAllStringSubmatch(cssComment.ReplaceAllString(css, ""), -1) {
|
||||
out[m[1]] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func definedIcons(t *testing.T) map[string]bool {
|
||||
out := map[string]bool{"ti": true}
|
||||
for _, m := range regexp.MustCompile(`\.(ti-[a-z0-9-]+):`).FindAllStringSubmatch(embedded(t, "static/tabler-icons.css"), -1) {
|
||||
out[m[1]] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newGuardEngine(t *testing.T) *Engine {
|
||||
t.Helper()
|
||||
engine, err := New("9.9.9-test")
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
engine.SetInboundEnabled(true)
|
||||
engine.SetDMARCEnabled(true)
|
||||
return engine
|
||||
}
|
||||
|
||||
// renderFixture renders a redesigned page the way a handler would.
|
||||
func renderFixture(t *testing.T, engine *Engine, page string) *gnode {
|
||||
t.Helper()
|
||||
fixture, ok := pageFixtures[page]
|
||||
if !ok {
|
||||
t.Fatalf("page %q has left legacy_pages.txt but has no fixture in fixtures_test.go, so nothing can check it", page)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
engine.Render(rec, http.StatusOK, page, fixture())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("page %q does not render with its fixture: status %d, %s", page, rec.Code, strings.TrimSpace(rec.Body.String()))
|
||||
}
|
||||
return parseHTML(rec.Body.String())
|
||||
}
|
||||
|
||||
// redesigned lists the engine's pages that are held to the contract.
|
||||
func redesigned(engine *Engine, r ratchet) []string {
|
||||
var out []string
|
||||
for name := range engine.Pages() {
|
||||
if !r.pages[name] {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// pageTemplates lists the template files of the redesigned pages — everything
|
||||
// but the kit's own two files.
|
||||
func pageTemplates(engine *Engine, r ratchet) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, name := range redesigned(engine, r) {
|
||||
for _, f := range pageFiles[name] {
|
||||
if base := path.Base(f); base != "components.html" && base != "layout.html" {
|
||||
seen[f] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return sorted(seen)
|
||||
}
|
||||
|
||||
// The ratchet and the engine must describe the same panel: a name that is not
|
||||
// a page exempts nothing and only hides a typo, and a page that is off the
|
||||
// list must be a screen that was actually drawn.
|
||||
func TestLegacyRatchetMatchesTheEngine(t *testing.T) {
|
||||
r := readRatchet(t)
|
||||
engine := newGuardEngine(t)
|
||||
var golden map[string]any
|
||||
if err := json.Unmarshal([]byte(mockup(t, "outlines.json")), &golden); err != nil {
|
||||
t.Fatalf("outlines.json: %v", err)
|
||||
}
|
||||
for name := range r.pages {
|
||||
if engine.Page(name) == nil {
|
||||
t.Errorf("legacy_pages.txt lists %q, which is not a page of the engine — delete the line", name)
|
||||
}
|
||||
}
|
||||
for _, name := range redesigned(engine, r) {
|
||||
if _, drawn := golden[name]; !drawn && !noOutline[name] {
|
||||
t.Errorf("page %q is not on legacy_pages.txt and is not a drawn screen: a redesigned page carries the name of its mockup", name)
|
||||
}
|
||||
if _, deferred := deferredScreens[name]; deferred {
|
||||
t.Errorf("page %q belongs to a roadmap item (%s) and is not built with the redesign", name, deferredScreens[name])
|
||||
}
|
||||
}
|
||||
if len(r.pages) == 0 && !r.kit {
|
||||
for name := range golden {
|
||||
if _, deferred := deferredScreens[name]; !deferred && engine.Page(name) == nil {
|
||||
t.Errorf("screen %q is drawn and in scope, but the engine has no such page", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, name := range redesigned(engine, r) {
|
||||
if r.kit && name != "components" {
|
||||
t.Errorf("page %q has left legacy_pages.txt while @kit is still listed: no page is restyled before the kit is accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The Go port of outline() must read the mockups exactly as check.py does, or
|
||||
// the comparison below would be between two different measures.
|
||||
func TestOutlinePortMatchesCheckPy(t *testing.T) {
|
||||
var golden map[string]any
|
||||
if err := json.Unmarshal([]byte(mockup(t, "outlines.json")), &golden); err != nil {
|
||||
t.Fatalf("outlines.json: %v", err)
|
||||
}
|
||||
if len(golden) < 30 {
|
||||
t.Fatalf("outlines.json has %d screens; it should hold every drawn screen", len(golden))
|
||||
}
|
||||
for name, want := range golden {
|
||||
root := parseHTML(mockup(t, name+".html"))
|
||||
pages := root.find("sp-page")
|
||||
if len(pages) != 1 {
|
||||
t.Errorf("%s.html: %d sp-page containers", name, len(pages))
|
||||
continue
|
||||
}
|
||||
if got := outlineJSON(outline(pages[0])); got != outlineJSON(want) {
|
||||
t.Errorf("%s: the Go outline differs from outlines.json\n got: %s\nwant: %s", name, got, outlineJSON(want))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A different design cannot pass as this one: rendered with its fixture, a
|
||||
// page has the component skeleton recorded for its mockup.
|
||||
func TestPanelOutlinesMatchMockups(t *testing.T) {
|
||||
r := skipUntilKit(t)
|
||||
engine := newGuardEngine(t)
|
||||
var golden map[string]any
|
||||
if err := json.Unmarshal([]byte(mockup(t, "outlines.json")), &golden); err != nil {
|
||||
t.Fatalf("outlines.json: %v", err)
|
||||
}
|
||||
for _, name := range redesigned(engine, r) {
|
||||
if noOutline[name] || outlineExempt[name] != "" {
|
||||
continue
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
want, ok := golden[name]
|
||||
if !ok {
|
||||
t.Fatalf("no outline is recorded for %q", name)
|
||||
}
|
||||
pages := renderFixture(t, engine, name).find("sp-page")
|
||||
if len(pages) != 1 {
|
||||
t.Fatalf("the rendered page has %d sp-page containers; the layout provides exactly one", len(pages))
|
||||
}
|
||||
if got := outlineJSON(outline(pages[0])); got != outlineJSON(want) {
|
||||
t.Errorf("the page's component skeleton is not its mockup's\n got: %s\nwant: %s", got, outlineJSON(want))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The structure rules of check.py, on the rendered page.
|
||||
func TestPanelPageStructure(t *testing.T) {
|
||||
r := skipUntilKit(t)
|
||||
engine := newGuardEngine(t)
|
||||
for _, name := range redesigned(engine, r) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := renderFixture(t, engine, name)
|
||||
if n := len(root.byTag("h1")); n != 1 {
|
||||
t.Errorf("%d <h1>; a page has exactly one", n)
|
||||
}
|
||||
for _, tbl := range root.byTag("table") {
|
||||
if !tbl.parent.has("table-container") {
|
||||
t.Error("a <table> must sit in a table-container so a wide one can scroll")
|
||||
}
|
||||
}
|
||||
for _, box := range root.find("box") {
|
||||
if len(box.kids) == 0 || !box.kids[0].has("sp-box-head") {
|
||||
t.Errorf("a box must start with box_head (box: %q)", textOf(box))
|
||||
}
|
||||
}
|
||||
pages := root.find("sp-page")
|
||||
if name == "login" || name == "setup" {
|
||||
if len(pages) != 0 || len(root.find("sp-signin")) != 1 || len(root.find("navbar")) != 0 {
|
||||
t.Error("a signed-out page is the split sp-signin screen, without the navbar and the page container")
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(pages) != 1 || len(root.find("navbar")) != 1 || len(root.find("sp-perf")) != 1 || len(root.find("sp-foot")) != 1 {
|
||||
t.Fatal("a signed-in page is rendered by the shell: one navbar, one perforated edge, one sp-page container, one footer")
|
||||
}
|
||||
var top []*gnode
|
||||
for _, k := range pages[0].kids {
|
||||
if !k.has("sp-foot") {
|
||||
top = append(top, k)
|
||||
}
|
||||
}
|
||||
if len(top) == 0 || !top[0].has("sp-head") {
|
||||
t.Fatal("the page must open with page_head")
|
||||
}
|
||||
marks := root.find("sp-postmark")
|
||||
if name != "components" {
|
||||
if len(marks) > 1 {
|
||||
t.Errorf("%d postmarks; at most one", len(marks))
|
||||
}
|
||||
for _, m := range marks {
|
||||
if !m.parent.has("sp-head") {
|
||||
t.Error("the postmark belongs inside page_head")
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range top[1:] {
|
||||
// A flash is conditional: allowed at the top level, never part of an outline.
|
||||
if !(k.has("box") || k.has("columns") || k.has("notification") || (k.tag == "form" && k.has("sp-form"))) {
|
||||
t.Errorf("top-level <%s class=%q> — a page is boxes, columns or one form", k.tag, k.attrs["class"])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// panel.css is the mockups' stylesheet and nothing else: brand.css followed by
|
||||
// theme.css, rule for rule. A rule the mockups do not have is a design change,
|
||||
// and a design change is made in the mockup first.
|
||||
func TestPanelCSSIsTheMockupStylesheet(t *testing.T) {
|
||||
skipUntilKit(t)
|
||||
got := parseCSS(embedded(t, "static/panel.css"))
|
||||
want := append(parseCSS(mockup(t, "../shared/brand.css")), parseCSS(mockup(t, "theme.css"))...)
|
||||
for i := 0; i < len(got) || i < len(want); i++ {
|
||||
switch {
|
||||
case i >= len(got):
|
||||
t.Fatalf("panel.css ends after %d rules; the mockup stylesheet goes on with\n %s", len(got), want[i])
|
||||
case i >= len(want):
|
||||
t.Fatalf("panel.css has a rule the mockups do not have:\n %s", got[i])
|
||||
case got[i] != want[i]:
|
||||
t.Fatalf("panel.css differs from brand.css + theme.css at rule %d\n got: %s\nwant: %s", i+1, got[i], want[i])
|
||||
}
|
||||
}
|
||||
for _, m := range cssURL.FindAllStringSubmatch(embedded(t, "static/panel.css"), -1) {
|
||||
if strings.Contains(m[1], "/") || strings.Contains(m[1], ":") {
|
||||
t.Errorf("panel.css loads %q; assets sit beside it in /static and are named without a path", m[1])
|
||||
} else if _, err := fs.Stat(assetsFS, "static/"+m[1]); err != nil {
|
||||
t.Errorf("panel.css loads %q, which is not embedded", m[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The CSS rules of check.py: no !important, no id or page selector, no size
|
||||
// under the type floor, no width outside WIDTH_OK.
|
||||
func TestPanelCSSContract(t *testing.T) {
|
||||
skipUntilKit(t)
|
||||
c := readContract(t)
|
||||
var (
|
||||
idSel = regexp.MustCompile(`(^|[\s,>+~])#[a-zA-Z]`)
|
||||
pageSel = regexp.MustCompile(`\b(html|body|main)[.\[#]|\[data-page`)
|
||||
fontSize = regexp.MustCompile(`font-size\s*:\s*([\d.]+)(rem|em|px)`)
|
||||
noShrink = regexp.MustCompile(`min-width\s*:\s*0\s*(;|$)`)
|
||||
width = regexp.MustCompile(`(^|[^-\w])(max-width|min-width|width)\s*:`)
|
||||
floor = map[string]float64{"rem": 0.8125, "em": 0.85, "px": 13}
|
||||
)
|
||||
for _, rule := range parseCSS(embedded(t, "static/panel.css")) {
|
||||
sel, body := rule.selector, rule.body
|
||||
if strings.Contains(body, "!important") {
|
||||
t.Errorf("`%s`: !important", sel)
|
||||
}
|
||||
if idSel.MatchString(sel) || pageSel.MatchString(sel) {
|
||||
t.Errorf("`%s`: an id or page selector — style components, not one element on one page", sel)
|
||||
}
|
||||
// Icons (.ti) and the postmark imprint are not running text.
|
||||
for _, m := range fontSize.FindAllStringSubmatch(body, -1) {
|
||||
v, _ := strconv.ParseFloat(m[1], 64)
|
||||
if v < floor[m[2]] && !strings.Contains(sel, ".sp-postmark") && !strings.Contains(sel, ".ti") {
|
||||
t.Errorf("`%s`: font-size %s%s is below the 13px floor — use --sp-fs-xs or --sp-fs-s", sel, m[1], m[2])
|
||||
}
|
||||
}
|
||||
// `min-width: 0` sizes nothing: it only lets a flex or grid child shrink.
|
||||
if width.MatchString(noShrink.ReplaceAllString(body, "")) {
|
||||
ok := false
|
||||
for _, w := range c.widthOK {
|
||||
ok = ok || strings.Contains(sel, w)
|
||||
}
|
||||
if !ok {
|
||||
t.Errorf("`%s` sets a width; widths come from the page layout (WIDTH_OK in check.py)", sel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One vocabulary: the Bulma subset, the sp- components of panel.css, the
|
||||
// vendored icons — in the template sources and in what they render.
|
||||
func TestPanelClassVocabulary(t *testing.T) {
|
||||
r := skipUntilKit(t)
|
||||
c := readContract(t)
|
||||
engine := newGuardEngine(t)
|
||||
defined := definedSP(embedded(t, "static/panel.css"))
|
||||
icons := definedIcons(t)
|
||||
|
||||
check := func(t *testing.T, where string, classes []string, inShell, inKit bool) {
|
||||
for _, cls := range classes {
|
||||
switch {
|
||||
case strings.HasPrefix(cls, "sp-"):
|
||||
switch {
|
||||
case !defined[cls]:
|
||||
t.Errorf("%s: `%s` is not defined in panel.css", where, cls)
|
||||
case c.shell[cls] && !inShell:
|
||||
t.Errorf("%s: `%s` belongs to the shell (layout.html)", where, cls)
|
||||
case c.signedOut[cls] && !inShell && !inKit:
|
||||
t.Errorf("%s: `%s` belongs to the signed-out shell", where, cls)
|
||||
}
|
||||
case cls == "ti" || strings.HasPrefix(cls, "ti-"):
|
||||
if !icons[cls] {
|
||||
t.Errorf("%s: icon `%s` is not in the vendored subset (tabler-icons.css) — it would render as nothing", where, cls)
|
||||
}
|
||||
case c.bulma[cls], inShell && c.shellBulma[cls]:
|
||||
default:
|
||||
t.Errorf("%s: class `%s` is neither in the Bulma subset nor an sp- component", where, cls)
|
||||
}
|
||||
}
|
||||
}
|
||||
smallOnlyOnProgress := func(t *testing.T, where string, root *gnode) {
|
||||
root.walk(func(n *gnode) {
|
||||
if n.has("is-small") && !n.has("progress") {
|
||||
t.Errorf("%s: `is-small` on <%s> — controls keep the normal size; only a progress bar is small", where, n.tag)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
used := map[string]bool{}
|
||||
for _, f := range []string{"templates/components.html", "templates/layout.html"} {
|
||||
classes := staticClasses(embedded(t, f))
|
||||
check(t, f, classes, f == "templates/layout.html", true)
|
||||
for _, cls := range classes {
|
||||
used[cls] = true
|
||||
}
|
||||
}
|
||||
for _, f := range pageTemplates(engine, r) {
|
||||
src := embedded(t, f)
|
||||
check(t, f, staticClasses(src), false, false)
|
||||
smallOnlyOnProgress(t, f, parseHTML(templateAction.ReplaceAllString(src, "")))
|
||||
}
|
||||
|
||||
kit := renderFixture(t, engine, "components")
|
||||
shown := map[string]bool{}
|
||||
kit.walk(func(n *gnode) {
|
||||
for _, cls := range n.classes() {
|
||||
shown[cls], used[cls] = true, true
|
||||
}
|
||||
})
|
||||
for _, name := range redesigned(engine, r) {
|
||||
root := renderFixture(t, engine, name)
|
||||
var all []string
|
||||
root.walk(func(n *gnode) { all = append(all, n.classes()...) })
|
||||
check(t, "rendered "+name, all, true, true)
|
||||
smallOnlyOnProgress(t, "rendered "+name, root)
|
||||
for _, cls := range all {
|
||||
used[cls] = true
|
||||
if strings.HasPrefix(cls, "sp-") && !shown[cls] && !c.shell[cls] && !c.signedOut[cls] {
|
||||
t.Errorf("page %s uses `%s`, which the components page does not show — document it there or stop using it", name, cls)
|
||||
}
|
||||
}
|
||||
}
|
||||
for cls := range defined {
|
||||
if !used[cls] {
|
||||
t.Errorf("panel.css defines `%s` but no template uses it", cls)
|
||||
}
|
||||
}
|
||||
for _, cls := range sorted(definedSP(mockup(t, "theme.css"))) {
|
||||
if !defined[cls] {
|
||||
t.Errorf("the mockups define `%s`; panel.css does not", cls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Component markup exists in one place. A page calls the partial and passes
|
||||
// it a struct; it does not rebuild the component, and it does not assemble
|
||||
// the partial's input on the spot.
|
||||
func TestTemplatesUseComponents(t *testing.T) {
|
||||
r := skipUntilKit(t)
|
||||
engine := newGuardEngine(t)
|
||||
|
||||
components := embedded(t, "templates/components.html")
|
||||
for _, name := range requiredPartials {
|
||||
if !regexp.MustCompile(`\{\{-?\s*define\s+"` + name + `"`).MatchString(components) {
|
||||
t.Errorf("components.html does not define the partial %q", name)
|
||||
}
|
||||
}
|
||||
funcs := templateFuncs()
|
||||
for _, name := range []string{"status_tag", "wbr_at"} {
|
||||
if _, ok := funcs[name]; !ok {
|
||||
t.Errorf("the template function %q is missing", name)
|
||||
}
|
||||
}
|
||||
if _, ok := funcs["copy_field"]; !ok && !strings.Contains(components, `define "copy_field"`) {
|
||||
t.Error("copy_field is neither a template function nor a partial")
|
||||
}
|
||||
|
||||
fset := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fset, "components.go", nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
t.Fatalf("components.go holds the typed input of every partial: %v", err)
|
||||
}
|
||||
documented := map[string]bool{}
|
||||
for _, decl := range file.Decls {
|
||||
gen, ok := decl.(*ast.GenDecl)
|
||||
if !ok || gen.Tok != token.TYPE {
|
||||
continue
|
||||
}
|
||||
for _, spec := range gen.Specs {
|
||||
ts := spec.(*ast.TypeSpec)
|
||||
doc := ts.Doc
|
||||
if doc == nil && len(gen.Specs) == 1 {
|
||||
doc = gen.Doc
|
||||
}
|
||||
documented[ts.Name.Name] = doc != nil && strings.TrimSpace(doc.Text()) != ""
|
||||
}
|
||||
}
|
||||
for _, name := range requiredInputs {
|
||||
if doc, ok := documented[name]; !ok {
|
||||
t.Errorf("components.go does not declare the partial input %s", name)
|
||||
} else if !doc {
|
||||
t.Errorf("components.go: %s has no doc comment saying where it is used", name)
|
||||
}
|
||||
}
|
||||
|
||||
defined := definedSP(embedded(t, "static/panel.css"))
|
||||
dict := regexp.MustCompile(`\{\{[^}]*\bdict\b`)
|
||||
for _, f := range pageTemplates(engine, r) {
|
||||
src := embedded(t, f)
|
||||
for _, cls := range staticClasses(src) {
|
||||
if strings.HasPrefix(cls, "sp-") && defined[cls] && !pageUtilities[cls] {
|
||||
t.Errorf("%s writes `%s` itself; that markup belongs to a partial of components.html — call it", f, cls)
|
||||
}
|
||||
}
|
||||
if dict.MatchString(src) {
|
||||
t.Errorf("%s builds a partial's input with dict; a page passes the struct from components.go", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A redesigned page leaves nothing of the old panel on screen: it does not
|
||||
// load the old stylesheet and does not render through the old layout.
|
||||
func TestRedesignedPagesLoadOnlyTheKit(t *testing.T) {
|
||||
r := skipUntilKit(t)
|
||||
engine := newGuardEngine(t)
|
||||
for _, name := range redesigned(engine, r) {
|
||||
root := renderFixture(t, engine, name)
|
||||
var sheets []string
|
||||
for _, l := range root.byTag("link") {
|
||||
if l.attrs["rel"] == "stylesheet" {
|
||||
sheets = append(sheets, l.attrs["href"])
|
||||
}
|
||||
}
|
||||
want := []string{"/static/bulma.min.css", "/static/tabler-icons.css", "/static/panel.css"}
|
||||
if strings.Join(sheets, " ") != strings.Join(want, " ") {
|
||||
t.Errorf("page %s loads the stylesheets %v, want exactly %v in that order", name, sheets, want)
|
||||
}
|
||||
for _, s := range root.byTag("script") {
|
||||
if src := s.attrs["src"]; src != "/static/htmx.min.js" && src != "/static/panel.js" {
|
||||
t.Errorf("page %s loads the script %q", name, src)
|
||||
}
|
||||
}
|
||||
for _, img := range root.byTag("img") {
|
||||
if !strings.HasPrefix(img.attrs["src"], "/static/") {
|
||||
t.Errorf("page %s shows an image from %q", name, img.attrs["src"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVendoredAssetsArePinned(t *testing.T) {
|
||||
for name, want := range vendoredSHA256 {
|
||||
b, err := fs.ReadFile(assetsFS, "static/"+name)
|
||||
if err != nil {
|
||||
t.Errorf("%s is not vendored: %v", name, err)
|
||||
continue
|
||||
}
|
||||
sum := sha256.Sum256(b)
|
||||
if got := hex.EncodeToString(sum[:]); got != want {
|
||||
t.Errorf("%s has SHA-256 %s, pinned %s — a vendored file is replaced by a deliberate upgrade, never edited", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticHoldsOnlyTheKnownAssets(t *testing.T) {
|
||||
r := readRatchet(t)
|
||||
entries, err := fs.ReadDir(assetsFS, "static")
|
||||
if err != nil {
|
||||
t.Fatalf("read static: %v", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
switch name := e.Name(); {
|
||||
case e.IsDir():
|
||||
t.Errorf("static/%s is a directory; assets are flat files", name)
|
||||
case staticFiles[name]:
|
||||
case legacyStaticFiles[name] && len(r.pages) > 0:
|
||||
case legacyStaticFiles[name]:
|
||||
t.Errorf("static/%s served the old pages only; the last of them is gone, delete it", name)
|
||||
default:
|
||||
t.Errorf("static/%s is not an asset the panel is allowed to ship: no second framework, icon set or script", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user