@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.
- package/README.md +23 -4
- package/adapters/omp/context.ts +1 -1
- package/adapters/omp/events.ts +286 -81
- package/adapters/omp/index.ts +3 -1
- package/adapters/omp/renderers.ts +4 -54
- package/adapters/omp/storage.ts +6 -1
- package/adapters/omp/ui.ts +17 -6
- package/adapters/pi/events.ts +280 -83
- package/adapters/pi/index.ts +11 -2
- package/adapters/pi/renderers.ts +3 -47
- package/adapters/pi/storage.ts +6 -1
- package/adapters/pi/ui.ts +17 -6
- package/adapters/shared/file-storage.ts +22 -0
- package/adapters/shared/widget-layout.ts +248 -2
- package/cli/settings.ts +1 -1
- package/cli/tui.tsx +1 -1
- package/core/command-service.ts +13 -0
- package/core/engine.ts +11 -2
- package/core/identity.ts +15 -0
- package/core/model.ts +3 -0
- package/core/ports.ts +4 -0
- package/core/reactions.ts +29 -2
- package/core/render-model.ts +4 -3
- package/package.json +6 -3
- package/scripts/ci/Dockerfile.vhs +125 -0
- package/scripts/ci/check-statusline.sh +441 -14
- package/scripts/ci/check-vhs-goldens.sh +97 -0
- package/scripts/ci/fonts.conf +37 -0
- package/scripts/ci/goldens/baseline-40.png +0 -0
- package/scripts/ci/goldens/baseline-80.png +0 -0
- package/scripts/ci/goldens/cjk-80.png +0 -0
- package/scripts/ci/goldens/emoji-80.png +0 -0
- package/scripts/ci/render-statusline-pty.py +107 -0
- package/scripts/ci/render-vhs-goldens.sh +226 -0
- package/scripts/ci/statusline.tape +28 -2
- package/scripts/ci/vhs-density-fixtures.sh +27 -0
- package/scripts/ci/vhs-run-statusline.sh +28 -0
- package/scripts/ci/write-density-status.ts +86 -0
- package/scripts/gen-emoji-widths.ts +41 -17
- package/scripts/paths.sh +3 -1
- package/server/art.ts +70 -17
- package/server/hooks/buddy-comment.ts +166 -17
- package/server/hooks/file-type-react.ts +1 -1
- package/server/hooks/mood-react.ts +1 -1
- package/server/hooks/name-react.ts +1 -1
- package/server/hooks/react.ts +1 -1
- package/server/hooks/reaction-data.ts +10 -3
- package/server/index.ts +67 -27
- package/server/path.ts +3 -0
- package/server/state.ts +147 -41
- package/skills/buddy/SKILL.md +1 -1
- package/statusline/buddy-status.sh +488 -76
- package/statusline/emoji-text.data +3 -0
- package/statusline/emoji-widths.data +2 -3
- package/statusline/substatus.sh +52 -10
- 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
|
|
3
|
-
#
|
|
4
|
-
#
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
433
|
+
render_at "$corrupt_dir" 20 10 auto
|
|
434
|
+
assert_render "corrupt-eye-minimal@20x10" 20 10 "Daffodil" auto
|
|
16
435
|
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
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
|
|
Binary file
|
|
Binary file
|