| 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. |
| 37 | package ui |
| 38 | |
| 39 | import ( |
| 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 | |
| 55 | type 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 | |
| 67 | type 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. |
| 77 | const ( |
| 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 | |
| 87 | func 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. |
| 415 | func 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. |
| 434 | func 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>. |
| 440 | func 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 | |
| 449 | func (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. |
| 469 | func (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 | |
| 479 | func (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 | |
| 496 | func (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 | |
| 508 | func 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 | |
| 516 | func (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 | |
| 521 | func (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 | |
| 527 | func (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. |
| 534 | func (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. |
| 541 | func (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 | |
| 553 | func (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 | |