#!/usr/bin/env python3
"""Lightweight tests for scripts/build.py and scripts/shared.py.
Run with: python3 scripts/tests/test_build.py
The harness uses plain assertions and a tiny runner so it has no third-party
dependency (matching the rest of the repo's lean tooling).
"""
from __future__ import annotations
import contextlib
import builtins
import importlib.util
import inspect
import io
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
# Make scripts/ importable when running this file directly.
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from build import ( # noqa: E402
DIAGRAM_TARGETS,
HTML_TARGETS,
PPTX_TARGETS,
SCREEN_TARGETS,
_BG_B,
_BG_G,
_BG_R,
_density_bucket,
_extract_root_vars,
_last_content_y,
_markdown_residue_issues,
_off_palette_findings,
_orphan_last_line,
_pair_names,
_parse_slide_sequence,
_resume_balance_issues,
_rhythm_issues,
_root_token_findings,
check_all,
check_cross_template_consistency,
check_markdown_residue,
check_off_palette,
check_placeholders,
main as build_main,
scan_file,
)
from shared import ( # noqa: E402
DIAGRAM_TEMPLATES,
HTML_TEMPLATES,
PARCHMENT_RGB,
ROOT as REPO_ROOT,
SCREEN_TEMPLATES,
TEMPLATES,
build_targets,
diagram_targets,
load_checks_thresholds,
screen_targets,
)
import highlight as highlight_mod # noqa: E402
from highlight import highlight_code_blocks # noqa: E402
from site_facts import ( # noqa: E402
FULL_PUBLIC_FACT_FILES,
REDIRECT_SITE_FILE,
check_site_facts,
site_fact_issues,
site_structure_issues,
)
from tokens import _mermaid_theme_drift # noqa: E402
from verify import RECOGNIZABLE_FALLBACK_FONT_MARKERS # noqa: E402
# --------------------------- helpers ---------------------------
_PASS = 0
_FAIL = 0
def check(name: str, predicate: bool, detail: str = "") -> None:
global _PASS, _FAIL
if predicate:
_PASS += 1
print(f"OK: {name}")
else:
_FAIL += 1
print(f"ERROR: {name}{(' - ' + detail) if detail else ''}")
def write_temp_html(body: str, suffix: str = "-en.html") -> Path:
f = tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False, encoding="utf-8")
f.write(body)
f.close()
return Path(f.name)
def silently(callable_, *args, **kwargs):
"""Run a function with stdout suppressed, return its result."""
sink = io.StringIO()
with contextlib.redirect_stdout(sink):
return callable_(*args, **kwargs)
def run_build_args(args: list[str]) -> tuple[int, str]:
sink = io.StringIO()
with contextlib.redirect_stdout(sink):
rc = build_main(["build.py", *args])
return rc, sink.getvalue()
def site_fact_file_map() -> dict[str, str]:
rels = (*FULL_PUBLIC_FACT_FILES, REDIRECT_SITE_FILE)
return {
rel: (REPO_ROOT / rel).read_text(encoding="utf-8", errors="replace")
for rel in rels
}
# --------------------------- package archive ---------------------------
PACKAGE_MAX_BYTES = 6_000_000
PACKAGE_ROOT_NAME = "kami"
PACKAGE_FORBIDDEN_EXACT = {
".claude-plugin/marketplace.json",
".gitignore",
"AGENTS.md",
"CLAUDE.md",
"README.md",
"assets/images/1.png",
"assets/images/2.png",
"assets/images/3.png",
"assets/fonts/TsangerJinKai02-W04.ttf",
"assets/fonts/TsangerJinKai02-W05.ttf",
"assets/fonts/SourceHanSerifKR-Regular.otf",
"assets/fonts/SourceHanSerifKR-Medium.otf",
"index.html",
"index-en.html",
"index-ja.html",
"index-ko.html",
"index-tw.html",
"index-zh.html",
"llms.txt",
"robots.txt",
"scripts/build_metadata.py",
"scripts/draft-release-notes.py",
"scripts/package-skill.sh",
"sitemap.xml",
"styles.css",
"vercel.json",
}
PACKAGE_FORBIDDEN_PREFIXES = (
".agents/",
".claude/",
".github/",
"assets/demos/",
"assets/examples/",
"assets/illustrations/",
"assets/showcase/",
"plugins/",
"scripts/tests/",
)
PACKAGE_REQUIRED_ENTRIES = {
"SKILL.md",
"CHEATSHEET.md",
"VERSION",
"LICENSE",
"assets/images/logo.svg",
"assets/fonts/JetBrainsMono.woff2",
"assets/templates/resume.html",
"assets/templates/landing-page.html",
"assets/diagrams/sequence.html",
"references/design.md",
"scripts/build.py",
"scripts/ensure-fonts.sh",
"scripts/site_facts.py",
}
def test_dist_package_contents() -> None:
archive = REPO_ROOT / "dist" / "kami.zip"
check("dist/kami.zip exists", archive.exists(), f"missing {archive}")
if not archive.exists():
return
size_bytes = archive.stat().st_size
check("dist/kami.zip stays below 6MB",
size_bytes <= PACKAGE_MAX_BYTES,
f"{size_bytes} bytes > {PACKAGE_MAX_BYTES} bytes")
with zipfile.ZipFile(archive) as zf:
names = set(zf.namelist())
bad_root = sorted(name for name in names if not name.startswith(f"{PACKAGE_ROOT_NAME}/"))
check("dist/kami.zip uses a Claude-friendly top-level skill folder",
not bad_root,
f"entries outside {PACKAGE_ROOT_NAME}/: {', '.join(bad_root)}")
payload_names = {
name.removeprefix(f"{PACKAGE_ROOT_NAME}/")
for name in names
if name.startswith(f"{PACKAGE_ROOT_NAME}/")
}
forbidden = sorted(
name for name in payload_names
if name.startswith(PACKAGE_FORBIDDEN_PREFIXES)
or name in PACKAGE_FORBIDDEN_EXACT
)
check("dist/kami.zip excludes site, CI, tests, demos, generated mirrors, and large bundled fonts",
not forbidden,
f"forbidden entries: {', '.join(forbidden)}")
missing_required = sorted(PACKAGE_REQUIRED_ENTRIES - payload_names)
check("dist/kami.zip keeps required runtime skill files",
not missing_required,
f"missing entries: {', '.join(missing_required)}")
def test_plugin_metadata_generated() -> None:
"""Claude Code / Codex marketplaces and plugin mirrors must stay generated."""
script = REPO_ROOT / "scripts" / "build_metadata.py"
check("build_metadata.py exists", script.exists(), f"missing {script}")
if not script.exists():
return
result = subprocess.run(
[sys.executable, str(script), "--check"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
)
detail = (result.stdout + result.stderr).strip()
check("plugin metadata matches generator", result.returncode == 0, detail)
def test_claude_plugin_marketplace_version_matches_version_file() -> None:
"""Claude Code uses this version instead of falling back to a commit hash."""
version = (REPO_ROOT / "VERSION").read_text(encoding="utf-8").strip()
marketplace_file = REPO_ROOT / ".claude-plugin" / "marketplace.json"
check("Claude plugin marketplace metadata exists", marketplace_file.exists())
if not marketplace_file.exists():
return
marketplace = json.loads(marketplace_file.read_text(encoding="utf-8"))
plugins = marketplace.get("plugins", [])
kami_plugin = next((plugin for plugin in plugins if plugin.get("name") == "kami"), None)
check("Claude plugin marketplace includes kami", kami_plugin is not None)
if not kami_plugin:
return
check("Claude plugin marketplace version matches VERSION",
kami_plugin.get("version") == version,
f"marketplace={kami_plugin.get('version')!r}, VERSION={version!r}")
check("Claude plugin marketplace installs the lightweight plugin directory",
kami_plugin.get("source") == "./plugins/kami",
f"source={kami_plugin.get('source')!r}")
plugin_file = REPO_ROOT / "plugins" / "kami" / ".claude-plugin" / "plugin.json"
check("Claude plugin manifest exists in generated plugin tree", plugin_file.exists())
if not plugin_file.exists():
return
plugin = json.loads(plugin_file.read_text(encoding="utf-8"))
check("Claude plugin manifest version matches VERSION",
plugin.get("version") == version,
f"plugin={plugin.get('version')!r}, VERSION={version!r}")
check("Claude plugin manifest exposes skills directory",
plugin.get("skills") == "./skills/",
f"skills={plugin.get('skills')!r}")
def test_build_metadata_reads_tokens_from_root_argument() -> None:
from build_metadata import build_codex_plugin, read_token_value
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "references").mkdir()
(root / "references" / "tokens.json").write_text('{"--brand":"#123456"}\n', encoding="utf-8")
brand_color = read_token_value(root, "brand")
plugin = build_codex_plugin("9.9.9", brand_color)
check("build_metadata reads brand token from provided root",
plugin["interface"]["brandColor"] == "#123456",
f"brandColor={plugin['interface']['brandColor']}")
# --------------------------- shared registry ---------------------------
def test_registry_consistency() -> None:
check("HTML_TEMPLATES has 24 entries", len(HTML_TEMPLATES) == 24,
f"got {len(HTML_TEMPLATES)}")
check("SCREEN_TARGETS has 3 entries", len(SCREEN_TARGETS) == 3,
f"got {len(SCREEN_TARGETS)}")
check("build_targets matches HTML_TEMPLATES key set",
set(build_targets()) == set(HTML_TEMPLATES))
check("screen_targets matches SCREEN_TARGETS key set",
set(screen_targets()) == set(SCREEN_TARGETS))
check("HTML_TARGETS in build.py matches build_targets()",
dict(HTML_TARGETS) == build_targets())
check("DIAGRAM_TARGETS has 18 entries", len(DIAGRAM_TARGETS) == 18,
f"got {len(DIAGRAM_TARGETS)}")
check("DIAGRAM_TARGETS in build.py matches shared.diagram_targets()",
dict(DIAGRAM_TARGETS) == diagram_targets() == dict(DIAGRAM_TEMPLATES))
check("PPTX_TARGETS has 2 entries", len(PPTX_TARGETS) == 2,
f"got {len(PPTX_TARGETS)}")
check("PARCHMENT_RGB is canonical", PARCHMENT_RGB == (0xF5, 0xF4, 0xED))
def test_runner_auto_discovers_tests() -> None:
names = [name for name, _ in _test_functions()]
check("test runner auto-discovers Codex update command test",
"test_check_update_uses_codex_plugin_update_command" in names)
check("test runner auto-discovers this test",
"test_runner_auto_discovers_tests" in names)
def test_build_cli_rejects_unexpected_flags() -> None:
rc, out = run_build_args(["resume", "--verify"])
check("build.py rejects flags after target",
rc == 2 and "ERROR: unexpected argument: --verify" in out,
out.strip())
rc, out = run_build_args(["--check-density", "-v"])
check("build.py rejects unknown flags for path-based checks",
rc == 2 and "ERROR: unexpected argument: -v" in out,
out.strip())
rc, out = run_build_args(["--verify", "-v"])
check("build.py rejects unknown --verify flags",
rc == 2 and "ERROR: unexpected argument: -v" in out,
out.strip())
rc, out = run_build_args(["--check-markdown", "-v"])
check("build.py rejects unknown --check-markdown flags",
rc == 2 and "ERROR: unexpected argument: -v" in out,
out.strip())
def test_long_doc_templates_use_rendered_toc_pages_and_chapter_headers() -> None:
"""Long-doc TOCs must use WeasyPrint target-counter, and running headers
must follow chapter h1 titles instead of getting stuck on the TOC h2.
"""
sources = ("long-doc.html", "long-doc-en.html", "long-doc-ko.html")
required_ids = {
"#ch-executive-summary",
"#ch-background",
"#ch-methodology",
"#ch-conclusions",
"#ch-appendix",
}
offenders: list[str] = []
for source in sources:
text = (TEMPLATES / source).read_text(encoding="utf-8")
if "target-counter(attr(href), page)" not in text:
offenders.append(f"{source}: missing target-counter")
if ".toc-page" in text:
offenders.append(f"{source}: still has obsolete toc-page wiring")
missing_ids = sorted(href for href in required_ids if f'href="{href}"' not in text or f'id="{href[1:]}"' not in text)
if missing_ids:
offenders.append(f"{source}: missing TOC href/id pairs {missing_ids}")
h1_block = re.search(r"(?m)^ h1\s*\{(?P
.*?)^ \}", text, re.S)
if not h1_block or "string-set: section-title content();" not in h1_block.group("body"):
offenders.append(f"{source}: h1 does not set running header")
h2_block = re.search(r"(?m)^ h2\s*\{(?P.*?)^ \}", text, re.S)
if h2_block and "string-set:" in h2_block.group("body"):
offenders.append(f"{source}: h2 still sets running header")
check("long-doc templates use rendered TOC pages and chapter headers",
not offenders,
"; ".join(offenders))
def test_site_facts_repo_clean() -> None:
rc = silently(check_site_facts, False)
check("public site facts match shared constants and registries", rc == 0,
f"check_site_facts returned {rc}")
def test_site_facts_flags_bad_diagram_count() -> None:
files = site_fact_file_map()
bad = files["index.html"]
bad = bad.replace("18 inline SVG diagram types", "17 inline SVG diagram types")
bad = bad.replace("Eighteen inline SVG diagram types", "Seventeen inline SVG diagram types")
files["index.html"] = bad
issues = site_fact_issues(files)
check("public site facts flag stale diagram counts",
any("index.html: missing diagram count 18" in issue for issue in issues),
f"issues: {issues}")
def test_site_structure_repo_clean() -> None:
"""Locale pages match index.html's DOM skeleton (redirect script exempt)."""
issues = site_structure_issues()
check("locale page structure matches index.html", not issues,
f"issues: {issues}")
def test_site_structure_flags_locale_drift() -> None:
files = site_fact_file_map()
files["index-zh.html"] = files["index-zh.html"].replace(
'
', '
', 1)
issues = site_structure_issues(files)
check("locale structure check flags a drifted heading",
any("index-zh.html: DOM skeleton drifted" in issue for issue in issues),
f"issues: {issues}")
def test_chinese_html_templates_keep_single_serif_stack() -> None:
"""Chinese templates must keep --sans pinned to --serif for PDF glyph safety."""
offenders: list[str] = []
for name, spec in HTML_TEMPLATES.items():
source = spec.source
if name.endswith("-en"):
continue
text = (TEMPLATES / source).read_text(encoding="utf-8")
if "--sans: var(--serif)" not in text and "--sans: var(--serif)" not in text:
offenders.append(source)
check("Chinese HTML templates keep --sans: var(--serif)",
not offenders,
f"offenders: {', '.join(offenders)}")
def _ko_stack_offenders(text: str) -> list[str]:
"""Return CSS declarations that reference the bare `"Source Han Serif K"`
family inside a multi-name fallback stack but omit the real OTF family
name `"Source Han Serif KR"`.
The bare name `"Source Han Serif K"` is legitimate on its own only as the
`@font-face` declared alias (a single-name `font-family: "Source Han Serif K";`
with no comma, which loads via the file/CDN `src`). Anywhere it appears as a
fallback item in a comma-separated stack (`--serif`, `--mono`, `@page`
margin boxes, `code`/`pre`, ...), `"Source Han Serif KR"` MUST sit alongside
it, or an offline Linux skill install cannot resolve the
ensure-fonts.sh-downloaded font by name.
Detection: scan only `font-family` / `--serif` / `--sans` / `--mono`
declaration values (up to the next `;`, never crossing `{`/`}`). The token
`"Source Han Serif K"` (closing quote after `K`) never matches
`"Source Han Serif KR"`, so a value that contains the bare token AND a comma
(i.e. a fallback stack, not a bare `@font-face` alias) must also contain KR.
"""
bare = '"Source Han Serif K"'
kr = '"Source Han Serif KR"'
decl_re = re.compile(r"(?:font-family|--serif|--sans|--mono)\s*:\s*([^;{}]*)", re.IGNORECASE)
offenders: list[str] = []
for m in decl_re.finditer(text):
value = m.group(1)
if bare in value and "," in value and kr not in value:
offenders.append(" ".join(value.split()))
return offenders
def test_korean_templates_carry_resolvable_serif_name() -> None:
"""Every KO fallback stack that names `Source Han Serif K` must also name
`Source Han Serif KR` (the actual family of the bundled OTFs), so the font
resolves by name on an offline Linux skill install. Checks per-declaration,
not just per-file, so a complete `--serif` cannot mask an incomplete local
stack (page-margin header/footer, code/pre, mono).
"""
offenders: list[str] = []
ko_sources = [spec.source for name, spec in HTML_TEMPLATES.items() if name.endswith("-ko")]
ko_sources += [source for name, source in SCREEN_TEMPLATES.items() if name.endswith("-ko")]
# Guard against vacuous green: with zero -ko templates the offender loop
# never runs and the check below would pass while enforcing nothing.
check("Korean template set is non-empty", bool(ko_sources),
"no -ko templates found in the registries")
for source in ko_sources:
text = (TEMPLATES / source).read_text(encoding="utf-8")
for bad in _ko_stack_offenders(text):
offenders.append(f"{source}: {bad}")
check("Korean fallback stacks all carry Source Han Serif KR",
not offenders,
f"offenders: {'; '.join(offenders)}")
# ---------- sibling placeholder parity (issue #38 class) ----------
# Repeated template structures whose placeholder hints must repeat the first
# block verbatim. Hint richness degrading from block 1 to later siblings makes
# fillers (human or agent) produce degraded copy; see issue #38. Cycle length N
# means placeholders repeat in groups of N (e.g. Role/Actions/Impact rows).
_SIBLING_PARITY_SPECS = (
("resume*.html", r'class="proj-text">(\{\{.*?\}\})', 3),
("resume*.html", r'class="proj-role">(\{\{.*?\}\})', 1),
("resume*.html", r'class="conv-body">\s*(\{\{.*?\}\})', 1),
("resume*.html", r'class="os-desc">(\{\{.*?\}\})', 1),
("resume*.html", r'class="art-stats">(\{\{.*?\}\})', 1),
("portfolio*.html", r'class="project-block">\s*
', 3),
("long-doc*.html", r'(\{\{(?:一段论述|A paragraph|한 단락 논술).*?\}\})', 1),
)
def test_sibling_placeholder_hints_stay_in_parity() -> None:
"""Same-structure sibling blocks must carry identical placeholder hints."""
matched = 0
offenders: list[str] = []
for glob_pattern, regex, cycle in _SIBLING_PARITY_SPECS:
rx = re.compile(regex, re.DOTALL)
for path in sorted(TEMPLATES.glob(glob_pattern)):
hits = rx.findall(path.read_text(encoding="utf-8"))
if not hits:
offenders.append(f"{path.name}: no match for {regex[:40]!r} (stale spec?)")
continue
matched += 1
if len(hits) % cycle != 0:
offenders.append(f"{path.name}: {len(hits)} hint(s) not divisible by cycle {cycle}")
continue
first = hits[:cycle]
for start in range(cycle, len(hits), cycle):
block = hits[start:start + cycle]
if block != first:
offenders.append(
f"{path.name}: block {start // cycle + 1} diverges from block 1: "
f"{block} != {first}")
check("sibling parity specs matched across template families", matched >= 30,
f"only {matched} template/spec matches; spec table may be stale")
check("repeated blocks carry identical placeholder hints", not offenders,
"; ".join(offenders[:6]))
def test_font_fallback_markers_recognize_pt_serif() -> None:
"""macOS without Charter may render English fallbacks as PT Serif."""
embedded = {"DROIWJ+PT-Serif", "ZBEAAE+JetBrains-Mono"}
fallback_present = any(
marker in font for font in embedded
for marker in RECOGNIZABLE_FALLBACK_FONT_MARKERS
)
check("font fallback markers recognize PT-Serif",
fallback_present,
f"markers: {RECOGNIZABLE_FALLBACK_FONT_MARKERS}")
def test_chinese_slides_mono_has_cjk_fallback() -> None:
"""Slide labels may mix mono Latin and CJK; the mono stack needs CJK fallback."""
text = (TEMPLATES / "slides-weasy.html").read_text(encoding="utf-8")
check("slides-weasy mono stack includes TsangerJinKai02 fallback",
'"TsangerJinKai02"' in text and '"Source Han Serif SC"' in text)
# --------------------------- scan_file ---------------------------
def test_scan_file_skip_bug() -> None:
"""Lines starting with '#' (CSS id selectors) must NOT be skipped."""
fixture = """
"""
p = write_temp_html(fixture)
try:
findings = scan_file(p)
rules = {f.rule for f in findings}
check("scan_file flags rgba on #id-prefixed CSS line",
"rgba-background" in rules,
f"rules found: {rules or '(none)'}")
finally:
p.unlink(missing_ok=True)
def test_scan_file_arrow_in_en() -> None:
"""`→` in -en.html body should trigger arrow-unicode-in-en."""
fixture = """
Step 1 → Step 2
"""
p = write_temp_html(fixture, suffix="-en.html")
try:
findings = scan_file(p)
rules = {f.rule for f in findings}
check("scan_file flags U+2192 arrow in -en.html",
"arrow-unicode-in-en" in rules,
f"rules found: {rules or '(none)'}")
finally:
p.unlink(missing_ok=True)
def test_scan_file_clean_template() -> None:
"""A clean template should produce zero findings."""
fixture = """
"""
p = write_temp_html(fixture)
try:
findings = scan_file(p)
check("scan_file produces no findings on clean template",
len(findings) == 0,
f"got {len(findings)} finding(s): {[f.rule for f in findings]}")
finally:
p.unlink(missing_ok=True)
# --------------------------- slide sequence ---------------------------
def test_parse_slide_sequence_empty() -> None:
fixture = """def main():
pass
"""
p = write_temp_html(fixture, suffix=".py")
try:
seq = _parse_slide_sequence(p)
check("_parse_slide_sequence returns [] for empty main()",
seq == [], f"got {seq}")
finally:
p.unlink(missing_ok=True)
def test_parse_slide_sequence_basic() -> None:
fixture = """def main():
cover_slide()
content_slide()
content_slide()
chapter_slide()
metrics_slide()
def helper():
other_call()
"""
p = write_temp_html(fixture, suffix=".py")
try:
seq = _parse_slide_sequence(p)
expected = ["cover_slide", "content_slide", "content_slide", "chapter_slide", "metrics_slide"]
check("_parse_slide_sequence parses ordered slide calls",
seq == expected, f"got {seq}")
finally:
p.unlink(missing_ok=True)
# --------------------------- scan_file extra rules ---------------------------
def test_scan_file_line_height_too_loose() -> None:
"""line-height >= 1.6 should trigger line-height-too-loose."""
fixture = """
"""
p = write_temp_html(fixture)
try:
findings = scan_file(p)
rules = {f.rule for f in findings}
check("scan_file flags line-height 1.8 (too loose)",
"line-height-too-loose" in rules,
f"rules found: {rules or '(none)'}")
finally:
p.unlink(missing_ok=True)
def test_scan_file_cool_gray() -> None:
"""Cool-gray hex literals should be flagged."""
fixture = """
"""
p = write_temp_html(fixture)
try:
findings = scan_file(p)
rules = {f.rule for f in findings}
check("scan_file flags cool gray #888",
"cool-gray" in rules,
f"rules found: {rules or '(none)'}")
finally:
p.unlink(missing_ok=True)
def test_off_palette_flags_non_token_hex() -> None:
"""A non-token, non-cool-gray hex in a component rule is off-palette."""
fixture = """
"""
p = write_temp_html(fixture)
try:
findings = _off_palette_findings(p, {"#1b365d"})
rules = {f.rule for f in findings}
check("_off_palette_findings flags non-token hex #ff00aa",
"off-palette" in rules,
f"rules found: {rules or '(none)'}")
finally:
p.unlink(missing_ok=True)
def test_off_palette_ignores_root_and_svg() -> None:
"""Hex inside :root token defs and inside