@ramarivera/coding-buddy 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +23 -4
  2. package/adapters/omp/context.ts +1 -1
  3. package/adapters/omp/events.ts +286 -81
  4. package/adapters/omp/index.ts +3 -1
  5. package/adapters/omp/renderers.ts +4 -54
  6. package/adapters/omp/storage.ts +6 -1
  7. package/adapters/omp/ui.ts +17 -6
  8. package/adapters/pi/events.ts +280 -83
  9. package/adapters/pi/index.ts +11 -2
  10. package/adapters/pi/renderers.ts +3 -47
  11. package/adapters/pi/storage.ts +6 -1
  12. package/adapters/pi/ui.ts +17 -6
  13. package/adapters/shared/file-storage.ts +22 -0
  14. package/adapters/shared/widget-layout.ts +248 -2
  15. package/cli/settings.ts +1 -1
  16. package/cli/tui.tsx +1 -1
  17. package/core/command-service.ts +13 -0
  18. package/core/engine.ts +11 -2
  19. package/core/identity.ts +15 -0
  20. package/core/model.ts +3 -0
  21. package/core/ports.ts +4 -0
  22. package/core/reactions.ts +29 -2
  23. package/core/render-model.ts +4 -3
  24. package/package.json +6 -3
  25. package/scripts/ci/Dockerfile.vhs +125 -0
  26. package/scripts/ci/check-statusline.sh +441 -14
  27. package/scripts/ci/check-vhs-goldens.sh +97 -0
  28. package/scripts/ci/fonts.conf +37 -0
  29. package/scripts/ci/goldens/baseline-40.png +0 -0
  30. package/scripts/ci/goldens/baseline-80.png +0 -0
  31. package/scripts/ci/goldens/cjk-80.png +0 -0
  32. package/scripts/ci/goldens/emoji-80.png +0 -0
  33. package/scripts/ci/render-statusline-pty.py +107 -0
  34. package/scripts/ci/render-vhs-goldens.sh +226 -0
  35. package/scripts/ci/statusline.tape +28 -2
  36. package/scripts/ci/vhs-density-fixtures.sh +27 -0
  37. package/scripts/ci/vhs-run-statusline.sh +28 -0
  38. package/scripts/ci/write-density-status.ts +86 -0
  39. package/scripts/gen-emoji-widths.ts +41 -17
  40. package/scripts/paths.sh +3 -1
  41. package/server/art.ts +70 -17
  42. package/server/hooks/buddy-comment.ts +166 -17
  43. package/server/hooks/file-type-react.ts +1 -1
  44. package/server/hooks/mood-react.ts +1 -1
  45. package/server/hooks/name-react.ts +1 -1
  46. package/server/hooks/react.ts +1 -1
  47. package/server/hooks/reaction-data.ts +10 -3
  48. package/server/index.ts +67 -27
  49. package/server/path.ts +3 -0
  50. package/server/state.ts +147 -41
  51. package/skills/buddy/SKILL.md +1 -1
  52. package/statusline/buddy-status.sh +488 -76
  53. package/statusline/emoji-text.data +3 -0
  54. package/statusline/emoji-widths.data +2 -3
  55. package/statusline/substatus.sh +52 -10
  56. package/scripts/postinstall.cjs +0 -42
@@ -1,24 +1,451 @@
1
1
  #!/usr/bin/env bash
2
- # CI golden check for the Claude Code statusline: renders buddy-status.sh
3
- # against the committed fixture state and asserts the output shape.
4
- # Run from the repo root: bash scripts/ci/check-statusline.sh
2
+ # CI golden check for the Claude Code statusline.
3
+ #
4
+ # Primary signal: loud text assertions over a row/column density matrix +
5
+ # CJK / wide-emoji / corrupt-eye fixtures. A broken render fails the build.
6
+ # VHS image goldens are a separate step (scripts/ci/check-vhs-goldens.sh).
7
+ #
8
+ # Run from the repo root:
9
+ # bash scripts/ci/check-statusline.sh
5
10
  set -euo pipefail
6
11
 
12
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
13
+ cd "$ROOT"
14
+
7
15
  fixture_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/statusline-fixture" && pwd)"
16
+ pty_helper="$ROOT/scripts/ci/render-statusline-pty.py"
8
17
  payload='{"session_id":"ci","model":{"display_name":"CI"},"workspace":{"current_dir":"."}}'
9
18
 
