rastrillo / fiesta Public

Sync select.js's placement fix from Rastrillo, and hold the docs and the releases to the version

The sync check against Rastrillo's origin/main found the CSS and
rastrillo.js unchanged since the seed, and two scripts moved. select.js
had a bug fix: a list fixed or anchored out of a bar that scrolls
sideways was held to the bar's overflow and opened 0px tall, because the
room was cut to every scrolling ancestor rather than to the ones that
can clip the list. The fix walks the chain of containing blocks by CSS's
own rules (paint containment and content-visibility, will-change by each
property's rule, the top layer, slots, table rows and ruby skipped, and
aliases only where the engine has them), and adds
data-rst-select-ceiling, floor's twin for a sticky header. The two
drives came with it, worded without Rastrillo's review ids, and the
select.js cap moves from 54KB to 57KB: Rastrillo holds the same code at
55KB, and the string tables are the bytes between. Against the old
select.js the ceiling leg and fifteen clip-or-escape rows fail. busy.js's
change is a handover to live.js, which Fiesta does not ship, so it is
recorded for part 2 rather than ported.

The release checks are written first. docs_test.go ports rastrillo's
cssfloor_test.go over README.md and DESIGN.md, holds the changelog's
newest release to fiesta.Version, and holds the README to the one link.
releases_test.go holds releases/<Version>/ to what build.Dist makes while
the version is untagged, every tagged release to its tag file for file
(a missing directory, a missing file, a changed file and an extra file
each fail, and the tag list must be read from this repository), and
builds the site from the releases. Six fail by name until the documents
and the release exist; the date tripwire and the tag check were seen red
in a scratch clone.
Human

Prompt

Task 15, Phase A: the rastrillo sync check first, then the README, CHANGELOG, strings.json drafts for copy review, and the Step 1 tests against stubs, each seen failing by name.

From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.

Summary

