Two changes to what the live site sends. Analytics: Fathom's tag goes in every template's <head>, deferred. It is cookieless and collects nothing about a person, so no consent banner — but it is a third party, so the CSP names cdn.usefathom.com for script and connect rather than opening the door generally, and the tests that said 'no script at all' now say 'no script this site depends on, and no origin nobody chose'. Caching: the host concatenates a header two matching rules both set rather than overriding, so /* and /static/* each setting Cache-Control sent 'max-age=0, must-revalidate, max-age=31536000, immutable' on the stylesheet — first max-age wins, and the year-long cache never happened. Measured on the live site, which is where the from-memory assumption in task 32 said to check it. Now /static/* is the only rule that sets it and HTML takes the host's revalidating default; the post-deploy checks in site/README.md verify both ends.
397 lines
17 KiB
Python
397 lines
17 KiB
Python
"""The docs design is drawn at a fixed 1180px, and the site's first
|
|
traffic is a link pasted into a chat and opened on a phone. This file is
|
|
about what happens below the design's width — and, just as much, about
|
|
what does not happen at it.
|
|
|
|
Four promises, each mechanised below:
|
|
|
|
- **Desktop is the design.** Every media query in the stylesheet is a
|
|
max-width at or below 1080px, and the two folded strips are
|
|
display:none in the base sheet. Nothing here can change the 1180px
|
|
rendering the design defines.
|
|
- **A column that goes away hands back a way to reach it.** The section
|
|
nav and the "on this page" list are written twice — the column and a
|
|
<details> strip carrying the same links — and the step that hides each
|
|
column is the step that shows its strip.
|
|
- **Only a code block or a table scrolls sideways.** A table becomes its
|
|
own scroller with an edge that says there is more; a long unbroken
|
|
token breaks or scrolls inside `pre` rather than widening the page.
|
|
- **No script.** One menu that opens and closes is a <details>; the site
|
|
ships no JavaScript at all, and this is where that stays true.
|
|
|
|
python3 -m unittest discover -s tests
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import shutil
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from tests.test_site_build import (BUILDER, REPO, SITE, ScratchCase,
|
|
needs_renderer, run_build)
|
|
|
|
CSS = SITE / "static" / "site.css"
|
|
TEMPLATES = SITE / "templates"
|
|
|
|
# The design's own frame. Nothing in the stylesheet may take effect at or
|
|
# above it, which is what "the desktop rendering is unchanged" means in a
|
|
# form a test can check.
|
|
DESIGN_WIDTH = 1180
|
|
|
|
# The step at which each column folds into its strip, and the strip it
|
|
# hands over to. Read as: at this width the column is gone and the
|
|
# <details> is there instead.
|
|
FOLDS = [
|
|
("1080px", ".gutter", ".menu-contents"), # "on this page"
|
|
("760px", ".side", ".menu"), # the section nav
|
|
]
|
|
|
|
# Everything a finger has to hit, once the layout is a phone's. Links
|
|
# inside running prose are deliberately not here: a line of text cannot
|
|
# be 44px tall and still be a line of text.
|
|
TAPPABLE = [".wordmark", ".nav-link", ".button", ".side-link", ".toc-link",
|
|
".strip-link", ".footer-repo"]
|
|
|
|
|
|
def stylesheet() -> str:
|
|
"""site.css with its comments removed — every test here reads
|
|
declarations, and a comment is prose about them."""
|
|
return re.sub(r"/\*.*?\*/", "", CSS.read_text("utf-8"), flags=re.S)
|
|
|
|
|
|
def media_blocks(css: str) -> list:
|
|
"""[(condition, body, (start, end))] for every @media, matched on
|
|
braces rather than a regex, because the body of one is full of
|
|
them. The span is what lets base_sheet cut the block back out."""
|
|
out = []
|
|
for opener in re.finditer(r"@media([^{]+)\{", css):
|
|
depth, index = 1, opener.end()
|
|
while depth and index < len(css):
|
|
depth += {"{": 1, "}": -1}.get(css[index], 0)
|
|
index += 1
|
|
out.append((opener.group(1).strip(), css[opener.end():index - 1],
|
|
(opener.start(), index)))
|
|
return out
|
|
|
|
|
|
def base_sheet(css: str) -> str:
|
|
"""The stylesheet with every @media block cut out: what a browser at
|
|
the design's width is left with."""
|
|
kept, cursor = [], 0
|
|
for _, _, (start, end) in media_blocks(css):
|
|
kept.append(css[cursor:start])
|
|
cursor = end
|
|
kept.append(css[cursor:])
|
|
return "".join(kept)
|
|
|
|
|
|
def rules(body: str) -> dict:
|
|
"""{selector: its declarations} for one flat block of CSS. A grouped
|
|
selector is recorded under each of its parts, and a selector written
|
|
twice accumulates — which is how the cascade reads it."""
|
|
found = {}
|
|
for selectors, declarations in re.findall(r"([^{}]+)\{([^{}]*)\}", body):
|
|
for one in selectors.split(","):
|
|
key = " ".join(one.split())
|
|
if key.startswith("@") or not key:
|
|
continue
|
|
found[key] = found.get(key, "") + declarations
|
|
return found
|
|
|
|
|
|
def widths(condition: str) -> list:
|
|
"""The px widths a media condition names."""
|
|
return [float(value) for value in
|
|
re.findall(r"max-width\s*:\s*([\d.]+)px", condition)]
|
|
|
|
|
|
def block_at(css: str, width: str) -> dict:
|
|
"""The rules of the @media block for `max-width:<width>`."""
|
|
for condition, body, _ in media_blocks(css):
|
|
if f"max-width:{width}" in condition.replace(" ", ""):
|
|
return rules(body)
|
|
raise AssertionError(f"the stylesheet has no max-width:{width} block")
|
|
|
|
|
|
class TheDesignsWidthIsUntouched(unittest.TestCase):
|
|
"""Desktop is the design; this is what happens below it. Both halves
|
|
of that sentence are checkable, and the second is the dangerous one:
|
|
a rule that leaks upwards redraws a layout nobody asked to redraw."""
|
|
|
|
def setUp(self):
|
|
self.css = stylesheet()
|
|
|
|
def test_every_query_is_a_max_width_below_the_design(self):
|
|
for condition, _, _ in media_blocks(self.css):
|
|
if "width" not in condition:
|
|
continue # prefers-reduced-motion, which is not a size
|
|
self.assertNotIn("min-width", condition,
|
|
f"@media{condition} turns something on as the "
|
|
f"screen gets wider")
|
|
found = widths(condition)
|
|
self.assertTrue(found, f"@media{condition} names no max-width")
|
|
for value in found:
|
|
self.assertLess(
|
|
value, DESIGN_WIDTH,
|
|
f"@media{condition} takes effect at the design's width")
|
|
|
|
def test_the_folded_strips_do_not_render_at_the_design_width(self):
|
|
"""`.menu` covers both — the contents strip carries both classes
|
|
— so the base sheet hides the pair in one declaration."""
|
|
base = rules(base_sheet(self.css))
|
|
self.assertIn("display:none", base.get(".menu", "").replace(" ", ""),
|
|
"the folded menus are not hidden by default")
|
|
|
|
def test_the_lede_survives_the_strip_between_it_and_the_title(self):
|
|
"""The contents strip is a sibling between the h1 and the body's
|
|
first paragraph even when it is display:none, so `h1 + p` alone
|
|
would silently demote the lede on every article page."""
|
|
base = rules(base_sheet(self.css))
|
|
self.assertIn(".prose .menu-contents + p", base)
|
|
self.assertIn(".prose h1 + p", base)
|
|
|
|
|
|
class EachColumnHandsBackAStrip(unittest.TestCase):
|
|
""""Collapsed is fine, absent is not" — so the step that hides a
|
|
column is the step that shows the <details> replacing it."""
|
|
|
|
def setUp(self):
|
|
self.css = stylesheet()
|
|
|
|
def test_the_step_that_hides_a_column_shows_its_menu(self):
|
|
for width, column, strip in FOLDS:
|
|
block = block_at(self.css, width)
|
|
self.assertIn("display:none", block.get(column, "").replace(" ", ""),
|
|
f"{column} is not hidden at {width}")
|
|
self.assertIn("display:block", block.get(strip, "").replace(" ", ""),
|
|
f"{strip} does not appear where {column} goes")
|
|
|
|
def test_a_menu_row_is_a_tap_target_at_every_width_it_shows(self):
|
|
"""The contents strip appears at 1080px, above the step where the
|
|
tap-target rules live, so the panel sizes its own rows."""
|
|
base = rules(base_sheet(self.css))
|
|
for selector in (".menu-panel .side-link", ".menu-panel .toc-link"):
|
|
self.assertIn("min-height:var(--tap)",
|
|
base.get(selector, "").replace(" ", ""),
|
|
f"{selector} is not a tap target")
|
|
|
|
|
|
class TapTargetsAreFingerSized(unittest.TestCase):
|
|
def setUp(self):
|
|
self.css = stylesheet()
|
|
|
|
def test_the_size_is_a_token_and_it_is_44px(self):
|
|
base = rules(base_sheet(self.css))
|
|
self.assertIn("--tap:44px", base.get(":root", "").replace(" ", ""))
|
|
|
|
def test_everything_a_finger_hits_is_at_least_that_tall(self):
|
|
block = block_at(self.css, "760px")
|
|
for selector in TAPPABLE:
|
|
self.assertIn("min-height:var(--tap)",
|
|
block.get(selector, "").replace(" ", ""),
|
|
f"{selector} is smaller than a fingertip on a phone")
|
|
|
|
|
|
class TheTypeScaleSteps(unittest.TestCase):
|
|
""""A smaller step on narrow screens without losing the Zilla Slab
|
|
display voice" — so the sizes come down and the family does not."""
|
|
|
|
def setUp(self):
|
|
self.css = stylesheet()
|
|
self.base = rules(base_sheet(self.css))[":root"]
|
|
|
|
def token(self, declarations: str, name: str):
|
|
found = re.search(rf"{name}\s*:\s*([\d.]+)px", declarations)
|
|
return float(found.group(1)) if found else None
|
|
|
|
def test_the_display_sizes_come_down_at_each_step(self):
|
|
previous = {name: self.token(self.base, name)
|
|
for name in ("--t-hero", "--t-title")}
|
|
for width in ("760px", "480px"):
|
|
root = block_at(self.css, width).get(":root", "")
|
|
for name, was in list(previous.items()):
|
|
now = self.token(root, name)
|
|
self.assertIsNotNone(
|
|
now, f"{name} is not stepped down at {width}")
|
|
self.assertLess(now, was,
|
|
f"{name} does not get smaller at {width}")
|
|
previous[name] = now
|
|
|
|
def test_the_display_family_is_never_redefined(self):
|
|
"""The step is the size. Zilla Slab is the voice, at every
|
|
width — a phone gets a smaller headline, not a different one."""
|
|
for condition, body, _ in media_blocks(self.css):
|
|
self.assertNotIn("--display:", body.replace(" ", ""),
|
|
f"@media{condition} changes the display face")
|
|
|
|
|
|
class OnlyCodeAndTablesScrollSideways(unittest.TestCase):
|
|
def setUp(self):
|
|
self.css = stylesheet()
|
|
|
|
def test_a_code_block_is_its_own_scroller(self):
|
|
base = rules(base_sheet(self.css))
|
|
self.assertIn("overflow-x:auto",
|
|
base.get(".prose pre", "").replace(" ", ""))
|
|
|
|
def test_a_table_becomes_its_own_scroller_with_an_edge(self):
|
|
"""display:block is what makes the table element a scroll
|
|
container at all; the local/scroll pair is what makes its edge
|
|
shadow appear only while there is more to the right."""
|
|
table = block_at(self.css, "1080px").get(".prose table", "")
|
|
flat = table.replace(" ", "")
|
|
self.assertIn("display:block", flat)
|
|
self.assertIn("overflow-x:auto", flat)
|
|
self.assertIn("background-attachment:local,local,scroll,scroll", flat)
|
|
|
|
def test_an_unbreakable_token_in_running_text_breaks(self):
|
|
block = block_at(self.css, "1080px")
|
|
self.assertIn("overflow-wrap:anywhere",
|
|
block.get(".prose :not(pre) > code", "").replace(" ", ""))
|
|
|
|
def test_an_image_can_never_widen_the_page(self):
|
|
base = rules(base_sheet(self.css))
|
|
self.assertIn("max-width:100%",
|
|
base.get(".prose img", "").replace(" ", ""))
|
|
|
|
|
|
class EveryTemplateSaysHowWideItIs(unittest.TestCase):
|
|
"""Without the viewport meta a phone renders the page at 980px and
|
|
scales it down, which makes every rule below a fiction."""
|
|
|
|
def test_each_layout_declares_the_viewport(self):
|
|
for template in sorted(TEMPLATES.glob("*.html")):
|
|
markup = template.read_text("utf-8")
|
|
self.assertIn('name="viewport"', markup,
|
|
f"{template.name} has no viewport meta")
|
|
self.assertIn("width=device-width", markup, template.name)
|
|
|
|
|
|
@needs_renderer
|
|
class TheBuiltPagesCarryTheMenus(unittest.TestCase):
|
|
"""The stylesheet's half of this is above; here is the markup's.
|
|
Built pages, not templates — a strip the builder fills with nothing
|
|
is the failure that would look fine in a template."""
|
|
|
|
NAV = re.compile(r'<details class="menu">(.*?)</details>', re.S)
|
|
CONTENTS = re.compile(
|
|
r'<details class="menu menu-contents">(.*?)</details>', re.S)
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.out = Path(tempfile.mkdtemp(prefix="bench-phone-")).resolve()
|
|
result = run_build(REPO, cls.out)
|
|
if result.returncode != 0: # not assert: must survive python -O
|
|
raise RuntimeError(f"site/build.py failed:\n{result.stderr}")
|
|
cls.manifest = json.loads((SITE / "pages.json").read_text("utf-8"))
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
shutil.rmtree(cls.out, ignore_errors=True)
|
|
|
|
def articles(self):
|
|
for entry in self.manifest["pages"]:
|
|
if entry["layout"] == "article":
|
|
yield entry, BUILDER.target_for(
|
|
self.out, entry["path"]).read_text("utf-8")
|
|
|
|
def test_every_article_carries_the_section_nav_folded(self):
|
|
listed = [page for page in self.manifest["pages"] if page.get("section")]
|
|
for entry, html in self.articles():
|
|
menu = self.NAV.search(html)
|
|
self.assertIsNotNone(menu, f'{entry["path"]} has no folded nav')
|
|
for page in listed:
|
|
self.assertIn(f'href="{page["path"]}"', menu.group(1),
|
|
f'{entry["path"]}\'s menu cannot reach '
|
|
f'{page["path"]}')
|
|
|
|
def test_the_folded_contents_lists_what_the_gutter_lists(self):
|
|
for entry, html in self.articles():
|
|
strip = self.CONTENTS.search(html)
|
|
self.assertIsNotNone(strip,
|
|
f'{entry["path"]} has no contents strip')
|
|
for slug in re.findall(r'<h2 id="([^"]+)"', html):
|
|
self.assertIn(f'href="#{slug}"', strip.group(1),
|
|
f'{entry["path"]}\'s contents strip drops '
|
|
f'#{slug}')
|
|
|
|
def test_at_least_one_page_proves_the_strip_is_not_empty(self):
|
|
"""Guards the test above from passing on a page that happens to
|
|
have no h2s at all."""
|
|
filled = [entry["path"] for entry, html in self.articles()
|
|
if "toc-link" in self.CONTENTS.search(html).group(1)]
|
|
self.assertTrue(filled, "no article page's contents strip has links")
|
|
|
|
def test_the_menus_need_no_script(self):
|
|
"""One menu that opens and closes is a <details>. The site ships
|
|
no JavaScript of its own — the only script on a page is the
|
|
third-party analytics tag, which no menu, link or word depends
|
|
on, and the CSP in site/root/_headers names it explicitly."""
|
|
self.assertEqual([], sorted(self.out.rglob("*.js")))
|
|
for entry in self.manifest["pages"]:
|
|
html = BUILDER.target_for(self.out, entry["path"]).read_text("utf-8")
|
|
for tag in re.findall(r"<script\b[^>]*>", html, re.I):
|
|
self.assertIn("cdn.usefathom.com", tag,
|
|
f'{entry["path"]} loads a script of its own')
|
|
|
|
def test_every_built_page_declares_the_viewport(self):
|
|
for entry in self.manifest["pages"]:
|
|
html = BUILDER.target_for(self.out, entry["path"]).read_text("utf-8")
|
|
self.assertIn(
|
|
'<meta name="viewport" content="width=device-width, '
|
|
'initial-scale=1">', html, entry["path"])
|
|
|
|
|
|
@needs_renderer
|
|
class TheHazardsInGeneratedMarkdown(ScratchCase):
|
|
"""The two shapes a slice of AGENTS.md can take that no amount of
|
|
layout CSS reflows: a table wider than a phone, and a token longer
|
|
than one. Both have to reach the page intact — the container is what
|
|
scrolls, not the page — so this builds them rather than trusting
|
|
that no source file has one yet."""
|
|
|
|
URL = ("https://github.com/12vectors/bench/releases/download/"
|
|
"v0.2-alpha/bench-0.2-alpha.tar.gz")
|
|
|
|
def build_one(self, body: str) -> str:
|
|
(self.repo.root / "SOURCE.md").write_text(
|
|
f"# Doc\n\n## Section\n\n{body}\n", encoding="utf-8")
|
|
self.repo.pages({
|
|
"path": "/hazard/", "title": "Hazard", "layout": "article",
|
|
"section": "Concepts", "source": "SOURCE.md",
|
|
"from": "## Section",
|
|
})
|
|
result = self.repo.build()
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
return (self.repo.out / "hazard" / "index.html").read_text("utf-8")
|
|
|
|
def test_a_wide_table_reaches_the_page_as_a_table(self):
|
|
"""It stays a <table>: the scrolling container is the table
|
|
element itself (see the stylesheet's max-width:1080px block), so
|
|
nothing in the build has to wrap it and nothing in the markup
|
|
can forget to."""
|
|
html = self.build_one(
|
|
"| Setting | Default | What it does |\n"
|
|
"| --- | --- | --- |\n"
|
|
"| `BOARD_PORT` | `26071` | the port the board serves on |\n"
|
|
"| `BOARD_SYNC` | `0` | origin/main is the truth, every "
|
|
"board a replica |\n")
|
|
self.assertIn("<table>", html)
|
|
self.assertIn("<code>BOARD_SYNC</code>", html)
|
|
|
|
def test_a_long_token_in_a_code_block_stays_in_the_code_block(self):
|
|
html = self.build_one(f"```\ncurl -fsSL {self.URL} | tar xz\n```")
|
|
fence = re.search(r"<pre>(.*?)</pre>", html, re.S)
|
|
self.assertIsNotNone(fence, "the code block did not survive")
|
|
self.assertIn(self.URL, fence.group(1))
|
|
|
|
def test_a_long_token_in_running_text_stays_inline_code(self):
|
|
html = self.build_one(f"Fetch it from `{self.URL}` and untar it.")
|
|
self.assertIn(f"<code>{self.URL}</code>", html)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|