10
- start=$(date +%s)
11
- output="$(printf '%s' "$payload" | CLAUDE_CONFIG_DIR="$fixture_dir" bash statusline/buddy-status.sh)"
12
- elapsed=$(( $(date +%s) - start ))
19
+ # Width/row budgets for each density tier. Full frames are 5 art rows + name;
20
+ # compact is 3 art rows + name; minimal is a single sprite/face + name line.
21
+ DENSITY_FULL_MIN=6
22
+ DENSITY_FULL_MAX=8
23
+ DENSITY_COMPACT_MIN=4
24
+ DENSITY_COMPACT_MAX=4
25
+ DENSITY_MINIMAL_MIN=1
26
+ DENSITY_MINIMAL_MAX=1
27
+
28
+ # Claude Code kills statusline scripts around 1s; stay under that budget.
29
+ TIME_BUDGET_MS=${BUDDY_STATUSLINE_TIME_BUDGET_MS:-1000}
30
+
31
+ # Density matrix uses BUDDY_STATUSLINE_ROWS to make CI independent of the
32
+ # ambient terminal size. Auto tier: full >=40 rows, compact 20-39, minimal <20.
33
+ # Very narrow terminal widths force minimal regardless of rows.
34
+ NARROW_COLS=40
35
+
36
+ CLEANUP_DIRS=()
37
+
38
+ fail() {
39
+ echo "FAIL: $1" >&2
40
+ if [ -n "${2:-}" ]; then
41
+ printf '%s\n' "$2" >&2
42
+ fi
43
+ exit 1
44
+ }
45
+
46
+ cleanup() {
47
+ for d in "${CLEANUP_DIRS[@]}"; do
48
+ rm -rf "$d" 2>/dev/null || true
49
+ done
50
+ }
51
+ trap cleanup EXIT
52
+
53
+ add_cleanup() {
54
+ CLEANUP_DIRS+=("$1")
55
+ }
56
+
57
+ # Strip SGR / CSI color sequences. Keep the rest of the glyphs intact so
58
+ # display-width math matches what a terminal would paint.
59
+ strip_ansi() {
60
+ # shellcheck disable=SC2001
61
+ printf '%s' "$1" | sed $'s/\x1b\\[[0-9;]*[A-Za-z]//g'
62
+ }
63
+
64
+ # Display width — mirrors statusline/buddy-status.sh:dwidth and
65
+ # server/art.ts:displayWidth (Emoji_Presentation + CJK + VS16 upgrade).
66
+ EMOJI_WIDTHS_DATA="$ROOT/statusline/emoji-widths.data"
67
+ EMOJI_PRES_2600="$(grep -v '^#' "$EMOJI_WIDTHS_DATA" 2>/dev/null | tr -d '\n' || true)"
68
+
69
+ dwidth() {
70
+ printf '%s' "$1" | iconv -f UTF-8 -t UTF-32LE 2>/dev/null | od -An -tu4 -v | awk -v pres="$EMOJI_PRES_2600" '
71
+ function load_ranges(value, target, n, i, count, bounds, start, end, cp) {
72
+ n = split(value, ranges, ",")
73
+ for (i = 1; i <= n; i++) {
74
+ count = split(ranges[i], bounds, "-")
75
+ start = bounds[1] + 0
76
+ end = (count == 2) ? bounds[2] + 0 : start
77
+ for (cp = start; cp <= end; cp++) target[cp] = 1
78
+ }
79
+ }
80
+ BEGIN {
81
+ load_ranges(pres, wide)
82
+ }
83
+ function char_width(cp) {
84
+ if (cp >= 126976) return 2
85
+ if (cp >= 9728 && cp <= 10175) return (cp in wide) ? 2 : 1
86
+ if (cp >= 9472 && cp <= 9631) return 1
87
+ if (cp >= 12288 && cp <= 40959) return 2
88
+ if (cp >= 65281 && cp <= 65376) return 2
89
+ return 1
90
+ }
91
+ {
92
+ for (i = 1; i <= NF; i++) {
93
+ cp = $i + 0
94
+ if (cp == 65039) {
95
+ if (upgradable) { w += 1; upgradable = 0 }
96
+ continue
97
+ }
98
+ if ((cp >= 65024 && cp <= 65038) || cp == 8205) { upgradable = 0; continue }
99
+ cw = char_width(cp)
100
+ w += cw
101
+ upgradable = (cw == 1 && cp >= 9728 && cp <= 10175) ? 1 : 0
102
+ }
103
+ }
104
+ END { print w + 0 }
105
+ '
106
+ }
107
+
108
+ now_ms() {
109
+ if command -v python3 >/dev/null 2>&1; then
110
+ python3 -c 'import time; print(int(time.time() * 1000))'
111
+ else
112
+ echo $(( $(date +%s) * 1000 ))
113
+ fi
114
+ }
115
+
116
+ # Augment a committed status.json with compactFrames/minimalFrames arrays.
117
+ # Falls back to full frames when the current getStatusFrames() does not yet
118
+ # emit the new arrays, so the CI harness is honest about missing density.
119
+ write_density_status() {
120
+ local src_status="$1"
121
+ local dest_status="$2"
122
+ mkdir -p "$(dirname "$dest_status")"
123
+ bun "$ROOT/scripts/ci/write-density-status.ts" "$dest_status" < "$src_status"
124
+ }
125
+
126
+ # Build a temporary baseline fixture with density-aware status arrays.
127
+ make_baseline_dir() {
128
+ local dest="$1"
129
+ rm -rf "$dest"
130
+ mkdir -p "$dest/buddy-state"
131
+ cp "$fixture_dir/buddy-state/config.json" "$dest/buddy-state/config.json"
132
+ write_density_status "$fixture_dir/buddy-state/status.json" "$dest/buddy-state/status.json"
133
+ }
134
+
135
+ # Render statusline against a fixture dir at a fixed terminal width/rows.
136
+ # Sets globals: OUTPUT, PLAIN, ELAPSED_MS, EFFECTIVE_COLS, EFFECTIVE_ROWS.
137
+ render_at() {
138
+ local source_dir="$1"
139
+ local cols="$2"
140
+ local rows="${3:-50}"
141
+ local density="${4:-auto}"
142
+ local test_dir rendered errfile
143
+ test_dir=$(mktemp -d)
144
+ add_cleanup "$test_dir"
145
+ mkdir -p "$test_dir/buddy-state"
146
+ cp "$source_dir/buddy-state/status.json" "$test_dir/buddy-state/status.json"
147
+ cp "$source_dir/buddy-state/config.json" "$test_dir/buddy-state/config.json"
148
+ for _react in "$source_dir"/buddy-state/reaction.*.json; do
149
+ [ -f "$_react" ] && cp "$_react" "$test_dir/buddy-state/"
150
+ done
151
+
152
+ if [ "$density" != "auto" ]; then
153
+ jq --arg d "$density" '.statuslineDensity = $d' "$test_dir/buddy-state/config.json" \
154
+ > "$test_dir/buddy-state/config.json.tmp" \
155
+ && mv "$test_dir/buddy-state/config.json.tmp" "$test_dir/buddy-state/config.json"
156
+ fi
157
+
158
+ errfile=$(mktemp)
159
+ rendered="$(
160
+ CLAUDE_CODE_SESSION_ID="" \
161
+ TMUX_PANE="" \
162
+ BUDDY_STATUSLINE_ROWS="$rows" \
163
+ STATUSLINE_PAYLOAD="$payload" \
164
+ python3 "$pty_helper" \
165
+ "$cols" \
166
+ "$ROOT" \
167
+ "$test_dir" \
168
+ "$ROOT/statusline/buddy-status.sh" \
169
+ 2>"$errfile"
170
+ )"
171
+ ELAPSED_MS=$(sed -n 's/^STATUSLINE_ELAPSED_MS=//p' "$errfile" | tail -n1)
172
+ rm -f "$errfile"
173
+ case "$ELAPSED_MS" in
174
+ ''|*[!0-9]*) ELAPSED_MS=9999 ;;
175
+ esac
176
+ OUTPUT="$(printf '%s' "$rendered" | tr -d '\r')"
177
+ PLAIN="$(strip_ansi "$OUTPUT")"
178
+ # Assert the real terminal width budget; old <40 special-casing is gone.
179
+ EFFECTIVE_COLS="$cols"
180
+ EFFECTIVE_ROWS="$rows"
181
+ }
182
+
183
+ # Resolve the expected density tier for a (density, rows, cols) tuple.
184
+ expected_tier() {
185
+ local density="${1:-auto}"
186
+ local rows="$2"
187
+ local cols="$3"
188
+ case "$density" in
189
+ full|compact|minimal)
190
+ printf '%s\n' "$density"
191
+ ;;
192
+ *)
193
+ if [ "$rows" -lt 20 ] || [ "$cols" -lt "$NARROW_COLS" ]; then
194
+ printf 'minimal\n'
195
+ elif [ "$rows" -lt 40 ]; then
196
+ printf 'compact\n'
197
+ else
198
+ printf 'full\n'
199
+ fi
200
+ ;;
201
+ esac
202
+ }
203
+
204
+ assert_render() {
205
+ local label="$1"
206
+ local cols="$2"
207
+ local rows="$3"
208
+ local expect_name="$4"
209
+ local density="${5:-auto}"
210
+ local expect_substr="${6:-}"
211
+
212
+ local tier min_lines max_lines line_count max_w w line
213
+ tier="$(expected_tier "$density" "$rows" "$cols")"
214
+ case "$tier" in
215
+ full) min_lines=$DENSITY_FULL_MIN; max_lines=$DENSITY_FULL_MAX ;;
216
+ compact) min_lines=$DENSITY_COMPACT_MIN; max_lines=$DENSITY_COMPACT_MAX ;;
217
+ minimal) min_lines=$DENSITY_MINIMAL_MIN; max_lines=$DENSITY_MINIMAL_MAX ;;
218
+ *) fail "[$label] unknown expected tier '$tier'" "$PLAIN" ;;
219
+ esac
220
+
221
+ line_count=$(printf '%s' "$PLAIN" | awk 'END {print NR}')
222
+
223
+ printf '%s' "$PLAIN" | grep -Fq "$expect_name" \
224
+ || fail "[$label] pet name '$expect_name' missing from output" "$PLAIN"
225
+
226
+ [ "$line_count" -ge "$min_lines" ] && [ "$line_count" -le "$max_lines" ] \
227
+ || fail "[$label] expected $tier line count $min_lines-$max_lines, got $line_count" "$PLAIN"
228
+
229
+ if [ -n "$expect_substr" ]; then
230
+ printf '%s' "$PLAIN" | grep -Fq "$expect_substr" \
231
+ || fail "[$label] expected substring missing: $expect_substr" "$PLAIN"
232
+ fi
233
+
234
+ # Sprite content must stay art, not descriptor words.
235
+ if printf '%s' "$PLAIN" | grep -Eq '<\([[:alpha:]]{2,}'; then
236
+ fail "[$label] sprite looks corrupted (word leaked into eye slot)" "$PLAIN"
237
+ fi
238
+ if printf '%s' "$PLAIN" | grep -Fq '{E}'; then
239
+ fail "[$label] unresolved {E} eye placeholder in output" "$PLAIN"
240
+ fi
241
+
242
+ max_w=0
243
+ while IFS= read -r line || [ -n "$line" ]; do
244
+ [ -z "$line" ] && continue
245
+ w=$(dwidth "$line")
246
+ if [ "$w" -gt "$max_w" ]; then
247
+ max_w=$w
248
+ fi
249
+ if [ "$w" -gt "$cols" ]; then
250
+ fail "[$label] row display width $w exceeds requested cols=$cols: $(printf '%q' "$line")" "$PLAIN"
251
+ fi
252
+ done < <(printf '%s' "$PLAIN")
253
+
254
+ # Bubble box: border rows and text rows must agree in outer width.
255
+ # Bubble and art share a line ("| text |-- <(° ..."), so measure only the
256
+ # box segment. Ignore short art glyphs like the duck feet (\`--') that also
257
+ # start with a backtick.
258
+ local box_w="" trimmed first bubble_only seg_w
259
+ while IFS= read -r line || [ -n "$line" ]; do
260
+ [ -z "$line" ] && continue
261
+ trimmed=$(printf '%s' "$line" | sed $'s/^[ \t\xE2\xA0\x80]*//')
262
+ first=$(printf '%s' "$trimmed" | cut -c1)
263
+ bubble_only=""
264
+ if [ "$first" = "." ]; then
265
+ bubble_only=$(printf '%s' "$trimmed" | sed -n 's/^\(\.[-][-]*\.\).*/\1/p')
266
+ elif [ "$first" = '\`' ]; then
267
+ bubble_only=$(printf '%s' "$trimmed" | sed -n "s/^\\(\\\`[-][-]*'\\).*/\\1/p")
268
+ elif [ "$first" = "|" ]; then
269
+ bubble_only=$(printf '%s' "$trimmed" | sed -n 's/^\(|[^|]*|\).*/\1/p')
270
+ fi
271
+ [ -z "$bubble_only" ] && continue
272
+ seg_w=$(dwidth "$bubble_only")
273
+ if [ "$seg_w" -lt 20 ]; then
274
+ continue
275
+ fi
276
+ if [ "$first" = "|" ]; then
277
+ if [ -n "$box_w" ] && [ "$seg_w" -ne "$box_w" ]; then
278
+ fail "[$label] bubble text width $seg_w != border $box_w" "$PLAIN"
279
+ fi
280
+ else
281
+ if [ -z "$box_w" ]; then
282
+ box_w=$seg_w
283
+ elif [ "$seg_w" -ne "$box_w" ]; then
284
+ fail "[$label] bubble border width $seg_w != $box_w" "$PLAIN"
285
+ fi
286
+ fi
287
+ done < <(printf '%s' "$PLAIN")
288
+
289
+ # Name row must stay within the panel budget. Minimal is a single line, so
290
+ # allow the full terminal width there; full/compact keep the existing panel
291
+ # sanity check.
292
+ local name_line name_w name_limit
293
+ name_line=$(printf '%s' "$PLAIN" | grep -F "$expect_name" | head -n1 || true)
294
+ if [ -n "$name_line" ]; then
295
+ name_w=$(dwidth "$(printf '%s' "$name_line" | sed $'s/^[ \t\xE2\xA0\x80]*//')")
296
+ if [ "$tier" = "minimal" ]; then
297
+ name_limit=$cols
298
+ else
299
+ name_limit=24
300
+ fi
301
+ if [ "$name_w" -gt "$name_limit" ]; then
302
+ fail "[$label] name row display width $name_w exceeds limit $name_limit" "$PLAIN"
303
+ fi
304
+ fi
305
+
306
+ if [ "$ELAPSED_MS" -gt "$TIME_BUDGET_MS" ]; then
307
+ fail "[$label] statusline took ${ELAPSED_MS}ms (budget ${TIME_BUDGET_MS}ms)" "$PLAIN"
308
+ fi
309
+
310
+ echo "OK: $label density=$tier requested_cols=$cols rows=$rows lines=$line_count max_row_w=$max_w time=${ELAPSED_MS}ms name='$expect_name'"
311
+ }
312
+
313
+ # ─── Fixture 1: committed baseline (pikachu / Cobalt) ────────────────────────
314
+ baseline_dir=$(mktemp -d)
315
+ add_cleanup "$baseline_dir"
316
+ make_baseline_dir "$baseline_dir"
317
+ base_name="$(jq -r .name "$baseline_dir/buddy-state/status.json")"
318
+
319
+ # Warm-up (not timed). Claude Code keeps the process hot after the first tick.
320
+ render_at "$baseline_dir" 80 50 auto >/dev/null || true
321
+
322
+ # Row/column density matrix. BUDDY_STATUSLINE_ROWS is always explicit so the
323
+ # results do not depend on the CI runner's terminal size.
324
+ LABELS=(
325
+ "full@80x50"
326
+ "full@120x50"
327
+ "compact@60x30"
328
+ "compact@80x25"
329
+ "minimal@80x10"
330
+ "narrow-minimal@20x50"
331
+ "explicit-full@80x10"
332
+ "explicit-compact@80x50"
333
+ "explicit-minimal@80x50"
334
+ "invalid-normalizes@80x50"
335
+ )
336
+ ROWS=(50 50 30 25 10 50 10 50 50 50)
337
+ COLS=(80 120 60 80 80 20 80 80 80 80)
338
+ DENSITIES=(auto auto auto auto auto auto full compact minimal invalid)
339
+
340
+ for i in "${!LABELS[@]}"; do
341
+ label="${LABELS[$i]}"
342
+ rows="${ROWS[$i]}"
343
+ cols="${COLS[$i]}"
344
+ density="${DENSITIES[$i]}"
345
+ render_at "$baseline_dir" "$cols" "$rows" "$density"
346
+ assert_render "$label" "$cols" "$rows" "$base_name" "$density"
347
+ done
348
+
349
+ # ─── Fixture 2: duck + CJK reaction (real prior breakage) ────────────────────
350
+ cjk_dir=$(mktemp -d)
351
+ add_cleanup "$cjk_dir"
352
+ mkdir -p "$cjk_dir/buddy-state"
353
+ cp "$fixture_dir/buddy-state/config.json" "$cjk_dir/buddy-state/config.json"
354
+
355
+ cat <<'JSON' | bun "$ROOT/scripts/ci/write-density-status.ts" "$cjk_dir/buddy-state/status.json"
356
+ {"name":"Daffodil","species":"duck","rarity":"common","stars":"★","face":"(°>","eye":"°","shiny":false,"hat":"none","reaction":"","muted":false,"achievement":"","level":1,"xp":0,"mood":"focused"}
357
+ JSON
358
+
359
+ _ts=$(now_ms)
360
+ printf '{"reaction":"编译通过了 你好世界","timestamp":%s,"reason":"turn"}\n' "$_ts" \
361
+ > "$cjk_dir/buddy-state/reaction.default.json"
362
+
363
+ # Full width CJK/emoji bubble checks.
364
+ for cols in 80 93 120 200; do
365
+ render_at "$cjk_dir" "$cols" 50 auto
366
+ assert_render "cjk@${cols}" "$cols" 50 "Daffodil" auto "你好"
367
+ printf '%s' "$PLAIN" | grep -Fq '<(°' \
368
+ || fail "[cjk@${cols}] duck eye row missing degree-sign glyph" "$PLAIN"
369
+ done
370
+
371
+ # Minimal sanity: name stays visible and nothing escapes the narrow budget.
372
+ render_at "$cjk_dir" 20 10 auto
373
+ assert_render "cjk-minimal@20x10" 20 10 "Daffodil" auto
374
+
375
+ # ─── Fixture 3: wide-emoji reaction (VS16 / presentation) ────────────────────
376
+ emoji_dir=$(mktemp -d)
377
+ add_cleanup "$emoji_dir"
378
+ mkdir -p "$emoji_dir/buddy-state"
379
+ cp "$cjk_dir/buddy-state/status.json" "$emoji_dir/buddy-state/status.json"
380
+ cp "$fixture_dir/buddy-state/config.json" "$emoji_dir/buddy-state/config.json"
381
+ _ts=$(now_ms)
382
+ printf '{"reaction":"ship it 🎉 ❤️ ✨","timestamp":%s,"reason":"success"}\n' "$_ts" \
383
+ > "$emoji_dir/buddy-state/reaction.default.json"
384
+
385
+ for cols in 80 93 120 200; do
386
+ render_at "$emoji_dir" "$cols" 50 auto
387
+ assert_render "emoji@${cols}" "$cols" 50 "Daffodil" auto "🎉"
388
+ done
389
+
390
+ render_at "$emoji_dir" 20 10 auto
391
+ assert_render "emoji-minimal@20x10" 20 10 "Daffodil" auto
392
+
393
+ # ─── Fixture 4: corrupt eye descriptor must not reach the sprite ─────────────
394
+ # Regression for the live "<(normal ..." bug: even if bones.eye is the word
395
+ # "normal", getStatusFrames must bake a real glyph across every density array.
396
+ corrupt_dir=$(mktemp -d)
397
+ add_cleanup "$corrupt_dir"
398
+ mkdir -p "$corrupt_dir/buddy-state"
399
+ cp "$fixture_dir/buddy-state/config.json" "$corrupt_dir/buddy-state/config.json"
400
+
401
+ cat <<'JSON' | bun "$ROOT/scripts/ci/write-density-status.ts" "$corrupt_dir/buddy-state/status.json"
402
+ {"name":"Daffodil","species":"duck","rarity":"common","stars":"★","face":"(°>","eye":"normal","shiny":false,"hat":"none","reaction":"","muted":false,"achievement":"","level":1,"xp":0,"mood":"focused"}
403
+ JSON
404
+
405
+ # Ensure the corrupt eye descriptor was sanitized in every density array.
406
+ bun -e "
407
+ import { readFileSync } from 'fs';
408
+ const status = JSON.parse(readFileSync(process.argv[1], 'utf8'));
409
+ for (const key of ['frames', 'compactFrames', 'minimalFrames']) {
410
+ const arr = status[key];
411
+ if (!arr) continue;
412
+ for (const body of arr) {
413
+ if (body.includes('normal')) {
414
+ console.error('getStatusFrames leaked descriptor into', key, ':', body);
415
+ process.exit(2);
416
+ }
417
+ if (body.includes('{E}')) {
418
+ console.error('unresolved {E} placeholder in', key);
419
+ process.exit(2);
420
+ }
421
+ }
422
+ }
423
+ " "$corrupt_dir/buddy-state/status.json"
424
+
425
+ render_at "$corrupt_dir" 80 50 auto
426
+ assert_render "corrupt-eye@80" 80 50 "Daffodil" auto
427
+ if printf '%s' "$PLAIN" | grep -Fq 'normal'; then
428
+ fail "[corrupt-eye@80] literal normal appeared in render" "$PLAIN"
429
+ fi
430
+ printf '%s' "$PLAIN" | grep -Fq '<(°' \
431
+ || fail "[corrupt-eye@80] expected sanitized degree-sign eye" "$PLAIN"
13
432
 