js/select.js takes Rastrillo's placement fix (eb983e8d to 05478795): a list's room is cut only by the ancestors that can clip it, by CSS's rules for containing blocks, and data-rst-select-ceiling marks a sticky header as not room. check/select_browser_test.go gains the ceiling leg of TestSelectPlacement and TestSelectRoomIsCutOnlyByWhatClipsTheList, and the select.js cap moves to 57KB. docs_test.go and releases_test.go are the release checks, written first and red by name until the README, the changelog and releases/1.0.0 exist.
Agent notes
The sync check compared Rastrillo origin/main f892f8beebf7aea2811e7634e77021e2ad2b161d with the seed e14b745 over tokens.css, shell.css, the themes, the seven scripts and rastrillo.js. The CSS and rastrillo.js are unchanged. select.js's change is a bug fix and is ported here. busy.js's change (data-live-form, a handover to Rastrillo's live.js) is coupled to a script Fiesta does not ship, so it is recorded and not ported; part 2 decides. DESIGN.md states no CSS floor, so TestCSSFloorIsStatedTheSameInBothFiles stays red on that half until a sentence is approved; the README, CHANGELOG, sync line and CLI strings wait in copy-review/strings.json.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev c6360eefe6f4c46fe87d084f013b249ee5c60585 parent 764bfcc
5 files changed, +723 −12
  • check/scripts_test.go +12 −1
  • check/select_browser_test.go +243 −0
  • docs_test.go +148 −0
  • js/select.js +58 −11
  • releases_test.go +262 −0

CI failed — run details

CI log
=== gofmt ===
=== no-rastrillo ===
go test -run '^(TestFiestaNeverImportsRastrillo|TestThePublicPackagesLinkNoBrowser)$' -count=1 .
ok  	amadan.net/rastrillo/fiesta	0.094s
=== size ===
go test ./internal/build -run '^TestTheOneLinkIsUnder25KiBGzipped$' -count=1 -v
=== RUN   TestTheOneLinkIsUnder25KiBGzipped
    build_test.go:138: fiesta-plain.css: 152856 bytes, 22303 bytes gzipped (21.8 KiB of the 25.0 KiB limit)
    build_test.go:138: fiesta-signal.css: 152951 bytes, 22414 bytes gzipped (21.9 KiB of the 25.0 KiB limit)
    build_test.go:138: fiesta.css: 155770 bytes, 23018 bytes gzipped (22.5 KiB of the 25.0 KiB limit)
--- PASS: TestTheOneLinkIsUnder25KiBGzipped (0.02s)
PASS
ok  	amadan.net/rastrillo/fiesta/internal/build	0.022s
=== suite ===
--- shard 0 of 10 ---
==> shard 0 of 10: make root
go build ./...
go vet ./...
FIESTA_TEST_REQUIRE_NODE=1 FIESTA_TEST_REQUIRE_GIT=1 go test -count=1 ./...
--- FAIL: TestCSSFloorIsStatedTheSameInBothFiles (0.01s)
    docs_test.go:70: README.md must exist and state the floor "Chrome 123, Safari 17.5, Firefox 121": open README.md: no such file or directory
    docs_test.go:74: DESIGN.md does not state the floor "Chrome 123, Safari 17.5, Firefox 121".
        Either it drifted, or the floor moved and this file was not brought along: cssFloor, README.md and DESIGN.md change together or not at all.
--- FAIL: TestVersionIsTheChangelogsNewestRelease (0.00s)
    docs_test.go:115: CHANGELOG.md must exist, with ## Unreleased and then ## 1.0.0: open CHANGELOG.md: no such file or directory
--- FAIL: TestTheReadmeShowsTheOneLink (0.00s)
    docs_test.go:143: README.md must exist and show the one link: open README.md: no such file or directory
--- FAIL: TestTheCurrentReleaseExists (0.00s)
    releases_test.go:126: releases/1.0.0 is not there: stat releases/1.0.0: no such file or directory. Cut it with go run ./cmd/fiesta release -version 1.0.0
--- FAIL: TestAnUntaggedReleaseIsAFreshDist (0.03s)
    releases_test.go:158: releases/1.0.0 is not there. Cut it with go run ./cmd/fiesta release -version 1.0.0
--- FAIL: TestTheSiteBuildsFromTheReleases (0.00s)
    releases_test.go:253: the site does not build from releases/ and site/: reading releases: open .: no such file or directory
FAIL
FAIL	amadan.net/rastrillo/fiesta	0.628s
ok  	amadan.net/rastrillo/fiesta/check	4.499s
ok  	amadan.net/rastrillo/fiesta/cmd/fiesta	4.703s
ok  	amadan.net/rastrillo/fiesta/example	0.231s
ok  	amadan.net/rastrillo/fiesta/internal/axe	0.270s
ok  	amadan.net/rastrillo/fiesta/internal/build	1.516s
ok  	amadan.net/rastrillo/fiesta/internal/codeview	0.023s
ok  	amadan.net/rastrillo/fiesta/internal/gallery	2.609s
?   	amadan.net/rastrillo/fiesta/internal/harness	[no test files]
ok  	amadan.net/rastrillo/fiesta/internal/markup	0.011s
ok  	amadan.net/rastrillo/fiesta/internal/nodetest	0.554s
ok  	amadan.net/rastrillo/fiesta/internal/site	0.144s
ok  	amadan.net/rastrillo/fiesta/internal/tokens	0.017s
FAIL
make[1]: *** [Makefile:113: root] Error 1
make: *** [Makefile:64: suite] Error 2

amadan: step suite shard 0 of 10 failed: exit status 2
--- shard 1 of 10 ---
==> shard 1 of 10: make staticcheck
go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 -tags browser ./...
--- shard 2 of 10 ---
==> shard 2 of 10: make govulncheck
go run golang.org/x/vuln/cmd/govulncheck@v1.8.0 ./...
=== Symbol Results ===

No vulnerabilities found.

Your code is affected by 0 vulnerabilities.
This scan also found 2 vulnerabilities in packages you import and 5
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.
Use '-show verbose' for more details.
--- shard 3 of 10 ---
==> shard 3 of 10: make gitleaks
go run github.com/zricethezav/gitleaks/v8@v8.30.1 git --no-banner --redact --log-opts='--full-history --diff-filter=tuxdb HEAD' .
10:48PM INF Unknown SCM platform. Use --platform to include links in findings. host=amadan.net
10:49PM INF 1 commits scanned.
10:49PM INF scanned ~4589783 bytes (4.59 MB) in 3.56s
10:49PM INF no leaks found
--- shard 4 of 10 ---
==> shard 4 of 10: make browser.internal.harness
./hack/gotest.sh browser 'internal.harness'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
ok  	amadan.net/rastrillo/fiesta/internal/harness	10.218s
--- shard 5 of 10 ---
==> shard 5 of 10: make browser.check~1of3
./hack/gotest.sh browser 'check~1of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 3 of ./check/ runs 92 of 277 tests
ok  	amadan.net/rastrillo/fiesta/check	190.989s
--- shard 6 of 10 ---
==> shard 6 of 10: make browser.check~2of3
./hack/gotest.sh browser 'check~2of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 3 of ./check/ runs 92 of 277 tests
ok  	amadan.net/rastrillo/fiesta/check	195.589s
--- shard 7 of 10 ---
==> shard 7 of 10: make browser.check~3of3
./hack/gotest.sh browser 'check~3of3'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 3 of 3 of ./check/ runs 93 of 277 tests
ok  	amadan.net/rastrillo/fiesta/check	195.805s
--- shard 8 of 10 ---
==> shard 8 of 10: make browser.internal.gallery~1of2
./hack/gotest.sh browser 'internal.gallery~1of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 1 of 2 of ./internal/gallery/ runs 1 of 13 tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	69.000s
--- shard 9 of 10 ---
==> shard 9 of 10: make browser.internal.gallery~2of2
./hack/gotest.sh browser 'internal.gallery~2of2'
gotest: on DOCS-T1, driving the harness's own choice (/usr/bin/chromium)
gotest: slice 2 of 2 of ./internal/gallery/ runs 12 of 13 tests
ok  	amadan.net/rastrillo/fiesta/internal/gallery	21.270s

amadan: job failed at step suite shard 0 of 10 — its output is above, not at the tail of this log
diff --git a/check/scripts_test.go b/check/scripts_test.go
index 28649d2..173d9e3 100644
--- a/check/scripts_test.go
+++ b/check/scripts_test.go
@@ -545,7 +545,18 @@ func TestSelectContract(t *testing.T) {
// bytes went into datetime.js, whose cap moved with them; see
// TestTheTwoLookupsAreTheSame. 53KB does not hold it; 54KB does,
// with 695 to spare.
- if n := len(js); n > 54*1024 {
+ //
+ // Then to 57KB (measured 57,787 bytes) by the placement fix synced
+ // from rastrillo: the room is cut only by the ancestors that can
+ // clip the list, its chain of containing blocks, held to CSS's own
+ // rules (paint containment and content-visibility, will-change's
+ // triggers by each property's rule, the top layer, slots, table
+ // rows and ruby skipped, aliases only where the engine has them),
+ // and data-rst-select-ceiling, floor's twin for a sticky header. A
+ // list fixed or anchored out of a bar that scrolls sideways had
+ // opened 0px tall. Rastrillo holds the same code at 55KB; the
+ // string tables above are the 1,473 bytes between the two caps.
+ if n := len(js); n > 57*1024 {
t.Fatalf("select.js is %d bytes; it is split out of the shim precisely to stay readable — trim it", n)
}
if strings.Contains(js, "\t") {
diff --git a/check/select_browser_test.go b/check/select_browser_test.go
index 4c6ead4..8a5124b 100644
--- a/check/select_browser_test.go
+++ b/check/select_browser_test.go
@@ -1523,6 +1523,11 @@ func TestSelectPlacement(t *testing.T) {
ClampTop float64 `json:"clampTop"`
BandTop float64 `json:"bandTop"`
TieUp bool `json:"tieUp"`
+ CeilingUp bool `json:"ceilingUp"`
+ CeilingTop float64 `json:"ceilingTop"`
+ CeilingEnd float64 `json:"ceilingEnd"`
+ NoCeilingUp bool `json:"noCeilingUp"`
+ BelowCeilUp bool `json:"belowCeilUp"`
}
drive(t, `
const frame = () => `+afterFrame+`;
@@ -1612,6 +1617,31 @@ func TestSelectPlacement(t *testing.T) {
await open();
out.tieUp = wrap.hasAttribute('rst-combo-up');
close();
+
+ // A header pinned above the box (data-rst-select-ceiling) is not
+ // room either: with 70px below and the header over all but 6px
+ // above, the list opens down. Without the header it opens up, and
+ // one marked below the box is ignored.
+ at(150);
+ document.dispatchEvent(new CustomEvent('rst:select-viewport', { detail: { top: 0, bottom: 150 + h + 70 } }));
+ await open();
+ out.noCeilingUp = wrap.hasAttribute('rst-combo-up');
+ close();
+ const header = document.createElement('div');
+ header.setAttribute('data-rst-select-ceiling', '');
+ header.style.cssText = 'position:fixed;left:0;right:0;top:0;height:140px';
+ document.body.append(header);
+ await open();
+ out.ceilingUp = wrap.hasAttribute('rst-combo-up');
+ out.ceilingTop = list.getBoundingClientRect().top;
+ out.ceilingEnd = header.getBoundingClientRect().bottom;
+ close();
+ header.style.top = (150 + h + 10) + 'px';
+ header.style.height = '5px';
+ await open();
+ out.belowCeilUp = wrap.hasAttribute('rst-combo-up');
+ close();
+ header.remove();
document.dispatchEvent(new CustomEvent('rst:select-viewport', { detail: null }));
wrap.removeAttribute('style');
return out;
@@ -1634,6 +1664,219 @@ func TestSelectPlacement(t *testing.T) {
if got.TieUp {
t.Error("equal room above and below opened upward: a tie goes down")
}
+ if !got.NoCeilingUp || got.CeilingUp || got.CeilingTop < got.CeilingEnd || !got.BelowCeilUp {
+ t.Errorf("70px below the box and 146px above it: up %v with no header; under a header ending at %.0f, up %v starting at %.0f; with the header marked below the box, up %v. Want up, then down below the header, then up: a ceiling is not room, and one below the box is ignored", got.NoCeilingUp, got.CeilingEnd, got.CeilingUp, got.CeilingTop, got.BelowCeilUp)
+ }
+}
+
+// The room is cut only by the ancestors that really clip the list, by
+// CSS's rules, row by row. A bar that scrolls sideways (overflow-x: auto,
+// so its overflow-y computes to auto too) clips a list it is the
+// containing block of, and not one fixed or anchored out of it: Docs'
+// phone toolbar is such a bar, and its lists opened 0px tall. "clip" rows
+// are a box that holds the list, so the list must end inside it; "escape"
+// rows are one that does not, so the list keeps its own height. Either
+// way the list is scrolled to its end and its last row must be the thing
+// at its centre: a list taller than what really shows it fails there,
+// which is how rastrillo's review of the fix found content-visibility
+// and the will-change triggers missed, then containment on a box that
+// is not there (display: contents, a plain inline), preserve-3d, and
+// will-change written in capitals or with -webkit-, and then
+// containment on table rows and ruby, a boxless ancestor's position,
+// will-change: filter on an inline, motion paths, and an alias the
+// engine lacks.
+func TestSelectRoomIsCutOnlyByWhatClipsTheList(t *testing.T) {
+ t.Parallel()
+ type row struct {
+ Name string `json:"name"`
+ Want string `json:"want"`
+ Height float64 `json:"height"`
+ Bottom float64 `json:"bottom"`
+ BarBottom float64 `json:"barBottom"`
+ Last bool `json:"last"`
+ Err string `json:"err"`
+ }
+ var got struct {
+ Preferred float64 `json:"preferred"`
+ Rows []row `json:"rows"`
+ }
+ drive(t, `
+ const frame = () => `+afterFrame+`;
+ const input = document.querySelector('#tz-combo');
+ const wrap = input.closest('[rst-combo]');
+ const list = document.querySelector('#tz-listbox');
+ const native = document.querySelector('#tz');
+ const home = wrap.parentNode, next = native.nextSibling;
+ const open = async () => { input.blur(); input.focus(); input.click(); await frame(); await frame(); };
+ const close = () => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
+ await open();
+ const preferred = list.getBoundingClientRect().height;
+ close();
+
+ // Each row: the bar's own style, the list's position, and how the
+ // bar is built. The list is anchored under its box, as Docs anchors
+ // it, so it sits there whichever box holds it.
+ const bar = 'width:300px;height:200px;overflow-x:auto;';
+ const rows = [
+ ['fixed, plain bar', 'escape', 'fixed', bar],
+ ['fixed, transform', 'clip', 'fixed', bar + 'transform:translateX(0)'],
+ ['fixed, translate', 'clip', 'fixed', bar + 'translate:0px'],
+ ['fixed, rotate', 'clip', 'fixed', bar + 'rotate:0deg'],
+ ['fixed, scale', 'clip', 'fixed', bar + 'scale:1'],
+ ['fixed, perspective', 'clip', 'fixed', bar + 'perspective:100px'],
+ ['fixed, filter', 'clip', 'fixed', bar + 'filter:blur(0)'],
+ ['fixed, backdrop-filter', 'clip', 'fixed', bar + 'backdrop-filter:blur(0)'],
+ ['fixed, contain: layout', 'clip', 'fixed', bar + 'contain:layout'],
+ ['fixed, contain: paint', 'clip', 'fixed', bar + 'contain:paint'],
+ ['fixed, contain: strict', 'clip', 'fixed', bar + 'contain:strict'],
+ ['fixed, contain: content', 'clip', 'fixed', bar + 'contain:content'],
+ ['fixed, content-visibility: auto', 'clip', 'fixed', bar + 'content-visibility:auto'],
+ ['fixed, will-change: transform', 'clip', 'fixed', bar + 'will-change:transform'],
+ ['fixed, will-change: translate', 'clip', 'fixed', bar + 'will-change:translate'],
+ ['fixed, will-change: rotate', 'clip', 'fixed', bar + 'will-change:rotate'],
+ ['fixed, will-change: scale', 'clip', 'fixed', bar + 'will-change:scale'],
+ ['fixed, will-change: perspective', 'clip', 'fixed', bar + 'will-change:perspective'],
+ ['fixed, will-change: filter', 'clip', 'fixed', bar + 'will-change:filter'],
+ ['fixed, will-change: backdrop-filter', 'clip', 'fixed', bar + 'will-change:backdrop-filter'],
+ ['fixed, will-change: contain', 'clip', 'fixed', bar + 'will-change:contain'],
+ ['fixed, will-change: position', 'escape', 'fixed', bar + 'will-change:position'],
+ ['fixed, will-change: scroll-position', 'escape', 'fixed', bar + 'will-change:scroll-position'],
+ ['fixed, container-type: inline-size', 'escape', 'fixed', bar + 'container-type:inline-size'],
+ ['fixed, container-type: size', 'escape', 'fixed', bar + 'container-type:size'],
+ ['fixed, contain: paint, overflow visible', 'clip', 'fixed', 'width:300px;height:200px;contain:paint'],
+ ['fixed, content-visibility: auto, overflow visible', 'clip', 'fixed', 'width:300px;height:200px;content-visibility:auto'],
+ ['absolute, held by its own box', 'clip', 'absolute', bar],
+ ['absolute, held outside the bar', 'escape', 'absolute static', bar],
+ ['absolute, bar positioned', 'clip', 'absolute static', bar + 'position:relative'],
+ ['absolute, will-change: position', 'clip', 'absolute static', bar + 'will-change:position'],
+ ['fixed, display: contents with contain: paint', 'escape', 'fixed', 'display:contents;contain:paint'],
+ ['fixed, display: contents with content-visibility: auto', 'escape', 'fixed', 'display:contents;content-visibility:auto'],
+ ['absolute, held by its own box, in display: contents with contain: paint', 'escape', 'absolute', 'display:contents;contain:paint'],
+ ['fixed, display: inline with contain: paint', 'escape', 'fixed', 'display:inline;contain:paint'],
+ // inline flow-root list-item is an inline-block with a marker:
+ // atomic, so all of it applies; inline list-item is not, which
+ // rastrillo's review of the fix found last. WebKit has neither
+ // and draws a block.
+ ['fixed, display: inline list-item with contain: paint', 'escape', 'fixed', 'display:inline list-item;contain:paint'],
+ ['fixed, display: inline flow-root list-item with contain: paint', 'clip', 'fixed', 'width:300px;height:200px;display:inline flow-root list-item;contain:paint'],
+ ['fixed, display: inline flow-root list-item with transform', 'clip', 'fixed', bar + 'display:inline flow-root list-item;transform:translateX(0)'],
+ ['absolute, held by its own box, in display: inline flow-root list-item', 'clip', 'absolute', bar + 'display:inline flow-root list-item'],
+ ['fixed, transform-style: preserve-3d', 'clip', 'fixed', bar + 'transform-style:preserve-3d'],
+ ['fixed, transform-style: preserve-3d, overflow: clip', 'clip', 'fixed', 'width:300px;height:200px;overflow:clip;transform-style:preserve-3d'],
+ ['fixed, will-change: transform-style', 'clip', 'fixed', bar + 'will-change:transform-style'],
+ ['fixed, will-change: TrAnSfOrM', 'clip', 'fixed', bar + 'will-change:TrAnSfOrM'],
+ ['fixed, will-change: -webkit-transform', 'clip', 'fixed', bar + 'will-change:-webkit-transform'],
+ ['fixed, will-change: -webkit-perspective', 'clip', 'fixed', bar + 'will-change:-webkit-perspective'],
+ ['fixed, will-change: -webkit-filter', 'clip', 'fixed', bar + 'will-change:-webkit-filter'],
+ // Only an alias the engine has: Chromium has no -webkit-backdrop-filter.
+ ['fixed, will-change: -webkit-backdrop-filter', CSS.supports('-webkit-backdrop-filter', 'initial') ? 'clip' : 'escape', 'fixed', bar + 'will-change:-webkit-backdrop-filter'],
+ ['fixed, in a modal dialog in a transformed clip', 'escape', 'fixed', 'dialog'],
+ ['fixed, in a popover in a transformed clip', 'escape', 'fixed', 'popover'],
+ ['absolute, slotted into a shadow scroller', 'clip', 'absolute', 'shadow'],
+ // Markup rows: the box goes in [data-in]; [data-held], or else the
+ // first element, is what must hold a "clip" list.
+ // Containment skips table rows, row groups and non-atomic ruby;
+ // a cell keeps it.
+ ['fixed, table row with contain: paint', 'escape', 'fixed', '<table><tbody><tr style="contain:paint"><td data-in></td></tr></tbody></table>'],
+ ['absolute, held by its own box, in a table row with contain: paint', 'escape', 'absolute', '<table><tbody><tr style="contain:paint"><td data-in></td></tr></tbody></table>'],
+ ['fixed, row group with content-visibility: auto', 'escape', 'fixed', '<table><tbody style="content-visibility:auto"><tr><td data-in></td></tr></tbody></table>'],
+ ['fixed, table row with contain: layout, in a scroller', 'escape', 'fixed', '<div style="' + bar + '"><table><tbody><tr style="contain:layout"><td data-in></td></tr></tbody></table></div>'],
+ ['fixed, table cell with contain: paint', 'clip', 'fixed', '<table><tbody><tr><td data-in data-held style="contain:paint;height:200px;vertical-align:top"></td></tr></tbody></table>'],
+ ['fixed, display: ruby with contain: paint', 'escape', 'fixed', '<div style="display:ruby;contain:paint" data-in></div>'],
+ ['fixed, display: ruby-text with contain: paint', 'escape', 'fixed', '<div style="display:ruby-text;contain:paint" data-in></div>'],
+ // A boxless ancestor's position is not the chain's.
+ ['absolute, held by its own box, in display: contents with position: fixed, in a scroller', 'clip', 'absolute', '<div style="' + bar + 'overflow:hidden"><div style="display:contents;position:fixed" data-in></div></div>'],
+ ['absolute, held by its own box, in display: contents with position: absolute, in a scroller', 'clip', 'absolute', '<div style="' + bar + 'overflow:hidden"><div style="display:contents;position:absolute" data-in></div></div>'],
+ // will-change: filter applies where filter does, inlines too.
+ ['fixed, inline with will-change: filter, in a scroller', 'clip', 'fixed', '<div style="' + bar + 'overflow:hidden"><span style="will-change:filter" data-in></span></div>'],
+ ['fixed, inline with will-change: backdrop-filter, in a scroller', 'clip', 'fixed', '<div style="' + bar + 'overflow:hidden"><span style="will-change:backdrop-filter" data-in></span></div>'],
+ // A motion path transforms, and its will-change names, the
+ // offset shorthand among them, hold.
+ ['fixed, offset-path', 'clip', 'fixed', bar + 'offset-path:path("M 0 0 L 100 0");offset-position:auto;offset-anchor:0 0'],
+ ['fixed, will-change: offset-path', 'clip', 'fixed', bar + 'will-change:offset-path'],
+ ['fixed, will-change: offset', 'clip', 'fixed', bar + 'will-change:offset'],
+ ];
+ const out = [];
+ for (const [name, want, position, style] of rows) {
+ const r = { name, want };
+ try {
+ // Positioned and outside every bar: what an absolute list with a
+ // static box is held by when the bar does not hold it.
+ const outer = document.createElement('div');
+ outer.style.cssText = 'position:absolute;left:0;top:100px;width:320px;height:600px';
+ document.body.append(outer);
+ let held, closeTop = () => {};
+ if (style === 'dialog' || style === 'popover') {
+ const clipper = document.createElement('div');
+ clipper.style.cssText = 'transform:translateX(0);overflow:hidden;width:300px;height:60px';
+ held = document.createElement(style === 'dialog' ? 'dialog' : 'div');
+ held.style.cssText = 'margin:0;padding:0;border:0;inset:auto;left:0;top:100px;width:300px;height:200px;overflow:auto';
+ clipper.append(held);
+ outer.append(clipper);
+ held.append(wrap, native);
+ if (style === 'dialog') { held.showModal(); closeTop = () => held.close(); }
+ else { held.setAttribute('popover', 'manual'); held.showPopover(); closeTop = () => held.hidePopover(); }
+ } else if (style === 'shadow') {
+ const host = document.createElement('div');
+ outer.append(host);
+ host.attachShadow({ mode: 'open' }).innerHTML = '<div style="width:300px;height:200px;overflow:auto"><slot></slot></div>';
+ host.append(wrap, native);
+ held = host.shadowRoot.firstChild;
+ } else if (style[0] === '<') {
+ outer.insertAdjacentHTML('beforeend', style);
+ held = outer.querySelector('[data-held]') || outer.firstElementChild;
+ outer.querySelector('[data-in]').append(wrap, native);
+ } else {
+ held = document.createElement('div');
+ held.style.cssText = style;
+ outer.append(held);
+ held.append(wrap, native);
+ }
+ wrap.style.cssText = 'display:block;width:200px;anchor-name:--rst-room' + (position.endsWith('static') ? ';position:static' : '');
+ list.style.cssText = 'position:' + position.split(' ')[0] + ';inset:auto;position-anchor:--rst-room;top:anchor(bottom);left:anchor(left);width:200px';
+ await open();
+ const lr = list.getBoundingClientRect();
+ r.height = lr.height;
+ r.bottom = lr.bottom;
+ r.barBottom = held.getBoundingClientRect().bottom;
+ list.scrollTop = list.scrollHeight;
+ await frame();
+ const lis = [...list.querySelectorAll('[rst-combo-option]')].filter((li) => !li.hidden);
+ const last = lis[lis.length - 1].getBoundingClientRect();
+ const hit = document.elementFromPoint(last.left + last.width / 2, last.top + last.height / 2);
+ r.last = !!hit && lis[lis.length - 1].contains(hit);
+ close();
+ closeTop();
+ outer.remove();
+ } catch (e) {
+ r.err = String(e);
+ }
+ list.removeAttribute('style');
+ wrap.removeAttribute('style');
+ home.insertBefore(wrap, next);
+ home.insertBefore(native, next);
+ out.push(r);
+ }
+ return { preferred, rows: out };
+ `, &got)
+ if got.Preferred < 100 {
+ t.Fatalf("the list opened %.0fpx tall on its own, too short to tell anything apart", got.Preferred)
+ }
+ for _, r := range got.Rows {
+ switch {
+ case r.Err != "":
+ t.Errorf("%s: %s", r.Name, r.Err)
+ case r.Want == "escape" && r.Height < got.Preferred-1:
+ t.Errorf("%s: the list opened %.0fpx tall, want its own %.0fpx: nothing here clips it", r.Name, r.Height, got.Preferred)
+ case r.Want == "clip" && r.Bottom > r.BarBottom+1:
+ t.Errorf("%s: the list ends at %.0f, past the end of the box that clips it at %.0f", r.Name, r.Bottom, r.BarBottom)
+ case !r.Last:
+ t.Errorf("%s: scrolled to its end, the list's last row is not what shows at its place: the list is taller than what shows it", r.Name)
+ }
+ }
+ if len(got.Rows) != 64 {
+ t.Errorf("ran %d rows, want 64", len(got.Rows))
+ }
}
// data-rst-class: a row wears its option's classes, the closed box wears
diff --git a/docs_test.go b/docs_test.go
new file mode 100644
index 0000000..571749d
--- /dev/null
+++ b/docs_test.go
@@ -0,0 +1,148 @@
+package fiesta
+
+import (
+ "os"
+ "strings"
+ "testing"
+ "time"
+)
+
+// This file is rastrillo's cssfloor_test.go aimed at Fiesta's two
+// documents, plus the two checks that hold the README and the
+// changelog to the release they describe.
+
+// cssFloor is the engine floor the stylesheets impose on every page
+// that links them. It is derived, not chosen: take the features
+// tokens.css and the three themes use, and keep the highest engine
+// version among them. light-dark() sets it everywhere except Firefox,
+// where :has() does. Both documents state the result, because a
+// reader who cannot find the floor invents one: an agent building on
+// rastrillo once refused oklch() as "a 2023-era browser feature" and
+// reached for hex twins, which protect nobody, since an engine too old
+// for oklch() dropped the whole light-dark() palette several
+// declarations earlier.
+//
+// Two files stating one fact can drift into two facts.
+// TestCSSFloorIsStatedTheSameInBothFiles holds them equal. Both
+// hard-wrap prose, so the comparison normalises whitespace: the string
+// may break across a line, it may not disagree.
+const cssFloor = "Chrome 123, Safari 17.5, Firefox 121"
+
+// The adoption policy is a rolling one: a feature is fair game once it
+// has been in all three engines about a year. A rolling policy with no
+// clock is a lapsed one nobody has noticed yet, and these two constants
+// are the clock.
+//
+// What does not move when the policy rolls is the floor. It rises only
+// when someone writes a newer feature into the CSS, so "anything a year
+// old may be used" and "today we bottom out at Chrome 123" stay true
+// together. That is also why the review is by hand: the computable
+// half, what is now a year old, was never the hard half. The judgement
+// is which of those to want, and every yes raises the floor for every
+// page that links the CSS.
+//
+// Nine months rather than twelve, so a review that slips a release
+// still lands inside the year the policy names.
+//
+// Answering the failure means re-deriving, not bumping the date: list
+// the features css/tokens.css and css/themes/*.css use, take the
+// highest engine version among them, and if it differs from cssFloor
+// change it here, in README.md and in DESIGN.md together. Then set
+// floorReviewed to the day you did it.
+const (
+ floorReviewed = "2026-09-01"
+ floorReviewMonths = 9
+)
+
+// floorFiles are the documents that state the floor: the README, for
+// someone deciding whether to link the CSS, and DESIGN.md, for someone
+// writing against it.
+var floorFiles = []string{"README.md", "DESIGN.md"}
+
+// TestCSSFloorIsStatedTheSameInBothFiles fails when the README and
+// DESIGN.md disagree about the floor, or either leaves it out. They
+// are written for different readers, and a reader who checks one
+// against the other and finds two numbers trusts neither.
+func TestCSSFloorIsStatedTheSameInBothFiles(t *testing.T) {
+ for _, path := range floorFiles {
+ b, err := os.ReadFile(path)
+ if err != nil {
+ t.Errorf("%s must exist and state the floor %q: %v", path, cssFloor, err)
+ continue
+ }
+ if !strings.Contains(strings.Join(strings.Fields(string(b)), " "), cssFloor) {
+ t.Errorf("%s does not state the floor %q.\n"+
+ "Either it drifted, or the floor moved and this file was not brought along: "+
+ "cssFloor, README.md and DESIGN.md change together or not at all.",
+ path, cssFloor)
+ }
+ }
+}
+
+// TestCSSFloorHasBeenReviewedRecently is the tripwire. It fails on a
+// date, which is unusual and intended: the alternative is a warning,
+// and a warning on a release evening is a thing you scroll past. A
+// build that stops for ten minutes while somebody re-reads a list of
+// CSS features is cheap; a floor that quietly describes 2024 while
+// the policy promises "about a year" is the silence the policy exists
+// to end.
+func TestCSSFloorHasBeenReviewedRecently(t *testing.T) {
+ reviewed, err := time.Parse(time.DateOnly, floorReviewed)
+ if err != nil {
+ t.Fatalf("floorReviewed must be a %s date: %v", time.DateOnly, err)
+ }
+ due := reviewed.AddDate(0, floorReviewMonths, 0)
+ if time.Now().After(due) {
+ t.Fatalf("the CSS floor was last reviewed %s and was due %s.\n"+
+ "Re-derive it, the features css/tokens.css and css/themes/*.css use and the highest engine "+
+ "version among them, then update cssFloor, README.md and DESIGN.md if it moved, and set "+
+ "floorReviewed to today. Do not bump the date alone; the date is the record that somebody looked.",
+ floorReviewed, due.Format(time.DateOnly))
+ }
+}
+
+// changelogName is the file whose newest release must be Version.
+const changelogName = "CHANGELOG.md"
+
+// TestVersionIsTheChangelogsNewestRelease holds the changelog and the
+// tree to one version: the first second-level heading is Unreleased,
+// and the one after it names Version, so a release is cut only once
+// its entry is written, and the tree is never a version the changelog
+// has not heard of. TestTheVersionIsSemver pins Version itself.
+func TestVersionIsTheChangelogsNewestRelease(t *testing.T) {
+ b, err := os.ReadFile(changelogName)
+ if err != nil {
+ t.Fatalf("%s must exist, with ## Unreleased and then ## %s: %v", changelogName, Version, err)
+ }
+ var headings []string
+ for _, line := range strings.Split(string(b), "\n") {
+ if strings.HasPrefix(line, "## ") {
+ headings = append(headings, strings.TrimSpace(line))
+ }
+ }
+ if len(headings) < 2 {
+ t.Fatalf("%s has %d second-level headings %q; want ## Unreleased and then ## %s", changelogName, len(headings), headings, Version)
+ }
+ if headings[0] != "## Unreleased" {
+ t.Errorf("%s's first heading is %q, want %q", changelogName, headings[0], "## Unreleased")
+ }
+ if want := "## " + Version; headings[1] != want {
+ t.Errorf("%s's newest release is %q, but the tree is %q: the changelog entry and fiesta.Version move together", changelogName, headings[1], want)
+ }
+}
+
+// oneLink is the tag the README exists to show: a page links this and
+// has the design system. /1/ is rewritten to the newest 1.y.z release.
+const oneLink = `<link rel="stylesheet" href="https://fiesta.rastrillo.org/1/fiesta.css">`
+
+// TestTheReadmeShowsTheOneLink: the README carries the link tag
+// exactly as a page should paste it.
+func TestTheReadmeShowsTheOneLink(t *testing.T) {
+ b, err := os.ReadFile("README.md")
+ if err != nil {
+ t.Fatalf("README.md must exist and show the one link: %v", err)
+ }
+ if !strings.Contains(string(b), oneLink) {
+ t.Errorf("README.md does not show the one link, verbatim: %s", oneLink)
+ }
+}
diff --git a/js/select.js b/js/select.js
index a2564db..caf37f4 100644
--- a/js/select.js
+++ b/js/select.js
@@ -226,6 +226,54 @@
told = d && isFinite(d.top) && isFinite(d.bottom) ? { top: +d.top, bottom: +d.bottom } : null;
});
+ // What a box is to the boxes in it, by CSS: the containing block of
+ // fixed (f) and absolute (a) ones, a clip with overflow visible (p,
+ // paint containment), whether its overflow clips (o), whether it is a
+ // box (b). Each property counts only where it applies: transforms,
+ // motion paths and overflow skip non-atomic inlines, table columns and
+ // ruby's parts; containment, table rows and groups too (css-contain-2
+ // §3); filters skip none. A will-change name counts by its property's
+ // rule, without case, offset as offset-path's shorthand, a -webkit-
+ // alias only where the engine has it (Chromium has none for
+ // backdrop-filter). Chromium takes a set offset-position (a digit in
+ // it) as a motion path; WebKit does not, and gets a shorter list.
+ // Size queries no longer contain layout (css-conditional-5).
+ const holds = (cs) => {
+ const d = cs.display, b = d !== "contents", cv = cs.contentVisibility;
+ const o = b && !/^(inline|run-in)( list-item)?$|^(ruby$|table-col|ruby-)/.test(d), c = o && !/^table-(row|head|foot)/.test(d);
+ const w = (cs.willChange || "").toLowerCase().split(/,\s*/).filter((v) => CSS.supports(v, "initial")).map((v) => v.replace(/^-webkit-/, ""));
+ // Named in will-change, or (v) set to other than none.
+ const X = (s, v) => s.split(" ").some((k) => w.includes(k) || (v && !/^(none|)$/.test(cs.getPropertyValue(k))));
+ const p = c && (/paint|strict|content/.test(cs.contain) || cv === "auto" || cv === "hidden");
+ const f = p || (c && (/layout/.test(cs.contain) || X("contain content-visibility"))) ||
+ (o && (cs.transformStyle === "preserve-3d" || /\d/.test(cs.offsetPosition) || X("transform translate rotate scale perspective offset-path", 1) || X("transform-style offset offset-position"))) ||
+ (b && X("filter backdrop-filter -webkit-backdrop-filter", 1));
+ return { f, a: f || (b && (cs.position !== "static" || X("position"))), p, o, b };
+ };
+ // A box in the top layer (a modal dialog, an open popover) is out of
+ // every ancestor's reach.
+ const onTop = (a) => [":modal", ":popover-open"].some((q) => { try { return a.matches(q); } catch (e) { return false; } });
+
+ // The ancestors that clip a list: only its chain of containing blocks
+ // can, walked through slots as it is drawn. Taking every ancestor that
+ // scrolls held a list fixed or anchored out of a sideways-scrolling bar
+ // (its overflow-y computes to auto) to the 4px of bar under its box:
+ // 0px tall, in Docs' phone toolbar.
+ const clippers = (list, pos) => {
+ const out = [];
+ for (let a = list; (a = a.assignedSlot || a.parentElement || (a.parentNode && a.parentNode.host)) && a !== document.body; ) {
+ const cs = getComputedStyle(a), h = holds(cs);
+ // Fixed skips to a holder; absolute, to a holder or a positioned box.
+ // A boxless ancestor is passed over: its position is no one's.
+ if (h.b && (pos === "fixed" ? h.f : pos !== "absolute" || h.a)) {
+ if (h.p || (h.o && /auto|scroll|hidden|clip/.test(cs.overflowY))) out.push(a);
+ pos = cs.position;
+ }
+ if (onTop(a)) break;
+ }
+ return out;
+ };
+
const ACTS = new Set(["ArrowDown", "ArrowUp", "Home", "End", "Enter", "Tab"]);
const el = (tag, attr) => {
@@ -528,7 +576,7 @@
// stylesheet max-height) and there is more room above; then up, so a box
// at the foot of a sheet does not open under a phone's keyboard. Held to
// the room on its side. Room is the visual viewport, cut to every
- // ancestor that scrolls. Only placement flips, never keyboard order or
+ // ancestor that clips the list. Only placement flips, never keyboard order or
// ARIA. The same rule as Tito Go's searchselect. Worked out when the list opens and when the room
// changes — never on a keystroke: the room is the same whatever is
// typed, and a keystroke's one layout read is the highlight's.
@@ -547,6 +595,8 @@
if (wrap.hasAttribute("rst-combo-up")) wrap.removeAttribute("rst-combo-up");
list.style.maxBlockSize = "";
const cs = getComputedStyle(list);
+ // Again after a resize: a media query can make the list fixed.
+ scrollers = clippers(list, cs.position);
preferred = parseFloat(cs.maxBlockSize) || 240;
gap = parseFloat(cs.marginTop) || 0;
chrome = cs.boxSizing === "border-box" ? 0 : ["paddingTop", "paddingBottom", "borderTopWidth", "borderBottomWidth"].reduce((n, k) => n + (parseFloat(cs[k]) || 0), 0);
@@ -564,11 +614,14 @@
bottom = Math.min(bottom, r.bottom);
}
const box = wrap.getBoundingClientRect();
- // A bar pinned below the box (data-rst-select-floor) is not room;
- // the highest wins, one above the box is ignored.
+ // A bar pinned below the box (data-rst-select-floor) or above it
+ // (data-rst-select-ceiling, a sticky header) is not room; the nearest
+ // wins, one on the wrong side is ignored.
for (const f of floors) {
const r = f.getBoundingClientRect();
- if (r.height && r.top >= box.bottom - 1) bottom = Math.min(bottom, r.top);
+ if (!r.height) continue;
+ if (!f.hasAttribute("data-rst-select-ceiling")) { if (r.top >= box.bottom - 1) bottom = Math.min(bottom, r.top); }
+ else if (r.bottom <= box.top + 1) top = Math.max(top, r.bottom);
}
const below = bottom - box.bottom - gap;
const above = box.top - top - gap;
@@ -734,13 +787,7 @@
open = true;
list.hidden = false;
input.setAttribute("aria-expanded", "true");
- // The ancestors that scroll, read once a list opens: which they are
- // does not change while it is open.
- scrollers = [];
- for (let a = wrap.parentElement; a && a !== document.body; a = a.parentElement) {
- if (/auto|scroll|hidden|clip/.test(getComputedStyle(a).overflowY)) scrollers.push(a);
- }
- floors = [...document.querySelectorAll("[data-rst-select-floor]")];
+ floors = [...document.querySelectorAll("[data-rst-select-floor],[data-rst-select-ceiling]")];
preferred = 0;
replace();
window.addEventListener("resize", replace);
diff --git a/releases_test.go b/releases_test.go
new file mode 100644
index 0000000..557f12a
--- /dev/null
+++ b/releases_test.go
@@ -0,0 +1,262 @@
+package fiesta
+
+import (
+ "bytes"
+ "io/fs"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "testing"
+
+ "amadan.net/rastrillo/fiesta/internal/build"
+ "amadan.net/rastrillo/fiesta/internal/site"
+)
+
+// releases/ holds every release Fiesta has cut, one X.Y.Z/ directory
+// each, written by fiesta release and committed. The site serves them
+// as they are. These tests hold the directory to what it claims to
+// be: the current version is there; while it is untagged it is what
+// the tree builds today; once a version is tagged its directory is
+// the tag's, file for file; and the site builds from all of them.
+
+// releasesDir is the committed release directory, relative to the
+// module root, where this package's tests run.
+const releasesDir = "releases"
+
+// gitOut runs git in the module root and returns its output, failing
+// the test if git cannot answer. A check that read nothing would pass
+// on nothing, so git failing is a failure, never a skip: ci_test.go
+// already needs git here.
+func gitOut(t *testing.T, args ...string) []byte {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ var stderr strings.Builder
+ cmd.Stderr = &stderr
+ out, err := cmd.Output()
+ if err != nil {
+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, stderr.String())
+ }
+ return out
+}
+
+// requireThisRepository checks that the git the tests ask is this
+// checkout's: the module root is the working tree's top, so a tag list
+// is a list of Fiesta's tags, not of whatever repository holds a
+// scratch copy.
+func requireThisRepository(t *testing.T) {
+ t.Helper()
+ top := strings.TrimSpace(string(gitOut(t, "rev-parse", "--show-toplevel")))
+ cwd, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if top, err = filepath.EvalSymlinks(top); err != nil {
+ t.Fatal(err)
+ }
+ if cwd, err = filepath.EvalSymlinks(cwd); err != nil {
+ t.Fatal(err)
+ }
+ if top != cwd {
+ t.Fatalf("the tests run in %s but git's working tree is %s; the tag list below would be another repository's", cwd, top)
+ }
+}
+
+// filesUnder reads every regular file under dir, keyed by its
+// slash-separated path relative to dir. A missing dir is reported as
+// such; anything that is not a regular file fails, since fiesta
+// release writes nothing else.
+func filesUnder(t *testing.T, dir string) (map[string][]byte, bool) {
+ t.Helper()
+ if _, err := os.Stat(dir); err != nil {
+ if os.IsNotExist(err) {
+ return nil, false
+ }
+ t.Fatal(err)
+ }
+ files := map[string][]byte{}
+ err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+ rel, err := filepath.Rel(dir, p)
+ if err != nil {
+ return err
+ }
+ if !d.Type().IsRegular() {
+ t.Errorf("%s is not a regular file, and a release holds nothing else", p)
+ return nil
+ }
+ b, err := os.ReadFile(p)
+ if err != nil {
+ return err
+ }
+ files[filepath.ToSlash(rel)] = b
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return files, true
+}
+
+// sortedNames is a map's keys in order, so a failure lists files the
+// same way every run.
+func sortedNames[V any](m map[string]V) []string {
+ names := make([]string, 0, len(m))
+ for name := range m {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ return names
+}
+
+// TestTheCurrentReleaseExists: releases/<Version>/ is there. A tree
+// at a version with no release directory is a tree whose release was
+// never cut, and the site would serve nothing under it.
+func TestTheCurrentReleaseExists(t *testing.T) {
+ dir := filepath.Join(releasesDir, Version)
+ fi, err := os.Stat(dir)
+ if err != nil {
+ t.Fatalf("%s is not there: %v. Cut it with go run ./cmd/fiesta release -version %s", dir, err, Version)
+ }
+ if !fi.IsDir() {
+ t.Fatalf("%s is not a directory", dir)
+ }
+}
+
+// isTagged reports whether v<version> is a tag of this repository.
+func isTagged(t *testing.T, version string) bool {
+ t.Helper()
+ return strings.TrimSpace(string(gitOut(t, "tag", "-l", "v"+version))) != ""
+}
+
+// TestAnUntaggedReleaseIsAFreshDist: until v<Version> is tagged, the
+// release directory is exactly what build.Dist makes from the tree
+// today, byte for byte, with nothing missing and nothing extra. The
+// one file not in the dist is fiesta release's own stamp at the top,
+// which site.Release skips and never publishes. Once the tag exists,
+// TestPublishedReleasesMatchTheirTags holds the directory to the tag
+// instead, and a later change to the tree waits for the next version.
+func TestAnUntaggedReleaseIsAFreshDist(t *testing.T) {
+ requireThisRepository(t)
+ if isTagged(t, Version) {
+ t.Skipf("v%s is tagged, so releases/%s is held to its tag by TestPublishedReleasesMatchTheirTags; a change to the tree is the next version's", Version, Version)
+ }
+ want, err := build.Dist(FS(), Version)
+ if err != nil {
+ t.Fatal(err)
+ }
+ dir := filepath.Join(releasesDir, Version)
+ got, ok := filesUnder(t, dir)
+ if !ok {
+ t.Fatalf("%s is not there. Cut it with go run ./cmd/fiesta release -version %s", dir, Version)
+ }
+ delete(got, site.StampName)
+ for _, name := range sortedNames(want) {
+ b, ok := got[name]
+ switch {
+ case !ok:
+ t.Errorf("%s/%s is missing from the committed release", dir, name)
+ case !bytes.Equal(b, want[name]):
+ t.Errorf("%s/%s differs from what the tree builds now; rebuild the candidate with go run ./cmd/fiesta release -version %s -candidate", dir, name, Version)
+ }
+ }
+ for _, name := range sortedNames(got) {
+ if _, ok := want[name]; !ok {
+ t.Errorf("%s/%s is not a file the tree builds", dir, name)
+ }
+ }
+}
+
+// releaseTag is a release tag's name: vX.Y.Z, nothing else. A v* tag
+// of any other shape is a mistake this repository has no use for.
+var releaseTag = regexp.MustCompile(`^v([0-9]+\.[0-9]+\.[0-9]+)$`)
+
+// TestPublishedReleasesMatchTheirTags: for every tag vX.Y.Z, the
+// current version included once it is tagged, releases/X.Y.Z/ exists
+// in the working tree and holds exactly the files the tag holds under
+// that path, byte for byte, the stamp included. A missing directory,
+// a missing file, a changed file and an extra file each fail: a
+// published release never changes, and this is the check that says so
+// on every push after the tag.
+//
+// With no tag yet there is nothing to compare, and the test is honest
+// about that rather than vacuous: git must answer the tag list from
+// this repository, or the test fails.
+func TestPublishedReleasesMatchTheirTags(t *testing.T) {
+ requireThisRepository(t)
+ out := gitOut(t, "tag", "-l", "v*")
+ var tags []string
+ for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
+ if line != "" {
+ tags = append(tags, line)
+ }
+ }
+ t.Logf("git tag -l 'v*' answered %d tags", len(tags))
+ for _, tag := range tags {
+ m := releaseTag.FindStringSubmatch(tag)
+ if m == nil {
+ t.Errorf("tag %s is not vX.Y.Z; a release tag is nothing else", tag)
+ continue
+ }
+ version := m[1]
+ prefix := releasesDir + "/" + version + "/"
+ listing := gitOut(t, "ls-tree", "-r", "--name-only", tag, "--", prefix)
+ tagged := map[string]bool{}
+ for _, line := range strings.Split(strings.TrimSpace(string(listing)), "\n") {
+ if line != "" {
+ tagged[strings.TrimPrefix(line, prefix)] = true
+ }
+ }
+ if len(tagged) == 0 {
+ t.Errorf("tag %s holds no %s, so it is not a release tag", tag, prefix)
+ continue
+ }
+ dir := filepath.Join(releasesDir, version)
+ got, ok := filesUnder(t, dir)
+ if !ok {
+ t.Errorf("%s is tagged but %s is missing from the tree: a published release is never removed", tag, dir)
+ continue
+ }
+ for _, name := range sortedNames(tagged) {
+ b, ok := got[name]
+ if !ok {
+ t.Errorf("%s/%s is in tag %s and missing from the tree", dir, name, tag)
+ continue
+ }
+ want := gitOut(t, "show", tag+":"+prefix+name)
+ if !bytes.Equal(b, want) {
+ t.Errorf("%s/%s differs from tag %s: a published release never changes; a fix is the next version", dir, name, tag)
+ }
+ }
+ for _, name := range sortedNames(got) {
+ if !tagged[name] {
+ t.Errorf("%s/%s is not in tag %s: a published release gains nothing after the tag", dir, name, tag)
+ }
+ }
+ }
+}
+
+// TestTheSiteBuildsFromTheReleases: the committed releases and the
+// pages assemble into the site, so what make site ships is what this
+// commit holds, and the current release is served under its exact
+// version with a /1/ rewrite onto it.
+func TestTheSiteBuildsFromTheReleases(t *testing.T) {
+ files, err := site.Build(os.DirFS(releasesDir), os.DirFS("site"))
+ if err != nil {
+ t.Fatalf("the site does not build from %s/ and site/: %v", releasesDir, err)
+ }
+ if _, ok := files[Version+"/fiesta.css"]; !ok {
+ t.Errorf("the site has no %s/fiesta.css", Version)
+ }
+ major, _, _ := strings.Cut(Version, ".")
+ if rules := string(files["_redirects"]); !strings.Contains(rules, "/"+major+"/* /") {
+ t.Errorf("_redirects has no rule for /%s/*:\n%s", major, rules)
+ }
+}