Skip to content

Commit 5cdd729

Browse files
feat: distinguish smoke skips from passes and assert post-exit sidecars
Exit 0 was the only green. A version skip would have been a vacuous pass. The host runner records SKIP only on exit 77 plus a SMOKE_SKIP reason below --min-version; a missing sidecar after Blender exits is FAIL; a leg with zero PASSes is red. Signed-off-by: fOuttaMyPaint <TMhospitalitystrategies@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7d08804 commit 5cdd729

14 files changed

Lines changed: 795 additions & 506 deletions

‎.github/workflows/blender-smoke.yml‎

Lines changed: 67 additions & 501 deletions
Large diffs are not rendered by default.

‎.github/workflows/validate.yml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,3 +270,12 @@ jobs:
270270
271271
print(f'Counts verified: {skill_count} skills, {rule_count} rules, {template_count} {template_word}, {snippet_count} snippets, {example_count} examples')
272272
PYEOF
273+
274+
validate-harness:
275+
name: Validate smoke harness protocol
276+
runs-on: ubuntu-latest
277+
steps:
278+
- uses: actions/checkout@v7
279+
280+
- name: Harness unit tests
281+
run: python3 tests/smoke/test_harness.py -v

‎AGENTS.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,12 @@ way, and a one-paragraph rationale. 30 to 80 lines is the right size.
157157
the manifest `version` line (see `release.yml` below) — never hand-edit it.
158158
- `blender-smoke.yml` executes every shipped example (check-only, no render)
159159
plus snippet/template smoke tests inside REAL headless Blender, on
160-
5.2 LTS and 4.5 LTS for every PR (5.1 on the weekly cron). A new example is not
161-
shipped until it has a step here.
160+
5.2 LTS and 4.5 LTS for every PR (5.1 on the weekly cron). Examples run
161+
through `tests/smoke/run_example.py` (catalog: `tests/smoke/catalog.json`).
162+
SKIP is exit 77 plus a `SMOKE_SKIP:` reason, and only when `--min-version`
163+
is above this Blender; exit 0 with that marker is a vacuous pass and fails.
164+
Post-exit sidecars are opt-in (`--expect-sidecar`). A leg with zero PASSes
165+
is red. A new example is not shipped until it has a catalog row.
162166
- `drift-check.yml` consumes `Developer-Tools-Directory/.github/actions/
163167
drift-check@v1.15` to enforce ecosystem standards-version markers.
164168
- `release.yml` auto-bumps the version, tags, force-updates floating tags