14
- plain="$(printf '%s' "$output" | sed $'s/\x1b\[[0-9;]*m//g')"
15
- lines=$(printf '%s\n' "$plain" | wc -l | tr -d ' ')
433
+ render_at "$corrupt_dir" 20 10 auto
434
+ assert_render "corrupt-eye-minimal@20x10" 20 10 "Daffodil" auto
16
435
 
17
- fail() { echo "FAIL: $1" >&2; printf '%s\n' "$plain" >&2; exit 1; }
436
+ # ─── Fixture 5: row override precedence smoke test ───────────────────────────
437
+ # PTY rows are set to 50 by the helper, but BUDDY_STATUSLINE_ROWS=10 forces
438
+ # minimal selection. This proves the env var wins over the PTY probe.
439
+ render_at "$baseline_dir" 80 50 auto
440
+ # Sanity: without the override the previous rows=50 call already exercised full.
441
+ # Now pin minimal with the same terminal rows to assert override behavior.
442
+ render_at "$baseline_dir" 80 10 auto
443
+ assert_render "row-override@80x10" 80 10 "$base_name" auto
18
444
 
19
- name="$(jq -r .name "$fixture_dir/buddy-state/status.json")"
20
- printf '%s' "$plain" | grep -q "$name" || fail "pet name '$name' missing from output"
21
- [ "$lines" -ge 4 ] || fail "expected >=4 output lines, got $lines"
22
- [ "$elapsed" -le 10 ] || fail "statusline took ${elapsed}s (budget 10s)"
445
+ # ─── Fixture 6: width invariants at the historic narrow width ──────────────────
446
+ # COLUMNS=20 used to be special-cased; the new density path must keep the name
447
+ # and sprite intact while honoring the actual 20-column budget.
448
+ render_at "$baseline_dir" 20 50 auto
449
+ assert_render "narrow-width@20x50" 20 50 "$base_name" auto
23
450
 
