A typo in a CSS class shouldn't need a human to catch it
Shipped
Ghostwriter got a new how-to card family: four layouts (howto, howto-grid, howto-check, howto-stack) that rotate so a how-to post’s graphic doesn’t look identical every time, plus a topic → icon cheat-sheet and a single rich idea-menu for blank-start posts instead of a two-tier pick-a-lane flow. The card family is genuinely new surface area: four HTML templates, each depending on a set of CSS classes defined in diagram.css, each needing a #canvas sizing rule so the render comes out at the right dimensions.
That’s also exactly the kind of change that used to only get caught by rendering a card and eyeballing the PNG. This release adds tests/test_card_templates.py, which checks the same thing without ever opening a browser.
Two files that only agree by convention
A card template is HTML: <div class="card howto-grid light">. The stylesheet is CSS: #canvas.card.howto-grid.light { ... }. Nothing in HTML or CSS checks that these strings match each other. A typo in either file, a class the template uses that the stylesheet never defines, or a sizing rule for a card type nobody shipped a template for, renders without error. The element just doesn’t get the styling, silently, and the only way anyone finds out is by looking at the output.
That’s the general shape of a class of bugs that live at the seam between two independently-edited artifacts with no compiler checking their agreement. It’s structurally similar to what contract testing solves for services: a consumer and a provider agree on a shape, and the value of testing that agreement is that “any provider behaviour not used by current consumers is free to change without breaking tests,” while behavior that is relied on gets caught the moment it drifts (Pact docs). A template and a stylesheet aren’t a client and a server, but the failure mode is the same: two things that only stay correct because a human kept them in sync by hand.
The reason this is worth catching statically, rather than by rendering and looking, comes down to where a check like this belongs in a test suite. Martin Fowler’s test pyramid argues for far more low-level tests than high-level ones, because tests that run through a GUI are brittle and “more prone to non-determinism problems” (martinfowler.com). Google’s internal testing guidance makes the same point from a different angle: tests confined to a single process, with no browser, network, or rendering involved, are categorically faster and more deterministic than tests that need more infrastructure, regardless of what they’re actually testing (Google Testing Blog). A rendered-PNG check is a large test for what’s actually a small, structural question: do these two files reference the same class names.
Build it: harvest what’s defined, check what’s used
The stylesheet is the source of truth. Every class it defines shows up as part of a selector, so a regex over the raw CSS text extracts the full defined vocabulary:
import re
from pathlib import Path
ASSETS = Path(__file__).resolve().parent.parent / "assets"
CSS = (ASSETS / "diagram.css.example").read_text(encoding="utf-8")
# .foo, .card.howto-grid.light, .step::before -> {foo, card, howto-grid, light, step}
DEFINED = set(re.findall(r"\.(-?[A-Za-z_][\w-]*)", CSS))
Each template’s class="..." attributes give the used vocabulary. One wrinkle: the templates carry documentation comments with example markup for illustration, and scanning those would produce false positives, so comments are stripped first:
COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
def used_classes(html: str) -> set[str]:
html = COMMENT.sub("", html)
tokens: set[str] = set()
for attr in re.findall(r'class="([^"]*)"', html):
tokens.update(attr.split())
return tokens
The actual test is a set difference, parametrized over every card-template*.html file so a new template is covered automatically:
import pytest
CARD_TEMPLATES = sorted(ASSETS.glob("card-template*.html"))
@pytest.mark.parametrize("tpl", CARD_TEMPLATES, ids=lambda p: p.name)
def test_every_template_class_is_defined(tpl: Path):
undefined = sorted(
c for c in used_classes(tpl.read_text(encoding="utf-8")) if c not in DEFINED
)
assert not undefined, f"{tpl.name} uses undefined class(es): {undefined}"
That catches a template referencing a class the stylesheet doesn’t have. The inverse gap, a card type registered in the stylesheet with a sizing rule but no template ever shipped for it, or a template shipped without its sizing rule, needs a second, more specific check:
def canvas_classes(html: str) -> list[str] | None:
m = re.search(r'id="canvas"\s+class="([^"]*)"', COMMENT.sub("", html))
return m.group(1).split() if m else None
def test_howto_family_is_registered_and_synced():
for ctype in ("howto", "howto-grid", "howto-check", "howto-stack"):
assert (ASSETS / f"card-template-{ctype}.html").exists(), (
f"missing template for how-to variant '{ctype}'"
)
assert f"#canvas.card.{ctype}.light" in CSS, (
f"diagram.css.example missing the sizing rule for '{ctype}'"
)
This one is a named-family test rather than a generic loop, on purpose: it encodes the fact that these four variants are a deliberate set, so a variant that exists in one file but not the other fails loudly instead of silently rendering at the wrong size.
Use it
pytest skills/ghostwriter/skills/ghostwriter/tests/test_card_templates.py -v
Rename a class in diagram.css without updating the template that uses it, and the run fails immediately with the exact template file and the exact undefined class name, no render, no headless browser, no PNG to inspect by eye.
Verify it and the edge case that mattered
The edge case that shaped the implementation was the documentation comments at the top of every template, which deliberately include example class="t-str/t-num" markup as a legend for how to style the card’s content. Scanning those as real usage would have flagged every template as broken on day one. Stripping HTML comments before extracting classes fixed it, and it’s also why the check has to parse the template rather than something coarser like grepping for class=, since a naive grep can’t distinguish real markup from a comment describing markup. The test suite ships as a genuinely fast, fully deterministic check: no fixtures, no network, no browser, just two files read as text and compared. That’s what makes it something to run on every change instead of something to remember to check before a release.
Sources
- Pact Docs — Introduction — the value proposition of contract testing between two independently-changing artifacts
- Martin Fowler — Test Pyramid — why high-level, rendering-dependent tests are more brittle and non-deterministic than low-level ones
- Google Testing Blog — Test Sizes — why single-process, no-infrastructure tests are faster and more deterministic regardless of scope
Changelog
- feat(ghostwriter): how-to card family + rich topic menu (0.10.0) (#39) (4f2b8fd)