‎CLAUDE.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,20 @@ Stage with **explicit paths only** — never `git add -A` or `git add .`. Cursor
130130
- **After gallery regeneration** (`python scripts/build_gallery.py`), read the **generated HTML** character by character — the `<img alt>` text and witnesses callouts in `docs/gallery/index.html` and `docs/gallery/<name>/index.html` — not just `examples/gallery.json`. Precedent: the `teaches.split(".")[0]` bug truncated 14/21 card alts at dotted API paths like `bmesh.ops` while the source JSON looked fine (fixed in PR #68).
131131
- **Playwright gallery captures:** gallery `<img>` tags lazy-load, so force them first (`document.querySelectorAll('img').forEach(i => i.loading = 'eager')`, then wait). **Scroll the target card into view and take a viewport capture** — `scrollIntoView({block:'center', behavior:'instant'})`, short wait, `browser_take_screenshot` with `fullPage` omitted. A `fullPage` capture is NOT a workaround: on a tall gallery page it renders every card image blank even when the images are verified loaded (`complete === true`, `naturalWidth === 1280`, `opacity === 1`) — measured on the 45-card grid at 1425x4516. Verify load state via `browser_evaluate` rather than trusting the pixels.
132132

133+
## Smoke skip and post-exit
134+
135+
The host runner is `tests/smoke/run_example.py`. Shipped examples are listed in
136+
`tests/smoke/catalog.json` (not a new YAML step per example).
137+
138+
- **SKIP:** print `SMOKE_SKIP: <reason>` and `sys.exit(77)`. Legal only when the
139+
catalog/runner `--min-version` is **above** this Blender. Exit 0 with that
140+
marker is FAIL (vacuous). Skip on a version that should run is FAIL.
141+
- **Post-exit sidecar:** opt-in `--expect-sidecar PATH`. The example writes
142+
`$BDT_SMOKE_SIDECAR` (set by the runner). The harness asserts after Blender
143+
exits. Not a gallery still.
144+
- **Summary:** `tests/smoke/summarize.py` prints passed/skipped/failed. Zero
145+
PASSes makes the job red even if every example skipped cleanly.
146+
133147
## Example-Run Process
134148

135149
- The canonical example-creation prompt lives at `docs/new-example-prompt.md`; keep it in agreement with this file and `AGENTS.md`.

‎docs/new-example-prompt.md‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,11 @@ you write code:
4646
Where an API diverges between 4.5, 5.1, and 5.2, asserting each side's actual contract is
4747
part of the witness—version-gate explicitly and document the divergence rather than
4848
papering over it. Version-gate on the `bpy.app.version` tuple, not
49-
`version_string`, which is not bare semver on LTS builds. If you discover an
49+
`version_string`, which is not bare semver on LTS builds. If an example cannot
50+
run on a matrix leg, it must print `SMOKE_SKIP: <reason>` and `sys.exit(77)` —
51+
exit 0 is a pass, not a skip. Set catalog `min_version` so a skip on a version
52+
that should run is FAIL. Post-exit sidecars (`$BDT_SMOKE_SIDECAR`, harness
53+
`--expect-sidecar`) are opt-in and are not gallery stills. If you discover an
5054
undocumented hazard while authoring (a crash, a dangling reference, an ordering
5155
constraint), that discovery belongs in the code comments and README; it is often
5256
more valuable than the original subject.
@@ -67,8 +71,8 @@ The example must:
6771

6872
Complete every integration required for a shipped example. Infer the exact current
6973
shape from neighboring examples and repository configuration, including the example
70-
directory, README, gallery metadata and assets, plugin manifest, smoke workflow,
71-
top-level README, and generated gallery pages. After regenerating the gallery with
74+
directory, README, gallery metadata and assets, plugin manifest, smoke catalog
75+
(`tests/smoke/catalog.json`), top-level README, and generated gallery pages. After regenerating the gallery with
7276
`python scripts/build_gallery.py`, read the **generated** output character by
7377
character—not only `examples/gallery.json` source fields. Open
7478
`docs/gallery/index.html` and `docs/gallery/<name>/index.html` and inspect the

‎tests/smoke/canary_sidecar.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Harness canary: write $BDT_SMOKE_SIDECAR then exit 0.
2+
3+
The host runner asserts the file after this process dies. Pass --omit-sidecar
4+
to prove a missing sidecar is FAIL. Not a shipped example. No gallery still.
5+
"""
6+
import os
7+
import sys
8+
9+
omit = "--omit-sidecar" in sys.argv
10+
path = os.environ.get("BDT_SMOKE_SIDECAR")
11+
if not omit:
12+
if not path:
13+
print("ERROR: BDT_SMOKE_SIDECAR unset", file=sys.stderr)
14+
sys.exit(1)
15+
parent = os.path.dirname(path)
16+
if parent:
17+
os.makedirs(parent, exist_ok=True)
18+
with open(path, "w", encoding="utf-8") as fh:
19+
fh.write("sidecar-ok\n")
20+
print(f"wrote sidecar {path}", flush=True)
21+
else:
22+
print("omitting sidecar (canary red path)", flush=True)
23+
sys.exit(0)

‎tests/smoke/canary_skip.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""Harness canary: always skip. Legal only when --min-version is above this Blender.
2+
3+
Prints SMOKE_SKIP and exits 77. Not a shipped example.
4+
"""
5+
import sys
6+
7+
print("SMOKE_SKIP: harness canary (always skip)", flush=True)
8+
sys.exit(77)

‎tests/smoke/catalog.json‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
[
2+
{
3+
"name": "swatch-grid",
4+
"script": "examples/swatch-grid/swatch_grid.py",
5+
"args": ["--output", "$OUT/swatch.png", "--engine", "cycles", "--samples", "8", "--width", "640"],
6+
"expect_file": "$OUT/swatch.png"
7+
},
8+
{"name": "turntable", "script": "examples/turntable/turntable.py"},
9+
{"name": "gn-sdf-remesh", "script": "examples/gn-sdf-remesh/gn_sdf_remesh.py"},
10+
{"name": "depsgraph-export", "script": "examples/depsgraph-export/depsgraph_export.py"},
11+
{"name": "wave-displace", "script": "examples/wave-displace/wave_displace.py"},
12+
{"name": "driver-wave", "script": "examples/driver-wave/driver_wave.py"},
13+
{"name": "bmesh-gear", "script": "examples/bmesh-gear/bmesh_gear.py"},
14+
{"name": "shader-node-group", "script": "examples/shader-node-group/shader_node_group.py"},
15+
{"name": "temp-override-join", "script": "examples/temp-override-join/temp_override_join.py"},
16+
{"name": "gn-instance-grid", "script": "examples/gn-instance-grid/gn_instance_grid.py"},
17+
{"name": "gn-modifier-inputs", "script": "examples/gn-modifier-inputs/gn_modifier_inputs.py"},
18+
{"name": "shape-key-blend", "script": "examples/shape-key-blend/shape_key_blend.py"},
19+
{"name": "curve-bevel-arc", "script": "examples/curve-bevel-arc/curve_bevel_arc.py"},
20+
{"name": "compositor-glare", "script": "examples/compositor-glare/compositor_glare.py"},
21+
{"name": "damped-track-aim", "script": "examples/damped-track-aim/damped_track_aim.py"},
22+
{"name": "color-attribute-wheel", "script": "examples/color-attribute-wheel/color_attribute_wheel.py"},
23+
{"name": "parent-inverse-orrery", "script": "examples/parent-inverse-orrery/parent_inverse_orrery.py"},
24+
{"name": "grease-pencil-rosette", "script": "examples/grease-pencil-rosette/grease_pencil_rosette.py"},
25+
{"name": "gp-lineart-contour", "script": "examples/gp-lineart-contour/gp_lineart_contour.py"},
26+
{"name": "armature-bend", "script": "examples/armature-bend/armature_bend.py"},
27+
{"name": "text-version-stamp", "script": "examples/text-version-stamp/text_version_stamp.py"},
28+
{"name": "image-pixels-testcard", "script": "examples/image-pixels-testcard/image_pixels_testcard.py"},
29+
{"name": "uv-layer-grid", "script": "examples/uv-layer-grid/uv_layer_grid.py"},
30+
{"name": "png-exr-alpha", "script": "examples/png-exr-alpha/png_exr_alpha.py"},
31+
{"name": "vse-cut-list", "script": "examples/vse-cut-list/vse_cut_list.py"},
32+
{
33+
"name": "vse-cut-list-pixels",
34+
"script": "examples/vse-cut-list/vse_cut_list.py",
35+
"args": ["--check-pixels", "--engine", "cycles"]
36+
},
37+
{"name": "gltf-export-roundtrip", "script": "examples/gltf-export-roundtrip/gltf_export_roundtrip.py"},
38+
{"name": "lod-decimate-chain", "script": "examples/lod-decimate-chain/lod_decimate_chain.py"},
39+
{"name": "vertex-weight-limit", "script": "examples/vertex-weight-limit/vertex_weight_limit.py"},
40+
{"name": "triangulate-tangents", "script": "examples/triangulate-tangents/triangulate_tangents.py"},
41+
{"name": "gltf-skin-roundtrip", "script": "examples/gltf-skin-roundtrip/gltf_skin_roundtrip.py"},
42+
{"name": "vse-gamma-cross", "script": "examples/vse-gamma-cross/vse_gamma_cross.py"},
43+
{"name": "light-link-studio", "script": "examples/light-link-studio/light_link_studio.py"},
44+
{"name": "collision-hull-proxy", "script": "examples/collision-hull-proxy/collision_hull_proxy.py"},
45+
{"name": "custom-normals-shade", "script": "examples/custom-normals-shade/custom_normals_shade.py"},
46+
{"name": "sky-texture-sun-elevation", "script": "examples/sky-texture-sun-elevation/sky_texture_sun_elevation.py"},
47+
{"name": "mesh-hygiene-audit", "script": "examples/mesh-hygiene-audit/mesh_hygiene_audit.py"},
48+
{"name": "prop-origin-transform", "script": "examples/prop-origin-transform/prop_origin_transform.py"},
49+
{"name": "soccer-ball-goldberg", "script": "examples/soccer-ball-goldberg/soccer_ball_goldberg.py"},
50+
{"name": "car-mirror-symmetry", "script": "examples/car-mirror-symmetry/car_mirror_symmetry.py"},
51+
{"name": "attribute-domain-shear", "script": "examples/attribute-domain-shear/attribute_domain_shear.py"},
52+
{"name": "degenerate-bevel-weld", "script": "examples/degenerate-bevel-weld/degenerate_bevel_weld.py"},
53+
{"name": "modular-kit-snap", "script": "examples/modular-kit-snap/modular_kit_snap.py"},
54+
{"name": "lightmap-uv-channel", "script": "examples/lightmap-uv-channel/lightmap_uv_channel.py"},
55+
{"name": "socket-attach-points", "script": "examples/socket-attach-points/socket_attach_points.py"},
56+
{"name": "vertex-color-ao", "script": "examples/vertex-color-ao/vertex_color_ao.py"}
57+
]

‎tests/smoke/protocol.py‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""Skip / pass / fail classification for the host smoke harness.
2+
3+
Blender example scripts have two legal ways to finish:
4+
5+
* exit 0 — ran and passed (then optional post-exit sidecar)
6+
* exit 77 with a ``SMOKE_SKIP: <reason>`` line — unsupported on this Blender
7+
8+
Anything else is FAIL, including the vacuous-pass vector: printing a skip
9+
marker and exiting 0, or exiting 77 on a version that should run.
10+
"""
11+
from __future__ import annotations
12+
13+
import os
14+
from typing import Optional, Tuple
15+
16+
SKIP_EXIT = 77
17+
SKIP_PREFIX = "SMOKE_SKIP:"
18+
19+
PASS = "PASS"
20+
SKIP = "SKIP"
21+
FAIL = "FAIL"
22+
23+
24+
def parse_version(spec: str) -> Tuple[int, ...]:
25+
"""'5.2' or '5.2.1' or '5.0' -> comparable tuple (pad to 3)."""
26+
parts = []
27+
for p in spec.strip().split("."):
28+
if not p.isdigit():
29+
raise ValueError(f"not a version spec: {spec!r}")
30+
parts.append(int(p))
31+
if not parts:
32+
raise ValueError(f"empty version spec: {spec!r}")
33+
while len(parts) < 3:
34+
parts.append(0)
35+
return tuple(parts[:3])
36+
37+
38+
def parse_skip_reason(output: str) -> Optional[str]:
39+
for line in output.splitlines():
40+
s = line.strip()
41+
if s.startswith(SKIP_PREFIX):
42+
reason = s[len(SKIP_PREFIX):].strip()
43+
return reason or None
44+
return None
45+
46+
47+
def sidecar_ok(path: Optional[str], contains: Optional[str]) -> Tuple[bool, str]:
48+
if not path:
49+
return False, "sidecar path not set"
50+
if not os.path.isfile(path):
51+
return False, f"missing post-exit sidecar {path}"
52+
if os.path.getsize(path) == 0:
53+
return False, f"empty post-exit sidecar {path}"
54+
if contains:
55+
with open(path, encoding="utf-8") as fh:
56+
text = fh.read()
57+
if contains not in text:
58+
return False, f"sidecar missing expected text {contains!r}"
59+
return True, ""
60+
61+
62+
def classify(
63+
*,
64+
proc_exit: int,
65+
output: str,
66+
min_version: Optional[str] = None,
67+
blender_version: Optional[str] = None,
68+
forbid_skip: bool = False,
69+
expect_sidecar: bool = False,
70+
sidecar_path: Optional[str] = None,
71+
sidecar_contains: Optional[str] = None,
72+
) -> Tuple[str, str]:
73+
"""Return (PASS|SKIP|FAIL, detail).
74+
75+
Skip is legal only when ``min_version`` is set and ``blender_version``
76+
is strictly below it (and ``forbid_skip`` is false). An expected skip
77+
does not require a sidecar.
78+
"""
79+
reason = parse_skip_reason(output)
80+
skipped = proc_exit == SKIP_EXIT
81+
82+
if proc_exit == 0 and reason is not None:
83+
return FAIL, "SMOKE_SKIP marker with exit 0 (vacuous skip)"
84+
85+
if skipped:
86+
if not reason:
87+
return FAIL, f"exit {SKIP_EXIT} without SMOKE_SKIP reason"
88+
if forbid_skip:
89+
return FAIL, f"skipped where skip is forbidden: {reason}"
90+
if min_version is None:
91+
return FAIL, f"skipped with no --min-version (unexpected): {reason}"
92+
if blender_version is None:
93+
return FAIL, f"skipped but blender version unknown: {reason}"
94+
if parse_version(blender_version) >= parse_version(min_version):
95+
return (
96+
FAIL,
97+
f"skipped on {blender_version} but min-version {min_version} "
98+
f"(should run): {reason}",
99+
)
100+
return SKIP, reason
101+
102+
if proc_exit != 0:
103+
return FAIL, f"blender exit {proc_exit}"
104+
105+
if expect_sidecar:
106+
ok, detail = sidecar_ok(sidecar_path, sidecar_contains)
107+
if not ok:
108+
return FAIL, detail
109+
110+
return PASS, ""
111+
112+
113+
def summarize_records(records):
114+
"""records: iterable of dicts with 'status' key.
115+
116+
Returns (passed, skipped, failed, harness_exit).
117+
harness_exit is 0 on mixed pass+skip, 1 if any FAIL, 2 if zero PASS.
118+
"""
119+
passed = skipped = failed = 0
120+
for rec in records:
121+
st = rec["status"]
122+
if st == PASS:
123+
passed += 1
124+
elif st == SKIP:
125+
skipped += 1
126+
else:
127+
failed += 1
128+
if failed:
129+
return passed, skipped, failed, 1
130+
if passed == 0:
131+
return passed, skipped, failed, 2
132+
return passed, skipped, failed, 0

‎tests/smoke/run_catalog.py‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Run every shipped example through run_example.classify via run_example.py.
2+
3+
Stops on the first FAIL (same fail-fast as the previous per-step YAML).
4+
"""
5+
from __future__ import annotations
6+
7+
import argparse
8+
import json
9+
import os
10+
import subprocess
11+
import sys
12+
13+
HERE = os.path.dirname(os.path.abspath(__file__))
14+
15+
16+
def main(argv=None):
17+
p = argparse.ArgumentParser()
18+
p.add_argument("--blender", required=True)
19+
p.add_argument("--catalog", default=os.path.join(HERE, "catalog.json"))
20+
p.add_argument("--series", required=True)
21+
p.add_argument("--status", default=os.environ.get("BDT_SMOKE_STATUS"))
22+
p.add_argument("--out", required=True, help="scratch dir for --output renders")
23+
p.add_argument("--xvfb", action="store_true")
24+
args = p.parse_args(argv)
25+
26+
with open(args.catalog, encoding="utf-8") as fh:
27+
catalog = json.load(fh)
28+
29+
os.makedirs(args.out, exist_ok=True)
30+
runner = os.path.join(HERE, "run_example.py")
31+
n = 0
32+
for item in catalog:
33+
n += 1
34+
name = item["name"]
35+
script = item["script"]
36+
extra = list(item.get("args") or [])
37+
extra = [a.replace("$OUT", args.out) for a in extra]
38+
cmd = [
39+
sys.executable,
40+
runner,
41+
"--name",
42+
name,
43+
"--blender",
44+
args.blender,
45+
"--script",
46+
script,
47+
"--series",
48+
args.series,
49+
"--status",
50+
args.status or "",
51+
]
52+
if args.xvfb:
53+
cmd.append("--xvfb")
54+
if item.get("min_version"):
55+
cmd.extend(["--min-version", item["min_version"]])
56+
if extra:
57+
cmd.append("--")
58+
cmd.extend(extra)
59+
print(f"::group::{name}", flush=True)
60+
code = subprocess.call(cmd)
61+
print("::endgroup::", flush=True)
62+
if code != 0:
63+
print(f"catalog abort at {name} (exit {code})", file=sys.stderr)
64+
return code
65+
expect = item.get("expect_file")
66+
if expect:
67+
expect = expect.replace("$OUT", args.out)
68+
if not os.path.isfile(expect) or os.path.getsize(expect) == 0:
69+
print(f"ERROR: expected output missing {expect}", file=sys.stderr)
70+
return 1
71+
print(f"catalog finished {n} entries", flush=True)
72+
return 0
73+
74+
75+
if __name__ == "__main__":
76+
sys.exit(main())

0 commit comments

Comments
 (0)