rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1//go:build fiestaseed && browser
2
3// The Fiesta seed exporter for ui. hack/seed/run.sh copies this file
4// into a scratch rastrillo worktree at the seed commit and runs TestFiestaSeed
5// there, beside the browser drives Fiesta ports,
6// so it can call their page builders. It writes, under FIESTA_SEED_OUT:
7//
8// - check/testdata/fixtures/sizing.html, the whole sizing fixture;
9// - check/testdata/fixtures/spelling-class.html, the whole class
10// fixture in class spelling;
11// - check/testdata/fixtures/<file>/<builder>[-<case>].html, every
12// case of every page builder in the ported files, listed in
13// check/testdata/fixtures/index.json;
14// - check/testdata/legacy/{sidebar,console}.html, the pre-phone-index
15// layouts rendered the way their drives render them.
16//
17// Three kinds of fixture. A "document" is a whole page: what a builder
18// that returns an http.Handler served for one request, or what a
19// layout builder rendered, with /rastrillo.js read as /fiesta.js. A
20// "body" is what a builder handed sizingDoc. A "piece" is one rendered
21// partial a test glues into its own literal markup; the port keeps the
22// literal and reads the piece. Where a builder is a plain function it
23// is called, so its own wrapper markup is in the fixture and the port
24// retypes nothing; where a render sits inside a Test function the
25// exporter repeats that one call with rastrillo's data.
26//
27// The layout drives fill the chrome outside the
28// content slot with their own blocks: brand Harbour, nav-invoices,
29// #rail-locale, a blank brand, a whitespace view. No layout-* example
30// carries that chrome, so every one of their pages is seeded as a
31// document, with the defines the drive gives it, and the words rastrillo
32// gives it, exactly: these are test data the ported assertions match.
33//
34// With FIESTA_SEED_DRYRUN set it writes nothing. The browser tag is
35// needed because the builders live in browser-tagged files; nothing
36// here starts a browser.
37package ui
38
39import (
40 "encoding/json"
41 "errors"
42 "fmt"
43 "html/template"
44 "io/fs"
45 "net/http"
46 "net/http/httptest"
47 "os"
48 "path/filepath"
49 "strings"
50 "testing"
51
52 "amadan.net/rastrillo/rastrillo"
53)
54
55type fixtureEntry struct {
56 File string `json:"file"`
57 Builder string `json:"builder"`
58 Case string `json:"case"`
59 Path string `json:"path"`
60 Kind string `json:"kind,omitempty"`
61 Request string `json:"request,omitempty"`
62 Example string `json:"example,omitempty"`
63 Unseeded string `json:"unseeded,omitempty"`
64 Note string `json:"note,omitempty"`
65}
66
67type fixtureWriter struct {
68 t *testing.T
69 out string
70 dry bool
71 index []fixtureEntry
72 written int
73}
74
75// The drives' own words, kept exactly as rastrillo writes them, because the
76// ported assertions match them.
77const (
78 postsNav3 = `<a href="#" aria-current="page">Posts</a><a href="#">Drafts</a><a href="#">Settings</a>`
79 postsNav2 = `<a href="#" aria-current="page">Posts</a><a href="#">Drafts</a>`
80 viewIndex = `{{define "view"}}index{{end}}`
81 upInvoices = `{{define "up"}}/#nav-invoices{{end}}`
82 upOrders = `{{define "up"}}/#nav-orders{{end}}`
83 upTeam = `{{define "up"}}/#nav-team{{end}}`
84 topbarLinks = `<h1>Posts</h1><div class="spacer"></div><p><a id="main-link" href="/go/main">A link in the page</a></p><div class="spacer"></div>`
85)
86
87func TestFiestaSeed(t *testing.T) {
88 out := os.Getenv("FIESTA_SEED_OUT")
89 dry := os.Getenv("FIESTA_SEED_DRYRUN") != ""
90 if out == "" && !dry {
91 t.Skip("FIESTA_SEED_OUT is unset; this test is the Fiesta seed exporter, run by hack/seed/run.sh")
92 }
93 w := &fixtureWriter{t: t, out: out, dry: dry}
94 if !dry {
95 w.clearFixtures()
96 }
97 layout := func(shell string) []byte {
98 src, ok := Layout(shell)
99 if !ok {
100 t.Fatalf("no %s layout", shell)
101 }
102 return src
103 }
104
105 // The two whole fixtures.
106 w.whole("sizing_fixture_test.go", "sizingFixture", "sizing.html", sizingFixture(t), "the whole fixture, every adversarial case included; the port wraps it in sizingDoc")
107 w.whole("markup_v3_browser_test.go", "classFixture", "spelling-class.html", classSpelling(t, classFixture(t)), "classSpelling(t, classFixture(t)): the whole fixture in class spelling; markup.Respell derives the attribute twin")
108
109 // The legacy layouts, rendered with shellLayoutPage's defaults.
110 for _, name := range []string{"sidebar", "console"} {
111 w.write("check/testdata/legacy/"+name+".html", []byte(fiestaScripts(shellLayoutPage(t, legacyLayout(t, name), "ltr"))))
112 w.add(fixtureEntry{File: "index_browser_test.go", Builder: "legacyLayout", Case: name, Path: "../legacy/" + name + ".html", Kind: "document",
113 Note: "shellLayoutPage(t, legacyLayout(t, " + name + "), \"ltr\") with no extra defines"})
114 }
115
116 // ── browser_test.go's handlers, and scheme_browser_test.go ───────
117 h, _ := page(t, 40)
118 w.document("browser_test.go", "page", "40", "/", h, "page(t, 40)")
119 h, _ = datePage(t, "field-date", "due")
120 w.document("browser_test.go", "datePage", "field-date", "/", h, `datePage(t, "field-date", "due")`)
121 w.document("browser_test.go", "datePage", "field-date-nocal", "/?nocal", h, `datePage(t, "field-date", "due"), without calendar.js`)
122 h, _ = datePage(t, "field-time", "at")
123 w.document("browser_test.go", "datePage", "field-time", "/", h, `datePage(t, "field-time", "at")`)
124 h, _ = groupPage(t)
125 w.document("browser_test.go", "groupPage", "", "/", h, "")
126 w.document("browser_test.go", "menuPage", "", "/", menuPage(t), "")
127 w.document("browser_test.go", "menuPage", "old-spelling", "/old-spelling", menuPage(t), "the mid-upgrade page in class spelling")
128 w.document("browser_test.go", "modalPage", "", "/", modalPage(t), `Styleguide()["modal"] in a bare page; the same markup is example modal/default`)
129 w.document("browser_test.go", "fieldRowPage", "ltr", "/?dir=ltr", fieldRowPage(t), "")
130 w.document("browser_test.go", "fieldRowPage", "rtl", "/?dir=rtl", fieldRowPage(t), "")
131 h, _ = busyPage(t)
132 w.document("browser_test.go", "busyPage", "", "/", h, "")
133 w.document("browser_test.go", "bidiRowPage", "", "/", bidiRowPage(t), "")
134 w.document("scheme_browser_test.go", "schemeApp", "", "/", schemeApp(t),
135 "the column shell through ui.Pages with no scheme cookie, so System is current; its server-backed tests stay in rastrillo; only the three literal schemeFlipBody tests are ported")
136
137 // ── The component drives ─────────────────────────────────────────
138 w.piece("sizing_browser_test.go", "TestTheCalendarDocksAndItsDaysAreTaps", "field",
139 render(t, "field-date", map[string]any{"Name": "due", "Label": "Due", "Value": "2026-08-28"}), "the field both pages hold")
140 w.add(fixtureEntry{File: "sizing_browser_test.go", Builder: "TestDesktopDensityIsPinned", Case: "modal", Example: "modal/default", Note: `Styleguide()["modal"]`})
141 w.add(fixtureEntry{File: "sizing_browser_test.go", Builder: "TestEveryTapTargetIsAtLeast44Pixels", Case: "modal", Example: "modal/default", Note: `Styleguide()["modal"]`})
142 w.add(fixtureEntry{File: "sizing_browser_test.go", Builder: "TestTheFloorChangesNothingButSizeOnADesktop", Case: "", Path: "sizing.html", Kind: "body", Note: "sizingFixture(t) plus a literal tail the test keeps"})
143
144 w.body("rows_browser_test.go", "rowsFixture", "", rowsFixture(t), "")
145 w.body("rows_browser_test.go", "narrowRowsPage", "", bodyOf(t, narrowRowsPage(t)["/"]), "the / page's body")
146
147 w.body("blocks_browser_test.go", "segTabsPage", "", bodyOf(t, segTabsPage(t)["/"]), "segTabsPage(t)[\"/\"]: the tabs over the card and over the box, with their ids")
148 w.piece("blocks_browser_test.go", "TestADetailListsLabelSitsOnItsValuesFirstLine", "dl", render(t, "detail-list", map[string]any{"Items": []any{
149 map[string]any{"Label": "Audience", "Value": "Members"},
150 map[string]any{"Label": "Published", "Value": "2 August 2026", "DateTime": "2026-08-02"},
151 map[string]any{"Label": "Reference", "Value": "post_01H9ZQ", "Mono": true},
152 map[string]any{"Label": "Summary", "Value": "Release notes for August: the new export, faster search, a fix for invoices that never arrived, and the rest of the month's small changes."},
153 }}), "")
154
155 w.body("bulkbar_browser_test.go", "bulkBarPage", "", bodyOf(t, bulkBarPage(t, "ltr")), "bulkBarPage(t, \"ltr\"): every bulkBarCases entry in its <div rst-list id=\"bulk-<id>\">; the port flips dir on the document")
156
157 dropdown := func() string {
158 return render(t, "dropdown", map[string]any{"Label": "Newest", "Aria": "Sort: Newest", "Items": []any{
159 map[string]any{"Href": "/o?sort=new", "Label": "Newest", "Current": true},
160 map[string]any{"Href": "/o?sort=old", "Label": "Oldest"},
161 }})
162 }
163 w.piece("dropdown_browser_test.go", "TestADropdownOpensUnderItsOwnSummary", "partial", dropdown(), "the partial; the sample is example dropdown/menu-idiom")
164 w.piece("dropdown_browser_test.go", "TestADropdownWithoutAnchorsOrScriptsStaysOnScreen", "partial", dropdown(), "the partial; the sample is example dropdown/menu-idiom")
165 w.piece("dropdown_browser_test.go", "TestAnOpenDropdownIsPlacedAgainWhenTheWindowChanges", "menu", dropdown(), "")
166
167 w.body("pagination_browser_test.go", "paginationPage", "", bodyOf(t, paginationPage(t, "ltr")), "paginationPage(t, \"ltr\"): every paginationCases entry in its <div id=\"pg-<id>\">; the port flips dir on the document")
168
169 rowMenuPages, _ := rowMenuPage(t)
170 w.body("rowmenu_browser_test.go", "rowMenuPage", "root", bodyOf(t, rowMenuPages["/"]), "the / page: #outside and the three-row list with #row-a (/act/a, /go/view-a, the hidden fields), #row-b and #row-c (the long POST label)")
171 w.body("rowmenu_browser_test.go", "rowMenuPage", "bottom", bodyOf(t, rowMenuPages["/bottom"]), "the /bottom page: the same list under a 150vh spacer; /go/ is a literal landing page")
172 w.piece("rowmenu_browser_test.go", "TestARowMenuOpensBesideItsOwnKebab", "menu", render(t, "row-menu", map[string]any{"Name": "Grace Hopper", "Items": []any{
173 map[string]any{"Label": "Edit", "Href": "/go/edit"},
174 map[string]any{"Label": "Archive", "Action": "/act/a"},
175 map[string]any{"Label": "Delete order…", "Href": "/go/delete", "Danger": true},
176 }}), "")
177
178 w.body("mediagrid_browser_test.go", "mediaPage", "", bodyOf(t, mediaPage(t, "ltr")), "the ltr body; the port flips dir on the document; pictures at /images/tall.svg and /images/wide.svg")
179 w.body("apitokens_browser_test.go", "tokensPageDoc", "", bodyOf(t, tokensPageDoc(t)), "")
180
181 w.document("signin_browser_test.go", "signinPage", "", "/", signinPage(t),
182 "the live cases link <script type=\"module\" src=\"/static/passkey-signin.mjs\">, which rastrillo's signinServer answered with an empty script: serve /static/passkey-signin.mjs or strip it")
183 var signinBody strings.Builder
184 for i, c := range signinStates() {
185 for _, preview := range []bool{false, true} {
186 if !preview && c.st.Door() == "continue" {
187 continue
188 }
189 data := signinData(c.st)
190 data["Brand"] = map[string]any{"Name": "Harbour", "Pitch": "Moorings and berths, booked in a minute."}
191 if preview {
192 data["Preview"] = true
193 }
194 fmt.Fprintf(&signinBody, `<div id="case-%d-%v" title=%q>%s</div>`, i, preview, c.name, render(t, "signin", data))
195 }
196 }
197 w.body("signin_browser_test.go", "TestSigninControlsStayInsideTheCardOnAPhone", "", signinBody.String(), "every signinStates entry, live and in Preview, as the test builds them; the live cases link /static/passkey-signin.mjs: serve it or strip it")
198
199 // ── The shell drives ─────────────────────────────────────────────
200 w.piece("shell_browser_test.go", "TestAMenuOpenedInsideAListCardEscapesTheCard", "bulk-bar", render(t, "bulk-bar", map[string]any{
201 "DoneHref": "/posts", "Count": "3 selected",
202 "MenuLabel": "Actions",
203 "Actions": []any{
204 map[string]any{"Value": "export", "Label": "Export"},
205 map[string]any{"Value": "unpublish", "Label": "Unpublish"},
206 map[string]any{"Value": "delete", "Label": "Delete…", "Danger": true},
207 },
208 }), "")
209 w.piece("shell_browser_test.go", "TestSelfShapedChildrenKeepTheirCornersInsideACard", "list-bar-search", render(t, "list-bar-search", map[string]any{"Action": "/posts"}), "the test strips the <search> wrapper itself")
210 var menuItems []any
211 for _, name := range []string{
212 "English", "Espanol", "Portugues", "Gaeilge", "Arabiyya", "Hindi",
213 "Bangla", "Nihongo", "Russkiy", "Tieng Viet", "Jyutping", "Zhongwen",
214 } {
215 menuItems = append(menuItems, map[string]any{"Href": "#", "Label": name})
216 }
217 for _, c := range menuCorners {
218 items := menuItems
219 if c.short {
220 items = items[:3]
221 }
222 if c.rowMenu {
223 var b strings.Builder
224 b.WriteString(`<details rst-row-menu name="rst-menu-` + c.id + `">` +
225 `<summary aria-label="Actions">` + string(rastrillo.Icon("kebab")) + `</summary>` +
226 `<div rst-row-menu-panel>`)
227 for _, it := range items {
228 b.WriteString(`<a href="#">` + it.(map[string]any)["Label"].(string) + `</a>`)
229 }
230 b.WriteString(`</div></details>`)
231 w.piece("shell_browser_test.go", "TestMenusCapTheirHeightScrollAndFlipToFitTheViewport", c.id, b.String(), "the hand-written row menu around rastrillo.Icon(\"kebab\"), which fiesta.Icon(\"kebab\") gives")
232 continue
233 }
234 w.piece("shell_browser_test.go", "TestMenusCapTheirHeightScrollAndFlipToFitTheViewport", c.id, render(t, "dropdown", map[string]any{
235 "Label": "Language", "Items": items, "MenuGroup": "rst-menu-" + c.id,
236 }), "one menuCorners entry")
237 }
238 w.piece("shell_browser_test.go", "TestClearingASearchIsALinkAndTheNativeCrossIsGone", "search", render(t, "list-bar-search", map[string]any{
239 "Action": "/posts", "Query": "sere", "Placeholder": "Search posts",
240 "Hidden": [][2]string{{"status", "paid"}},
241 }), "")
242 for _, m := range []struct {
243 id string
244 items []any
245 }{{"short", menuItems[:3]}, {"long", menuItems}} {
246 w.piece("shell_browser_test.go", "TestAMenuOpenedInsideAShortFrameIsStillUsable", m.id, render(t, "dropdown", map[string]any{
247 "Label": "Menu", "Items": m.items, "MenuGroup": "rst-menu-" + m.id,
248 }), "")
249 }
250
251 // The two shell_browser_test.go drives that build their own
252 // template: the sidebar's index, page and tall-rail pages, and the
253 // topbar.
254 railDefs := []string{
255 `{{define "content"}}<p>Content.</p>{{end}}`,
256 `{{define "nav"}}` + postsNav2 + `{{end}}`,
257 `{{define "profile"}}<span rst-person-av aria-hidden="true">G</span><span rst-person-name>Grace Hopper</span><span rst-person-email>grace@example.com</span>{{end}}`,
258 `{{define "account"}}<a id="acct-profile" href="#">Profile</a>{{end}}`,
259 `{{define "locale"}}<details rst-dropdown rst-locale id="rail-locale" name="rst-menus"><summary>Language</summary><div rst-dropdown-menu><a href="#" lang="en">English</a><a href="#" lang="ga">Gaeilge</a></div></details>{{end}}`,
260 }
261 const railTest = "TestTheSidebarRailPutsThePersonAtItsFootAndTheProfileMenuOpensUpward"
262 w.page("shell_browser_test.go", railTest, "index", layoutWith(t, layout("sidebar"), append(railDefs, viewIndex)...), "the index: the drive's own defines plus the view")
263 w.page("shell_browser_test.go", railTest, "page", layoutWith(t, layout("sidebar"), railDefs...), "the content page")
264 w.page("shell_browser_test.go", railTest, "tall", layoutWith(t, layout("sidebar"), append(railDefs, `{{define "nav"}}`+railTallNav+`{{end}}`)...), "the page with railTallNav, the twenty-link rail")
265 w.page("shell_browser_test.go", "TestTheTopbarCollapsesItsTailBehindOneDisclosure", "", layoutWith(t, layout("topbar"),
266 `{{define "content"}}<p>Content.</p>{{end}}`,
267 `{{define "nav"}}`+postsNav2+`{{end}}`,
268 `{{define "account"}}<a href="#">Profile</a><a href="#">Sign out</a>{{end}}`,
269 `{{define "locale"}}<details rst-dropdown rst-locale id="bar-locale" name="rst-menus"><summary>Language</summary><div rst-dropdown-menu><a href="#" lang="en">English</a><a href="#" lang="ga">Gaeilge</a></div></details>{{end}}`,
270 ), "")
271
272 // index_browser_test.go: shellLayoutPage with the defines each
273 // drive gives it. The rows no ported drive reads say so in their
274 // note: the drives that ran on them now run on the layout examples,
275 // and the pages stay as what rastrillo rendered, for comparison.
276 const (
277 unreadExample = "; read by no drive: the ported test runs on the layout example, and this is the page rastrillo's drive rendered, kept for comparison"
278 unreadDefault = "; read by no drive: the helper's own defaults, kept for comparison with the layout examples"
279 )
280 for _, shell := range LayoutNames() {
281 w.page("index_browser_test.go", "shellLayoutPage", shell, shellLayoutPage(t, layout(shell), "ltr"), "the helper's own defaults: brand Harbour, nav-invoices/orders/team, #rail-locale, the Invoices content"+unreadDefault)
282 }
283 for _, shell := range []string{"sidebar", "console"} {
284 for _, dir := range []string{"ltr", "rtl"} {
285 src := layout(shell)
286 w.page("index_browser_test.go", "TestThePhoneIndexWorksWithNoScript", shell+"-"+dir+"-index", shellLayoutPage(t, src, dir, viewIndex), "/")
287 w.page("index_browser_test.go", "TestThePhoneIndexWorksWithNoScript", shell+"-"+dir+"-invoices", shellLayoutPage(t, src, dir, upInvoices), "/invoices")
288 w.page("index_browser_test.go", "TestThePhoneIndexWorksWithScripts", shell+"-"+dir+"-index", shellLayoutPage(t, src, dir, viewIndex), "/")
289 w.page("index_browser_test.go", "TestThePhoneIndexWorksWithScripts", shell+"-"+dir+"-invoices", shellLayoutPage(t, src, dir, upInvoices), "/invoices")
290 w.page("index_browser_test.go", "TestThePhoneIndexWorksWithScripts", shell+"-"+dir+"-orders", shellLayoutPage(t, src, dir, upOrders), "/orders")
291 w.page("index_browser_test.go", "TestTheWideLayoutIsUnchanged", shell+"-"+dir+"-index", shellLayoutPage(t, src, dir, viewIndex), "/index")
292 w.page("index_browser_test.go", "TestTheWideLayoutIsUnchanged", shell+"-"+dir+"-page", shellLayoutPage(t, src, dir), "/page")
293 w.page("index_browser_test.go", "TestTheWideLayoutIsUnchanged", shell+"-"+dir+"-legacy", shellLayoutPage(t, legacyLayout(t, shell), dir), "/legacy: the pre-phone-index layout")
294 }
295 }
296 w.add(fixtureEntry{File: "index_browser_test.go", Builder: "TestAnOldSidebarLayoutKeepsItsDrawer", Case: "", Path: "../legacy/sidebar.html", Kind: "document",
297 Note: "shellLayoutPage(t, legacyLayout(t, \"sidebar\"), \"ltr\") with no extra defines: the seeded legacy sidebar"})
298 // A slice, not a map: the rows land in index.json in this order,
299 // and a reseed must write the same file.
300 for _, c := range []struct{ kase, nav string }{
301 {"group-first", `<p rst-shell-group>Sales</p><a href="/a">A</a><a href="/b">B</a><p rst-shell-group>Settings</p><a href="/c">C</a>`},
302 {"one", `<a href="/a">A</a>`},
303 {"no-groups", `<a href="/a">A</a><a href="/b">B</a><a href="/c" aria-current="page">C</a>`},
304 } {
305 w.page("index_browser_test.go", "TestTheIndexRowsRoundEachRunOfLinks", c.kase, shellLayoutPage(t, layout("sidebar"), "ltr", viewIndex, `{{define "nav"}}`+c.nav+`{{end}}`), "/"+c.kase)
306 }
307 {
308 short := `<a id="nav-invoices" href="/invoices">Invoices</a><a id="nav-orders" href="/orders">Orders</a>`
309 var long strings.Builder
310 for i := 1; i <= 30; i++ {
311 fmt.Fprintf(&long, `<a id="nav-%d" href="/s%d">Section %d</a>`, i, i, i)
312 }
313 foot := `{{define "foot"}}<a href="/about">About</a>{{end}}`
314 src := layout("console")
315 w.page("index_browser_test.go", "TestTheConsoleIndexRailFillsTheScreen", "short", shellLayoutPage(t, src, "ltr", viewIndex, `{{define "nav"}}`+short+`{{end}}`, foot), "/short")
316 w.page("index_browser_test.go", "TestTheConsoleIndexRailFillsTheScreen", "nofoot", shellLayoutPage(t, src, "ltr", viewIndex, `{{define "nav"}}`+short+`{{end}}`), "/nofoot")
317 w.page("index_browser_test.go", "TestTheConsoleIndexRailFillsTheScreen", "long", shellLayoutPage(t, src, "ltr", viewIndex, `{{define "nav"}}`+long.String()+`{{end}}`, foot), "/long: thirty sections")
318 }
319 w.page("index_browser_test.go", "TestAWhitespaceViewIsTheIndexInTheBrowser", "", shellLayoutPage(t, layout("sidebar"), "ltr", "{{define \"view\"}}\n index\n{{end}}"), "the view block written with whitespace around index")
320 w.page("index_browser_test.go", "TestAnOldConsoleLayoutKeepsItsRailOpen", "", shellLayoutPage(t, legacyLayout(t, "console"), "ltr",
321 `{{define "nav"}}<p rst-shell-group id="group">Sales</p><a id="nav-invoices" href="/invoices">Invoices</a><a id="nav-orders" href="/orders">Orders</a>{{end}}`,
322 `{{define "content"}}<h1>Invoices</h1><details rst-dropdown id="page-menu" name="rst-menus"><summary>Sort</summary><div rst-dropdown-menu><a href="/go/new">Newest</a></div></details>{{end}}`),
323 "the legacy console with the drive's own nav and content")
324 {
325 content := `{{define "content"}}<h1>Invoices</h1><div style="block-size: 150vh"></div><h2 id="target">Target</h2><div style="block-size: 150vh"></div>{{end}}`
326 for _, shell := range []string{"sidebar", "console"} {
327 w.page("index_browser_test.go", "TestAFragmentLinkLandsBelowTheBackStrip", shell, shellLayoutPage(t, layout(shell), "ltr", content), "/page"+unreadExample)
328 }
329 }
330 for _, shell := range []string{"sidebar", "console"} {
331 src := layout(shell)
332 w.page("index_browser_test.go", "TestThePhoneHeaderStripIs56Pixels", shell+"-index", shellLayoutPage(t, src, "ltr", append(profileDefs("Harbour"), viewIndex)...), "/"+unreadExample)
333 w.page("index_browser_test.go", "TestThePhoneHeaderStripIs56Pixels", shell+"-page", shellLayoutPage(t, src, "ltr", profileDefs("Harbour")...), "/page"+unreadExample)
334 }
335
336 // console_shell_browser_test.go. consolePage returns the page and a
337 // control twin, which is the page with consoleControlCSS injected
338 // before </head>; the port derives the twin the same way.
339 const controlNote = "; the control twin is this page with <style>consoleControlCSS</style> injected before </head>"
340 {
341 page, _ := consolePage(t, postsNav3, "ltr")
342 w.page("console_shell_browser_test.go", "consolePage", "ltr", page, "consolePage(t, <Posts, Drafts, Settings>, \"ltr\")"+controlNote)
343 page, _ = consolePage(t, postsNav3, "ltr", viewIndex)
344 w.page("console_shell_browser_test.go", "consolePage", "ltr-index", page, "consolePage(t, <Posts, Drafts, Settings>, \"ltr\", view index)"+controlNote)
345 page, _ = consolePage(t, postsNav2, "rtl")
346 w.page("console_shell_browser_test.go", "consolePage", "rtl", page, "consolePage(t, <Posts, Drafts>, \"rtl\")"+controlNote)
347 page, _ = consolePage(t, railTallNav, "ltr")
348 w.page("console_shell_browser_test.go", "consolePage", "tall", page, "consolePage(t, railTallNav, \"ltr\")"+controlNote)
349 page, _ = legacyConsolePage(t, postsNav3, "ltr")
350 w.page("console_shell_browser_test.go", "legacyConsolePage", "ltr", page, "legacyConsolePage(t, <Posts, Drafts, Settings>, \"ltr\"): the legacy console with consolePageFrom's own blocks"+controlNote)
351 page, _ = consolePageFrom(t, layout("console"), postsNav3, "ltr")
352 w.page("console_shell_browser_test.go", "consolePageFrom", "console-ltr", page, "consolePageFrom with the console layout and no extra defines; consolePage and legacyConsolePage are its callers"+controlNote+unreadDefault)
353 }
354
355 // profile_browser_test.go.
356 {
357 const long = "Harbour Freight Forwarding and Customs Brokerage"
358 for _, shell := range []string{"sidebar", "console"} {
359 for _, dir := range []string{"ltr", "rtl"} {
360 src := layout(shell)
361 w.page("profile_browser_test.go", "TestTheProfileMenuOnThePhoneIndex", shell+"-"+dir+"-index", shellLayoutPage(t, src, dir, append(profileDefs("Harbour"), viewIndex)...), "/")
362 w.page("profile_browser_test.go", "TestTheProfileMenuOnThePhoneIndex", shell+"-"+dir+"-long", shellLayoutPage(t, src, dir, append(profileDefs(long), viewIndex)...), "/long: the brand "+long)
363 }
364 }
365 for _, dir := range []string{"ltr", "rtl"} {
366 w.page("profile_browser_test.go", "TestTheProfileMenuIsADropUpAtTheRailsFoot", dir, shellLayoutPage(t, layout("sidebar"), dir, profileDefs("Harbour")...), "/")
367 }
368 src := layout("sidebar")
369 noLocale := `{{define "locale"}}{{if false}}x{{end}}{{end}}`
370 noScheme := `{{define "scheme"}}{{if false}}x{{end}}{{end}}`
371 w.page("profile_browser_test.go", "TestThePersonNeverVanishesFromTheRail", "person", shellLayoutPage(t, src, "ltr", profileDefs("Harbour")[0], profileDefs("Harbour")[1], noLocale), "/person")
372 w.page("profile_browser_test.go", "TestThePersonNeverVanishesFromTheRail", "person-idx", shellLayoutPage(t, src, "ltr", profileDefs("Harbour")[0], profileDefs("Harbour")[1], noLocale, viewIndex), "/person-idx")
373 w.page("profile_browser_test.go", "TestThePersonNeverVanishesFromTheRail", "placeholder", shellLayoutPage(t, src, "ltr", noLocale, noScheme), "/placeholder")
374 w.page("profile_browser_test.go", "TestABlankBrandFallsBackToTheTitle", "", shellLayoutPage(t, src, "ltr", viewIndex,
375 `{{define "title"}}Harbour Ledger{{end}}`, "{{define \"brand\"}}\n {{if false}}<a href=\"/\">x</a>{{end}}\n{{end}}"), "a blank brand and the title Harbour Ledger")
376 }
377
378 // card_browser_test.go.
379 w.page("card_browser_test.go", "topbarPage", "ltr", topbarPage(t, "ltr"), "topbarPage(t, \"ltr\"): topbarPageWith and the drives' standard content")
380 w.page("card_browser_test.go", "topbarPage", "rtl", topbarPage(t, "rtl"), "topbarPage(t, \"rtl\")")
381 w.page("card_browser_test.go", "topbarPageWith", "modal", topbarPageWith(t, "ltr", Styleguide()["modal"]), "topbarPageWith(t, \"ltr\", Styleguide()[\"modal\"]); TestAModalCoversTheTopbarOnAPhone's topbar leg"+unreadExample)
382 for _, shell := range []string{"sidebar", "console"} {
383 w.page("card_browser_test.go", "TestAModalCoversTheTopbarOnAPhone", shell, shellLayoutPage(t, layout(shell), "ltr", upInvoices,
384 `{{define "content"}}`+Styleguide()["modal"]+`{{end}}`), "the "+shell+" content page with the modal as its content; the topbar leg is topbarPageWith-modal"+unreadExample)
385 }
386 for _, dir := range []string{"ltr", "rtl"} {
387 w.page("card_browser_test.go", "TestTheConsoleBarIsTheSameCard", dir, shellLayoutPage(t, layout("console"), dir, `{{define "account"}}<a id="acct-profile" href="/go/profile">Profile</a>{{end}}`,
388 `{{define "content"}}<h1>Invoices</h1><div style="block-size: 450px"></div><p><a id="main-link" href="/go/main">A link in the page</a></p>{{end}}`), "the same page for scripts on and off")
389 }
390
391 // prerender_browser_test.go.
392 {
393 src, stage := layout("sidebar"), layout("stage")
394 w.page("prerender_browser_test.go", "prerenderSite", "index", shellLayoutPage(t, src, "ltr", viewIndex, `{{define "content"}}<h1>Harbour</h1><p><a id="plain" href="/plain">A link in the page</a></p>{{end}}`), "/")
395 w.page("prerender_browser_test.go", "prerenderSite", "invoices", shellLayoutPage(t, src, "ltr", upInvoices), "/invoices")
396 w.page("prerender_browser_test.go", "prerenderSite", "orders", shellLayoutPage(t, src, "ltr", upOrders), "/orders")
397 w.page("prerender_browser_test.go", "prerenderSite", "team", shellLayoutPage(t, src, "ltr", upTeam), "/team")
398 w.page("prerender_browser_test.go", "prerenderSite", "plain", shellLayoutPage(t, src, "ltr"), "/plain")
399 w.page("prerender_browser_test.go", "prerenderSite", "signin", shellLayoutPage(t, stage, "ltr", `{{define "content"}}<p><a id="stage-link" href="/orders">Orders</a></p>{{end}}`), "/signin: the stage shell")
400 w.add(fixtureEntry{File: "prerender_browser_test.go", Builder: "prerenderSite", Case: "legacy", Path: "../legacy/sidebar.html", Kind: "document", Note: "/legacy: shellLayoutPage(t, legacyLayout(t, \"sidebar\"), \"ltr\"), the seeded legacy sidebar"})
401 }
402
403 w.writeIndex()
404 if dry {
405 fmt.Printf("fiesta seed dry run: %d fixture entries\n", len(w.index))
406 return
407 }
408 fmt.Printf("fiesta seed: wrote %d fixture files under %s\n", w.written, out)
409}
410
411// layoutWith renders a layout's source with the given defines and
412// nothing else, the way the two shell_browser_test.go drives that
413// build their own template do. A define repeated later in defs
414// replaces the earlier one, as the tall-rail clone's nav does.
415func layoutWith(t *testing.T, src []byte, defs ...string) string {
416 t.Helper()
417 tmpl := template.Must(template.New("layout").Funcs(Funcs()).Funcs(template.FuncMap{
418 "asset": func(p string) string { return "/" + strings.TrimPrefix(p, "static/") },
419 "iconAssets": func() template.HTML { return "" },
420 }).Parse(string(src)))
421 template.Must(tmpl.ParseFS(Templates(), "*.html"))
422 for _, d := range defs {
423 template.Must(tmpl.Parse(d))
424 }
425 var b strings.Builder
426 if err := tmpl.ExecuteTemplate(&b, "layout", nil); err != nil {
427 t.Fatalf("rendering the layout: %v", err)
428 }
429 return b.String()
430}
431
432// fiestaScripts reads /rastrillo.js as /fiesta.js in a served document,
433// the one rename Fiesta makes to the scripts it ships.
434func fiestaScripts(doc string) string {
435 return strings.ReplaceAll(doc, `src="/rastrillo.js"`, `src="/fiesta.js"`)
436}
437
438// bodyOf is what a builder handed sizingDoc: the bytes between <body>
439// and </body>.
440func bodyOf(t *testing.T, doc string) string {
441 t.Helper()
442 i, j := strings.Index(doc, "<body>"), strings.LastIndex(doc, "</body>")
443 if i < 0 || j < i {
444 t.Fatalf("no body in\n%s", doc)
445 }
446 return doc[i+len("<body>") : j]
447}
448
449func (w *fixtureWriter) write(rel string, body []byte) {
450 w.t.Helper()
451 if w.dry {
452 return
453 }
454 p := filepath.Join(w.out, filepath.FromSlash(rel))
455 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
456 w.t.Fatal(err)
457 }
458 if err := os.WriteFile(p, body, 0o644); err != nil {
459 w.t.Fatal(err)
460 }
461 w.written++
462}
463
464// clearFixtures removes what a previous run wrote and this one owns:
465// every per-file directory under fixtures/, the two whole fixtures and
466// the index, and the rendered legacy layouts. The legacy stylesheets
467// beside them are the designsystem exporter's. A directory that is
468// not there yet is fine; any other error is not.
469func (w *fixtureWriter) clearFixtures() {
470 fixtures := filepath.Join(w.out, "check", "testdata", "fixtures")
471 w.removeMatching(fixtures, func(e os.DirEntry) bool {
472 return e.IsDir() || e.Name() == "sizing.html" || e.Name() == "spelling-class.html" || e.Name() == "index.json"
473 })
474 w.removeMatching(filepath.Join(w.out, "check", "testdata", "legacy"), func(e os.DirEntry) bool {
475 return !e.IsDir() && strings.HasSuffix(e.Name(), ".html")
476 })
477}
478
479func (w *fixtureWriter) removeMatching(dir string, match func(os.DirEntry) bool) {
480 entries, err := os.ReadDir(dir)
481 if errors.Is(err, fs.ErrNotExist) {
482 return
483 }
484 if err != nil {
485 w.t.Fatal(err)
486 }
487 for _, e := range entries {
488 if match(e) {
489 if err := os.RemoveAll(filepath.Join(dir, e.Name())); err != nil {
490 w.t.Fatal(err)
491 }
492 }
493 }
494}
495
496func (w *fixtureWriter) add(e fixtureEntry) {
497 if e.Path == "" && e.Example == "" && e.Unseeded == "" {
498 w.t.Fatalf("%s %s %q has no path, example or unseeded reason", e.File, e.Builder, e.Case)
499 }
500 for _, x := range w.index {
501 if x.File == e.File && x.Builder == e.Builder && x.Case == e.Case {
502 w.t.Fatalf("%s %s %q listed twice", e.File, e.Builder, e.Case)
503 }
504 }
505 w.index = append(w.index, e)
506}
507
508func fixturePath(file, builder, kase string) string {
509 name := builder
510 if kase != "" {
511 name += "-" + kase
512 }
513 return strings.TrimSuffix(file, ".go") + "/" + name + ".html"
514}
515
516func (w *fixtureWriter) whole(file, builder, path, markup, note string) {
517 w.write("check/testdata/fixtures/"+path, []byte(markup))
518 w.add(fixtureEntry{File: file, Builder: builder, Path: path, Kind: "body", Note: note})
519}
520
521func (w *fixtureWriter) piece(file, builder, kase, markup, note string) {
522 p := fixturePath(file, builder, kase)
523 w.write("check/testdata/fixtures/"+p, []byte(markup))
524 w.add(fixtureEntry{File: file, Builder: builder, Case: kase, Path: p, Kind: "piece", Note: note})
525}
526
527func (w *fixtureWriter) body(file, builder, kase, markup, note string) {
528 p := fixturePath(file, builder, kase)
529 w.write("check/testdata/fixtures/"+p, []byte(markup))
530 w.add(fixtureEntry{File: file, Builder: builder, Case: kase, Path: p, Kind: "body", Note: note})
531}
532
533// page keeps a whole rendered page.
534func (w *fixtureWriter) page(file, builder, kase, html, note string) {
535 p := fixturePath(file, builder, kase)
536 w.write("check/testdata/fixtures/"+p, []byte(fiestaScripts(html)))
537 w.add(fixtureEntry{File: file, Builder: builder, Case: kase, Path: p, Kind: "document", Note: note})
538}
539
540// document requests one path of a handler and keeps the response.
541func (w *fixtureWriter) document(file, builder, kase, request string, h http.Handler, note string) {
542 w.t.Helper()
543 rec := httptest.NewRecorder()
544 h.ServeHTTP(rec, httptest.NewRequest("GET", request, nil))
545 if rec.Code != http.StatusOK {
546 w.t.Fatalf("%s %s: GET %s answered %d:\n%s", file, builder, request, rec.Code, rec.Body.String())
547 }
548 p := fixturePath(file, builder, kase)
549 w.write("check/testdata/fixtures/"+p, []byte(fiestaScripts(rec.Body.String())))
550 w.add(fixtureEntry{File: file, Builder: builder, Case: kase, Path: p, Kind: "document", Request: request, Note: note})
551}
552
553func (w *fixtureWriter) writeIndex() {
554 b, err := json.MarshalIndent(w.index, "", " ")
555 if err != nil {
556 w.t.Fatal(err)
557 }
558 w.write("check/testdata/fixtures/index.json", append(b, '\n'))
559}
560