24
- echo "OK: statusline rendered $lines lines with '$name' in ${elapsed}s"
451
+ echo "OK: all statusline golden assertions passed"
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env bash
2
+ # Compare freshly rendered VHS PNGs against committed goldens.
3
+ #
4
+ # Usage (from repo root, after render-vhs-goldens.sh):
5
+ # bash scripts/ci/check-vhs-goldens.sh
6
+ #
7
+ # To re-baseline intentionally (MUST use the linux/amd64 container path):
8
+ # bash scripts/ci/render-vhs-goldens.sh --update
9
+ # # or: bun run ci:vhs:update
10
+ # Never re-baseline from host macOS vhs — CI will fail on font SSIM.
11
+ set -euo pipefail
12
+
13
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
14
+ OUT_DIR="$ROOT/scripts/ci/vhs-out"
15
+ GOLDEN_DIR="$ROOT/scripts/ci/goldens"
16
+
17
+ if [ ! -d "$GOLDEN_DIR" ]; then
18
+ echo "FAIL: missing $GOLDEN_DIR — run: bash scripts/ci/render-vhs-goldens.sh --update" >&2
19
+ exit 1
20
+ fi
21
+
22
+ if [ ! -d "$OUT_DIR" ]; then
23
+ echo "FAIL: missing $OUT_DIR — run render-vhs-goldens.sh first" >&2
24
+ exit 1
25
+ fi
26
+
27
+ shopt -s nullglob
28
+ goldens=("$GOLDEN_DIR"/*.png)
29
+ if [ ${#goldens[@]} -eq 0 ]; then
30
+ echo "FAIL: no golden PNGs in $GOLDEN_DIR" >&2
31
+ exit 1
32
+ fi
33
+
34
+ fail=0
35
+ for golden in "${goldens[@]}"; do
36
+ base=$(basename "$golden")
37
+ got="$OUT_DIR/$base"
38
+ if [ ! -f "$got" ]; then
39
+ echo "FAIL: rendered output missing for golden $base" >&2
40
+ fail=1
41
+ continue
42
+ fi
43
+
44
+ # Exact byte identity is ideal; fall back to a tight perceptual threshold
45
+ # via ffmpeg's ssim if metadata/filters differ across ffmpeg builds.
46
+ if cmp -s "$golden" "$got"; then
47
+ echo "OK: $base (byte-identical)"
48
+ continue
49
+ fi
50
+
51
+ # Dimension check first — a size drift is always a real regression.
52
+ read -r gw gh < <(ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0:s=x "$golden")
53
+ read -r ow oh < <(ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0:s=x "$got")
54
+ if [ "$gw" != "$ow" ] || [ "$gh" != "$oh" ]; then
55
+ echo "FAIL: $base size golden=${gw}x${gh} got=${ow}x${oh}" >&2
56
+ fail=1
57
+ continue
58
+ fi
59
+
60
+ # SSIM mean across the frame. 0.99+ is effectively identical for terminal
61
+ # screenshots; lower means glyphs/spacing shifted.
62
+ ssim_line=$(ffmpeg -v error -i "$golden" -i "$got" -lavfi ssim=stats_file=/dev/stdout -f null - 2>/dev/null | tail -n1 || true)
63
+ # stats look like: n:1 Y:0.998 U:0.999 V:0.999 All:0.998 (inf)
64
+ ssim_all=$(printf '%s' "$ssim_line" | sed -n 's/.*All:\([0-9.]*\).*/\1/p')
65
+ if [ -z "$ssim_all" ]; then
66
+ echo "FAIL: $base differs and ssim could not be computed" >&2
67
+ echo " golden: $golden" >&2
68
+ echo " got: $got" >&2
69
+ fail=1
70
+ continue
71
+ fi
72
+ ok=$(awk -v s="$ssim_all" 'BEGIN { print (s + 0 >= 0.99) ? "yes" : "no" }')
73
+ if [ "$ok" = "yes" ]; then
74
+ echo "OK: $base (ssim All=$ssim_all)"
75
+ else
76
+ echo "FAIL: $base visual regression (ssim All=$ssim_all < 0.99)" >&2
77
+ echo " golden: $golden" >&2
78
+ echo " got: $got" >&2
79
+ echo " re-baseline (linux/amd64 container only):" >&2
80
+ echo " bash scripts/ci/render-vhs-goldens.sh --update" >&2
81
+ fail=1
82
+ fi
83
+ done
84
+
85
+ # Every rendered PNG that we care about should have a golden.
86
+ for got in "$OUT_DIR"/*.png; do
87
+ base=$(basename "$got")
88
+ if [ ! -f "$GOLDEN_DIR/$base" ]; then
89
+ echo "FAIL: rendered $base has no committed golden (forget to --update?)" >&2
90
+ fail=1
91
+ fi
92
+ done
93
+
94
+ if [ "$fail" -ne 0 ]; then
95
+ exit 1
96
+ fi
97
+ echo "OK: all vhs goldens matched"
@@ -0,0 +1,37 @@
1
+ <?xml version="1.0"?>
2
+ <!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
3
+ <!-- Stable font selection for VHS goldens. Keep in lockstep with Dockerfile.vhs. -->
4
+ <fontconfig>
5
+ <alias>
6
+ <family>monospace</family>
7
+ <prefer>
8
+ <family>DejaVu Sans Mono</family>
9
+ <family>Noto Sans Mono</family>
10
+ <family>Noto Sans Mono CJK SC</family>
11
+ <family>Noto Color Emoji</family>
12
+ </prefer>
13
+ </alias>
14
+
15
+ <alias>
16
+ <family>DejaVu Sans Mono</family>
17
+ <prefer>
18
+ <family>DejaVu Sans Mono</family>
19
+ </prefer>
20
+ </alias>
21
+
22
+ <!-- Prefer color emoji glyphs over monochrome fallbacks. -->
23
+ <match target="pattern">
24
+ <test name="family"><string>emoji</string></test>
25
+ <edit name="family" mode="prepend" binding="strong">
26
+ <string>Noto Color Emoji</string>
27
+ </edit>
28
+ </match>
29
+
30
+ <!-- CJK: route Han to Noto CJK SC so 编译/你好 paint deterministically. -->
31
+ <match target="pattern">
32
+ <test name="lang"><string>zh-cn</string></test>
33
+ <edit name="family" mode="prepend" binding="strong">
34
+ <string>Noto Sans CJK SC</string>
35
+ </edit>
36
+ </match>
37
+ </fontconfig>
Binary file
Binary file