qualia-framework-v2 2.8.0 → 2.9.0

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.
@@ -82,33 +82,88 @@ assert_exit "allows safe ALTER TABLE" 0 $?
82
82
  echo '{"tool_input":{"file_path":"src/app.tsx","content":"DROP TABLE users;"}}' | $NODE "$HOOKS_DIR/migration-guard.js" > /dev/null 2>&1
83
83
  assert_exit "skips non-migration files" 0 $?
84
84
 
85
- # --- branch-guard.js (grep-basedfull run needs git + real config) ---
85
+ # --- branch-guard.js (behavioralreal git repo + real config file) ---
86
86
  echo ""
87
87
  echo "branch-guard:"
88
88
 
89
- if grep -q '.qualia-config.json' "$HOOKS_DIR/branch-guard.js"; then
90
- echo " ✓ reads role from .qualia-config.json"
91
- PASS=$((PASS + 1))
92
- else
93
- echo " not reading from .qualia-config.json"
94
- FAIL=$((FAIL + 1))
95
- fi
89
+ # setup_guard_repo <branch> <role> prints absolute path to a fresh tmp dir
90
+ # containing a git repo (checked out to <branch>) and a
91
+ # .claude/.qualia-config.json with {"role":"<role>"}. Caller must `rm -rf`.
92
+ setup_guard_repo() {
93
+ local branch="$1" role="$2"
94
+ local tmp
95
+ tmp=$(mktemp -d)
96
+ mkdir -p "$tmp/proj" "$tmp/.claude"
97
+ (cd "$tmp/proj" \
98
+ && git init -q \
99
+ && git checkout -b "$branch" -q 2>/dev/null)
100
+ printf '{"role":"%s"}\n' "$role" > "$tmp/.claude/.qualia-config.json"
101
+ echo "$tmp"
102
+ }
96
103
 
97
- if grep -q 'branch --show-current' "$HOOKS_DIR/branch-guard.js"; then
98
- echo " ✓ checks current git branch"
99
- PASS=$((PASS + 1))
100
- else
101
- echo " ✗ missing branch check"
102
- FAIL=$((FAIL + 1))
103
- fi
104
+ # OWNER on main allowed (exit 0)
105
+ TMP=$(setup_guard_repo main OWNER)
106
+ (cd "$TMP/proj" && HOME="$TMP" $NODE "$HOOKS_DIR/branch-guard.js" >/dev/null 2>&1)
107
+ assert_exit "OWNER on main → allowed" 0 $?
108
+ rm -rf "$TMP"
104
109
 
105
- if grep -q 'OWNER' "$HOOKS_DIR/branch-guard.js"; then
106
- echo " ✓ enforces OWNER role"
110
+ # EMPLOYEE on main blocked (exit 1)
111
+ TMP=$(setup_guard_repo main EMPLOYEE)
112
+ OUT=$(cd "$TMP/proj" && HOME="$TMP" $NODE "$HOOKS_DIR/branch-guard.js" 2>&1)
113
+ RC=$?
114
+ if [ "$RC" -eq 1 ] && echo "$OUT" | grep -q "BLOCKED" && echo "$OUT" | grep -q "main"; then
115
+ echo " ✓ EMPLOYEE on main → blocked (BLOCKED in stdout)"
107
116
  PASS=$((PASS + 1))
108
117
  else
109
- echo " ✗ missing OWNER check"
118
+ echo " ✗ EMPLOYEE on main → blocked (exit=$RC)"
110
119
  FAIL=$((FAIL + 1))
111
120
  fi
121
+ rm -rf "$TMP"
122
+
123
+ # EMPLOYEE on master → blocked
124
+ TMP=$(setup_guard_repo master EMPLOYEE)
125
+ (cd "$TMP/proj" && HOME="$TMP" $NODE "$HOOKS_DIR/branch-guard.js" >/dev/null 2>&1)
126
+ assert_exit "EMPLOYEE on master → blocked" 1 $?
127
+ rm -rf "$TMP"
128
+
129
+ # EMPLOYEE on feature branch → allowed
130
+ TMP=$(setup_guard_repo feature/xyz EMPLOYEE)
131
+ (cd "$TMP/proj" && HOME="$TMP" $NODE "$HOOKS_DIR/branch-guard.js" >/dev/null 2>&1)
132
+ assert_exit "EMPLOYEE on feature/xyz → allowed" 0 $?
133
+ rm -rf "$TMP"
134
+
135
+ # OWNER on feature branch → allowed
136
+ TMP=$(setup_guard_repo feature/xyz OWNER)
137
+ (cd "$TMP/proj" && HOME="$TMP" $NODE "$HOOKS_DIR/branch-guard.js" >/dev/null 2>&1)
138
+ assert_exit "OWNER on feature/xyz → allowed" 0 $?
139
+ rm -rf "$TMP"
140
+
141
+ # Missing config → fails closed (block, exit 1)
142
+ TMP=$(mktemp -d)
143
+ mkdir -p "$TMP/proj"
144
+ (cd "$TMP/proj" && git init -q && git checkout -b feature/x -q 2>/dev/null)
145
+ # NO .claude/.qualia-config.json
146
+ (cd "$TMP/proj" && HOME="$TMP" $NODE "$HOOKS_DIR/branch-guard.js" >/dev/null 2>&1)
147
+ assert_exit "missing config → blocked (fails closed)" 1 $?
148
+ rm -rf "$TMP"
149
+
150
+ # Malformed config JSON → fails closed
151
+ TMP=$(mktemp -d)
152
+ mkdir -p "$TMP/proj" "$TMP/.claude"
153
+ (cd "$TMP/proj" && git init -q && git checkout -b feature/x -q 2>/dev/null)
154
+ echo 'not json{' > "$TMP/.claude/.qualia-config.json"
155
+ (cd "$TMP/proj" && HOME="$TMP" $NODE "$HOOKS_DIR/branch-guard.js" >/dev/null 2>&1)
156
+ assert_exit "malformed config JSON → blocked" 1 $?
157
+ rm -rf "$TMP"
158
+
159
+ # Empty role field → fails closed
160
+ TMP=$(mktemp -d)
161
+ mkdir -p "$TMP/proj" "$TMP/.claude"
162
+ (cd "$TMP/proj" && git init -q && git checkout -b feature/x -q 2>/dev/null)
163
+ echo '{"role":""}' > "$TMP/.claude/.qualia-config.json"
164
+ (cd "$TMP/proj" && HOME="$TMP" $NODE "$HOOKS_DIR/branch-guard.js" >/dev/null 2>&1)
165
+ assert_exit "empty role field → blocked" 1 $?
166
+ rm -rf "$TMP"
112
167
 
113
168
  # --- pre-push.js ---
114
169
  echo ""
@@ -136,25 +191,100 @@ TMP=$(mktemp -d)
136
191
  assert_exit "exits 0 with no tracking.json" 0 $?
137
192
  rm -rf "$TMP"
138
193
 
139
- # --- pre-deploy-gate.js ---
194
+ # --- pre-deploy-gate.js (behavioral — real project trees) ---
140
195
  echo ""
141
196
  echo "pre-deploy-gate:"
142
197
 
143
- if grep -q 'tsc' "$HOOKS_DIR/pre-deploy-gate.js"; then
144
- echo " ✓ runs TypeScript check"
198
+ # Empty project (no package.json, no tsconfig) → nothing to gate → exit 0
199
+ TMP=$(mktemp -d)
200
+ (cd "$TMP" && $NODE "$HOOKS_DIR/pre-deploy-gate.js" >/dev/null 2>&1)
201
+ assert_exit "empty project → exit 0 (no gates to run)" 0 $?
202
+ rm -rf "$TMP"
203
+
204
+ # No tsconfig → TypeScript gate skipped → exit 0 (only security scan runs)
205
+ TMP=$(mktemp -d)
206
+ mkdir -p "$TMP/src"
207
+ echo 'export const x = 1;' > "$TMP/src/app.ts"
208
+ (cd "$TMP" && $NODE "$HOOKS_DIR/pre-deploy-gate.js" >/dev/null 2>&1)
209
+ assert_exit "no tsconfig → TS gate skipped → exit 0" 0 $?
210
+ rm -rf "$TMP"
211
+
212
+ # service_role literal in app/ → BLOCKED with diagnostic
213
+ TMP=$(mktemp -d)
214
+ mkdir -p "$TMP/app"
215
+ cat > "$TMP/app/page.tsx" <<'EOF'
216
+ const key = "service_role_literal_leak";
217
+ export default function P(){return null}
218
+ EOF
219
+ OUT=$(cd "$TMP" && $NODE "$HOOKS_DIR/pre-deploy-gate.js" 2>&1)
220
+ RC=$?
221
+ if [ "$RC" -eq 1 ] \
222
+ && echo "$OUT" | grep -q "BLOCKED" \
223
+ && echo "$OUT" | grep -q "service_role"; then
224
+ echo " ✓ service_role leak in app/ → blocked with diagnostic"
145
225
  PASS=$((PASS + 1))
146
226
  else
147
- echo " ✗ missing TypeScript check"
227
+ echo " ✗ service_role leak in app/ → blocked (exit=$RC)"
148
228
  FAIL=$((FAIL + 1))
149
229
  fi
230
+ rm -rf "$TMP"
231
+
232
+ # service_role leak in components/ → BLOCKED
233
+ TMP=$(mktemp -d)
234
+ mkdir -p "$TMP/components"
235
+ cat > "$TMP/components/Widget.tsx" <<'EOF'
236
+ const key = "service_role_literal_leak";
237
+ EOF
238
+ (cd "$TMP" && $NODE "$HOOKS_DIR/pre-deploy-gate.js" >/dev/null 2>&1)
239
+ assert_exit "service_role in components/ → blocked" 1 $?
240
+ rm -rf "$TMP"
241
+
242
+ # service_role in a *.server.ts file → allowed (skip convention)
243
+ TMP=$(mktemp -d)
244
+ mkdir -p "$TMP/app/api"
245
+ cat > "$TMP/app/api/route.server.ts" <<'EOF'
246
+ const key = "service_role_legit_server_key";
247
+ EOF
248
+ (cd "$TMP" && $NODE "$HOOKS_DIR/pre-deploy-gate.js" >/dev/null 2>&1)
249
+ assert_exit ".server.ts is exempt from service_role scan" 0 $?
250
+ rm -rf "$TMP"
251
+
252
+ # service_role inside a server/ directory → allowed
253
+ TMP=$(mktemp -d)
254
+ mkdir -p "$TMP/app/server"
255
+ cat > "$TMP/app/server/admin.ts" <<'EOF'
256
+ const key = "service_role_legit_server_dir";
257
+ EOF
258
+ (cd "$TMP" && $NODE "$HOOKS_DIR/pre-deploy-gate.js" >/dev/null 2>&1)
259
+ assert_exit "files under server/ are exempt from service_role scan" 0 $?
260
+ rm -rf "$TMP"
150
261
 
151
- if grep -q 'service_role' "$HOOKS_DIR/pre-deploy-gate.js"; then
152
- echo " ✓ checks for service_role leaks"
262
+ # node_modules and dotdirs are NOT walked — a leak inside them must not block
263
+ TMP=$(mktemp -d)
264
+ mkdir -p "$TMP/app/node_modules/evil"
265
+ cat > "$TMP/app/node_modules/evil/index.ts" <<'EOF'
266
+ const key = "service_role_in_node_modules";
267
+ EOF
268
+ (cd "$TMP" && $NODE "$HOOKS_DIR/pre-deploy-gate.js" >/dev/null 2>&1)
269
+ assert_exit "node_modules not walked (leak ignored)" 0 $?
270
+ rm -rf "$TMP"
271
+
272
+ # Clean project (no leaks anywhere) → passes security gate → exit 0
273
+ TMP=$(mktemp -d)
274
+ mkdir -p "$TMP/app" "$TMP/components" "$TMP/lib"
275
+ echo 'export const a = 1;' > "$TMP/app/page.tsx"
276
+ echo 'export const b = 2;' > "$TMP/components/Widget.tsx"
277
+ echo 'export const c = 3;' > "$TMP/lib/util.ts"
278
+ OUT=$(cd "$TMP" && $NODE "$HOOKS_DIR/pre-deploy-gate.js" 2>&1)
279
+ RC=$?
280
+ if [ "$RC" -eq 0 ] && echo "$OUT" | grep -q "All gates passed"; then
281
+ echo " ✓ clean project → all gates pass → exit 0"
153
282
  PASS=$((PASS + 1))
154
283
  else
155
- echo " ✗ missing service_role check"
284
+ echo " ✗ clean project → all gates pass (exit=$RC)"
156
285
  FAIL=$((FAIL + 1))
157
286
  fi
287
+ rm -rf "$TMP"
158
288
 
159
289
  # --- session-start.js — must exit 0 always ---
160
290
  echo ""
@@ -392,6 +392,143 @@ else
392
392
  fail_case "--to activity" "exit=$EXIT out=$OUT"
393
393
  fi
394
394
 
395
+ # ─── Parse schema errors ─────────────────────────────────
396
+ echo ""
397
+ echo "parse schema errors:"
398
+
399
+ # 21. Well-formed STATE.md: no schema_errors field in check output
400
+ TMP=$(make_project)
401
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" check 2>&1)
402
+ EXIT=$?
403
+ if [ "$EXIT" -eq 0 ] \
404
+ && echo "$OUT" | grep -q '"ok": true' \
405
+ && ! echo "$OUT" | grep -q 'schema_errors'; then
406
+ pass "well-formed STATE.md: check has no schema_errors"
407
+ else
408
+ fail_case "well-formed no schema_errors" "exit=$EXIT out=$OUT"
409
+ fi
410
+
411
+ # 22. Missing Phase: header → schema_errors with phase_header (error)
412
+ TMP=$(make_project)
413
+ sed -i.bak '/^Phase:/d' "$TMP/.planning/STATE.md"
414
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" check 2>&1)
415
+ EXIT=$?
416
+ if [ "$EXIT" -eq 0 ] \
417
+ && echo "$OUT" | grep -q 'schema_errors' \
418
+ && echo "$OUT" | grep -q 'phase_header'; then
419
+ pass "missing Phase: header → schema_errors contains phase_header"
420
+ else
421
+ fail_case "missing phase header" "exit=$EXIT out=$OUT"
422
+ fi
423
+
424
+ # 23. Missing roadmap table header → schema_errors with roadmap_table
425
+ TMP=$(make_project)
426
+ sed -i.bak '/^| # | Phase | Goal | Status |$/d' "$TMP/.planning/STATE.md"
427
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" check 2>&1)
428
+ EXIT=$?
429
+ if [ "$EXIT" -eq 0 ] \
430
+ && echo "$OUT" | grep -q 'schema_errors' \
431
+ && echo "$OUT" | grep -q 'roadmap_table'; then
432
+ pass "missing roadmap table → schema_errors contains roadmap_table"
433
+ else
434
+ fail_case "missing roadmap_table" "exit=$EXIT out=$OUT"
435
+ fi
436
+
437
+ # 24. Missing Status: line → schema_errors warning status_field, ok:true
438
+ TMP=$(make_project)
439
+ sed -i.bak '/^Status:/d' "$TMP/.planning/STATE.md"
440
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" check 2>&1)
441
+ EXIT=$?
442
+ if [ "$EXIT" -eq 0 ] \
443
+ && echo "$OUT" | grep -q '"ok": true' \
444
+ && echo "$OUT" | grep -q 'schema_errors' \
445
+ && echo "$OUT" | grep -q 'status_field' \
446
+ && echo "$OUT" | grep -q '"severity": "warning"'; then
447
+ pass "missing Status: → warning status_field, ok:true"
448
+ else
449
+ fail_case "missing Status field" "exit=$EXIT out=$OUT"
450
+ fi
451
+
452
+ # 25. Roadmap row count mismatch → schema_errors warning roadmap_rows
453
+ # Hand-edit header to claim 3 phases when only 2 rows exist.
454
+ TMP=$(make_project)
455
+ sed -i.bak 's/^Phase: 1 of 2 — Foundation/Phase: 1 of 3 — Foundation/' "$TMP/.planning/STATE.md"
456
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" check 2>&1)
457
+ EXIT=$?
458
+ if [ "$EXIT" -eq 0 ] \
459
+ && echo "$OUT" | grep -q 'schema_errors' \
460
+ && echo "$OUT" | grep -q 'roadmap_rows'; then
461
+ pass "roadmap row count mismatch → warning roadmap_rows"
462
+ else
463
+ fail_case "roadmap row count mismatch" "exit=$EXIT out=$OUT"
464
+ fi
465
+
466
+ # 26. Transition refuses on severity=error (missing Phase: header)
467
+ TMP=$(make_project)
468
+ touch "$TMP/.planning/phase-1-plan.md"
469
+ sed -i.bak '/^Phase:/d' "$TMP/.planning/STATE.md"
470
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" transition --to planned 2>&1)
471
+ EXIT=$?
472
+ if [ "$EXIT" -eq 1 ] \
473
+ && echo "$OUT" | grep -q '"error": "STATE_SCHEMA_ERROR"'; then
474
+ pass "transition refused on severity=error (STATE_SCHEMA_ERROR)"
475
+ else
476
+ fail_case "transition STATE_SCHEMA_ERROR" "exit=$EXIT out=$OUT"
477
+ fi
478
+
479
+ # 27. fix rewrites malformed STATE.md into canonical form
480
+ TMP=$(make_project)
481
+ sed -i.bak '/^Phase:/d' "$TMP/.planning/STATE.md"
482
+ # Confirm it's broken first
483
+ (cd "$TMP" && $NODE "$STATE_JS" check 2>&1 | grep -q schema_errors) || \
484
+ fail_case "fix pretest: check should show errors"
485
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" fix 2>&1)
486
+ EXIT=$?
487
+ OUT2=$(cd "$TMP" && $NODE "$STATE_JS" check 2>&1)
488
+ if [ "$EXIT" -eq 0 ] \
489
+ && echo "$OUT" | grep -q '"action": "fix"' \
490
+ && echo "$OUT" | grep -q '"fixed": true' \
491
+ && echo "$OUT" | grep -q '"previous_errors": 1' \
492
+ && ! echo "$OUT2" | grep -q 'schema_errors'; then
493
+ pass "fix repairs malformed STATE.md"
494
+ else
495
+ fail_case "fix repair" "exit=$EXIT fix=$OUT check=$OUT2"
496
+ fi
497
+
498
+ # 28. fix on well-formed STATE.md is a no-op (still parses clean)
499
+ TMP=$(make_project)
500
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" fix 2>&1)
501
+ EXIT=$?
502
+ OUT2=$(cd "$TMP" && $NODE "$STATE_JS" check 2>&1)
503
+ if [ "$EXIT" -eq 0 ] \
504
+ && echo "$OUT" | grep -q '"action": "fix"' \
505
+ && echo "$OUT" | grep -q '"previous_errors": 0' \
506
+ && ! echo "$OUT2" | grep -q 'schema_errors' \
507
+ && echo "$OUT2" | grep -q '"phase": 1' \
508
+ && echo "$OUT2" | grep -q '"total_phases": 2'; then
509
+ pass "fix on well-formed STATE.md is idempotent"
510
+ else
511
+ fail_case "fix idempotent" "exit=$EXIT fix=$OUT check=$OUT2"
512
+ fi
513
+
514
+ # 29. After fix, transition that was previously blocked now works
515
+ TMP=$(make_project)
516
+ touch "$TMP/.planning/phase-1-plan.md"
517
+ sed -i.bak '/^Phase:/d' "$TMP/.planning/STATE.md"
518
+ # Blocked before fix
519
+ (cd "$TMP" && $NODE "$STATE_JS" transition --to planned 2>&1 | grep -q STATE_SCHEMA_ERROR) || \
520
+ fail_case "fix unblock pretest: should be blocked"
521
+ (cd "$TMP" && $NODE "$STATE_JS" fix >/dev/null 2>&1)
522
+ OUT=$(cd "$TMP" && $NODE "$STATE_JS" transition --to planned 2>&1)
523
+ EXIT=$?
524
+ if [ "$EXIT" -eq 0 ] \
525
+ && echo "$OUT" | grep -q '"ok": true' \
526
+ && echo "$OUT" | grep -q '"status": "planned"'; then
527
+ pass "after fix, blocked transition succeeds"
528
+ else
529
+ fail_case "after fix transition" "exit=$EXIT out=$OUT"
530
+ fi
531
+
395
532
  # ─── Summary ─────────────────────────────────────────────
396
533
  echo ""
397
534
  echo "=== Results: $PASS passed, $FAIL failed ==="
@@ -0,0 +1,243 @@
1
+ #!/bin/bash
2
+ # Qualia Framework v2 — statusline.js behavioral tests
3
+ # Run: bash tests/statusline.test.sh
4
+ #
5
+ # Strategy: statusline.js reads a single JSON blob from stdin and prints two
6
+ # ANSI-formatted lines to stdout. We pipe JSON, capture stdout, and assert on
7
+ # exit code + presence of expected substrings (including raw ANSI escape codes
8
+ # for color assertions).
9
+ #
10
+ # Workspace dirs point at /tmp/qualia-sl-nonexistent so git subprocesses fail
11
+ # fast (statusline swallows the error) — no real git repo required.
12
+
13
+ PASS=0
14
+ FAIL=0
15
+ # Resolve SL_JS to an ABSOLUTE path so `cd` inside subshells doesn't break it.
16
+ SL_JS="$(cd "$(dirname "$0")/../bin" && pwd)/statusline.js"
17
+ NODE="${NODE:-node}"
18
+
19
+ # statusline.js caches git state per-user in $TMPDIR/qualia-git-cache-$USER
20
+ # (3-second TTL). Wipe before every test so cached results from a previous
21
+ # run don't leak branch/changes values into the current assertion.
22
+ CACHE_GLOB_DIR="${TMPDIR:-/tmp}"
23
+ clean_cache() {
24
+ rm -f "$CACHE_GLOB_DIR"/qualia-git-cache-* 2>/dev/null || true
25
+ }
26
+ trap clean_cache EXIT
27
+ clean_cache
28
+
29
+ # Colors used by statusline.js — hardcoded from bin/statusline.js
30
+ # These are raw ANSI escape sequences (printf %b interpolates \x1b -> ESC).
31
+ TEAL_ESC=$(printf '\x1b[38;2;0;206;209m')
32
+ YELLOW_ESC=$(printf '\x1b[38;2;234;179;8m')
33
+ RED_ESC=$(printf '\x1b[38;2;239;68;68m')
34
+
35
+ pass() {
36
+ echo " ✓ $1"
37
+ PASS=$((PASS + 1))
38
+ }
39
+
40
+ fail_case() {
41
+ echo " ✗ $1${2:+ — $2}"
42
+ FAIL=$((FAIL + 1))
43
+ }
44
+
45
+ # run_sl <json> → populates OUT (stdout) and RC (exit code).
46
+ # Uses a nonexistent workspace dir so git commands fail gracefully.
47
+ # We write stdout to a tmp file (not $()) so we can capture exit code
48
+ # without losing it to a subshell on the left side of the pipeline.
49
+ SL_OUTFILE=$(mktemp)
50
+ run_sl() {
51
+ local json="$1"
52
+ clean_cache
53
+ # printf %s (not echo) to avoid trailing newlines affecting JSON.parse.
54
+ printf '%s' "$json" | $NODE "$SL_JS" > "$SL_OUTFILE" 2>/dev/null
55
+ RC=$?
56
+ OUT=$(cat "$SL_OUTFILE")
57
+ }
58
+ # Extend the cleanup trap to also remove the tmp output file.
59
+ cleanup_sl() {
60
+ clean_cache
61
+ [ -f "$SL_OUTFILE" ] && rm -f "$SL_OUTFILE"
62
+ }
63
+ trap cleanup_sl EXIT
64
+
65
+ echo "=== statusline.js Behavioral Tests ==="
66
+ echo ""
67
+
68
+ # Sanity check
69
+ if [ ! -f "$SL_JS" ]; then
70
+ echo "FATAL: statusline.js not found at $SL_JS"
71
+ exit 1
72
+ fi
73
+
74
+ # ─── Basic rendering ─────────────────────────────────────
75
+ echo "basic rendering:"
76
+
77
+ # 1. Minimal input renders without crash — two lines, contains dir basename + model
78
+ NONEXIST="/tmp/qualia-sl-nonexist-$$"
79
+ JSON='{"model":{"display_name":"Claude Opus 4.6"},"workspace":{"current_dir":"'"$NONEXIST"'"},"context_window":{"used_percentage":0},"cost":{"total_cost_usd":0},"agent":{},"worktree":{}}'
80
+ run_sl "$JSON"
81
+ LINES=$(wc -l < "$SL_OUTFILE")
82
+ if [ "$RC" -eq 0 ] \
83
+ && [ "$LINES" -eq 2 ] \
84
+ && grep -qF "qualia-sl-nonexist" "$SL_OUTFILE" \
85
+ && grep -qF "Claude Opus 4.6" "$SL_OUTFILE"; then
86
+ pass "minimal input → exit 0, 2 lines, contains dir basename + model name"
87
+ else
88
+ fail_case "minimal input" "exit=$RC lines=$LINES"
89
+ fi
90
+
91
+ # 2. Two lines always produced (check trailing newline count)
92
+ # process.stdout.write twice with '\n' → exactly 2 newlines in output
93
+ if [ "$LINES" -eq 2 ]; then
94
+ pass "always prints exactly 2 lines"
95
+ else
96
+ fail_case "two lines" "got $LINES newlines"
97
+ fi
98
+
99
+ # ─── Context bar color thresholds ────────────────────────
100
+ echo ""
101
+ echo "context bar color:"
102
+
103
+ # 3. Teal at low % (<50)
104
+ JSON='{"model":{"display_name":"M"},"workspace":{"current_dir":"'"$NONEXIST"'"},"context_window":{"used_percentage":30},"cost":{"total_cost_usd":0},"agent":{},"worktree":{}}'
105
+ run_sl "$JSON"
106
+ if [ "$RC" -eq 0 ] && grep -qF "$TEAL_ESC" "$SL_OUTFILE"; then
107
+ pass "30% → teal color on bar"
108
+ else
109
+ fail_case "30% → teal" "exit=$RC"
110
+ fi
111
+
112
+ # 4. Yellow at medium % (50–79)
113
+ JSON='{"model":{"display_name":"M"},"workspace":{"current_dir":"'"$NONEXIST"'"},"context_window":{"used_percentage":60},"cost":{"total_cost_usd":0},"agent":{},"worktree":{}}'
114
+ run_sl "$JSON"
115
+ if [ "$RC" -eq 0 ] && grep -qF "$YELLOW_ESC" "$SL_OUTFILE"; then
116
+ pass "60% → yellow color on bar"
117
+ else
118
+ fail_case "60% → yellow" "exit=$RC"
119
+ fi
120
+
121
+ # 5. Red at high % (>=80)
122
+ JSON='{"model":{"display_name":"M"},"workspace":{"current_dir":"'"$NONEXIST"'"},"context_window":{"used_percentage":85},"cost":{"total_cost_usd":0},"agent":{},"worktree":{}}'
123
+ run_sl "$JSON"
124
+ if [ "$RC" -eq 0 ] && grep -qF "$RED_ESC" "$SL_OUTFILE"; then
125
+ pass "85% → red color on bar"
126
+ else
127
+ fail_case "85% → red" "exit=$RC"
128
+ fi
129
+
130
+ # ─── Cost and duration formatting ────────────────────────
131
+ echo ""
132
+ echo "cost and duration:"
133
+
134
+ # 6. Cost formatting: $X.XX
135
+ JSON='{"model":{"display_name":"M"},"workspace":{"current_dir":"'"$NONEXIST"'"},"context_window":{"used_percentage":10},"cost":{"total_cost_usd":2.47,"total_duration_ms":0},"agent":{},"worktree":{}}'
136
+ run_sl "$JSON"
137
+ if [ "$RC" -eq 0 ] && grep -qF '$2.47' "$SL_OUTFILE"; then
138
+ pass "cost 2.47 → \$2.47"
139
+ else
140
+ fail_case "cost formatting" "exit=$RC"
141
+ fi
142
+
143
+ # 7. Duration under 60s shown as seconds
144
+ JSON='{"model":{"display_name":"M"},"workspace":{"current_dir":"'"$NONEXIST"'"},"context_window":{"used_percentage":10},"cost":{"total_cost_usd":0,"total_duration_ms":45000},"agent":{},"worktree":{}}'
145
+ run_sl "$JSON"
146
+ if [ "$RC" -eq 0 ] && grep -qF "45s" "$SL_OUTFILE"; then
147
+ pass "duration 45000ms → 45s"
148
+ else
149
+ fail_case "duration seconds" "exit=$RC"
150
+ fi
151
+
152
+ # 8. Duration >=60s shown as minutes
153
+ JSON='{"model":{"display_name":"M"},"workspace":{"current_dir":"'"$NONEXIST"'"},"context_window":{"used_percentage":10},"cost":{"total_cost_usd":0,"total_duration_ms":125000},"agent":{},"worktree":{}}'
154
+ run_sl "$JSON"
155
+ if [ "$RC" -eq 0 ] && grep -qF "2m" "$SL_OUTFILE"; then
156
+ pass "duration 125000ms → 2m"
157
+ else
158
+ fail_case "duration minutes" "exit=$RC"
159
+ fi
160
+
161
+ # ─── Optional segments: agent + worktree ─────────────────
162
+ echo ""
163
+ echo "optional segments:"
164
+
165
+ # 9. Agent name segment appears
166
+ JSON='{"model":{"display_name":"M"},"workspace":{"current_dir":"'"$NONEXIST"'"},"context_window":{"used_percentage":10},"cost":{"total_cost_usd":0},"agent":{"name":"qualia-planner"},"worktree":{}}'
167
+ run_sl "$JSON"
168
+ if [ "$RC" -eq 0 ] && grep -qF "qualia-planner" "$SL_OUTFILE"; then
169
+ pass "agent.name rendered on line 1"
170
+ else
171
+ fail_case "agent segment" "exit=$RC"
172
+ fi
173
+
174
+ # 10. Worktree name segment appears
175
+ JSON='{"model":{"display_name":"M"},"workspace":{"current_dir":"'"$NONEXIST"'"},"context_window":{"used_percentage":10},"cost":{"total_cost_usd":0},"agent":{},"worktree":{"name":"feature-x"}}'
176
+ run_sl "$JSON"
177
+ if [ "$RC" -eq 0 ] && grep -qF "feature-x" "$SL_OUTFILE"; then
178
+ pass "worktree.name rendered on line 1"
179
+ else
180
+ fail_case "worktree segment" "exit=$RC"
181
+ fi
182
+
183
+ # ─── Degraded input ──────────────────────────────────────
184
+ echo ""
185
+ echo "degraded input:"
186
+
187
+ # 11. Empty stdin → still exit 0, still produces two lines
188
+ run_sl ""
189
+ LINES=$(wc -l < "$SL_OUTFILE")
190
+ if [ "$RC" -eq 0 ] && [ "$LINES" -eq 2 ]; then
191
+ pass "empty stdin → exit 0, 2 lines (degraded, no crash)"
192
+ else
193
+ fail_case "empty stdin" "exit=$RC lines=$LINES"
194
+ fi
195
+
196
+ # 12. Invalid JSON → still exit 0, no crash
197
+ run_sl "not json{"
198
+ LINES=$(wc -l < "$SL_OUTFILE")
199
+ if [ "$RC" -eq 0 ] && [ "$LINES" -eq 2 ]; then
200
+ pass "invalid JSON → exit 0, 2 lines (degraded, no crash)"
201
+ else
202
+ fail_case "invalid JSON" "exit=$RC lines=$LINES"
203
+ fi
204
+
205
+ # ─── Phase info from tracking.json ───────────────────────
206
+ echo ""
207
+ echo "phase info:"
208
+
209
+ # 13. tracking.json with phase=2/4 status=built → "P2/4" and "built" appear
210
+ TMP=$(mktemp -d)
211
+ mkdir -p "$TMP/.planning"
212
+ cat > "$TMP/.planning/tracking.json" <<'EOF'
213
+ {"phase": 2, "total_phases": 4, "status": "built"}
214
+ EOF
215
+ JSON='{"model":{"display_name":"M"},"workspace":{"current_dir":"'"$TMP"'"},"context_window":{"used_percentage":10},"cost":{"total_cost_usd":0},"agent":{},"worktree":{}}'
216
+ run_sl "$JSON"
217
+ if [ "$RC" -eq 0 ] \
218
+ && grep -qF "P2/4" "$SL_OUTFILE" \
219
+ && grep -qF "built" "$SL_OUTFILE"; then
220
+ pass "tracking.json phase=2/4 status=built → P2/4 + built rendered"
221
+ else
222
+ fail_case "phase info from tracking.json" "exit=$RC"
223
+ fi
224
+ rm -rf "$TMP"
225
+
226
+ # 14. Malformed tracking.json does not crash → still exits 0
227
+ TMP=$(mktemp -d)
228
+ mkdir -p "$TMP/.planning"
229
+ echo 'not json' > "$TMP/.planning/tracking.json"
230
+ JSON='{"model":{"display_name":"M"},"workspace":{"current_dir":"'"$TMP"'"},"context_window":{"used_percentage":10},"cost":{"total_cost_usd":0},"agent":{},"worktree":{}}'
231
+ run_sl "$JSON"
232
+ LINES=$(wc -l < "$SL_OUTFILE")
233
+ if [ "$RC" -eq 0 ] && [ "$LINES" -eq 2 ]; then
234
+ pass "malformed tracking.json → exit 0, no crash"
235
+ else
236
+ fail_case "malformed tracking.json" "exit=$RC lines=$LINES"
237
+ fi
238
+ rm -rf "$TMP"
239
+
240
+ # ─── Summary ─────────────────────────────────────────────
241
+ echo ""
242
+ echo "=== Results: $PASS passed, $FAIL failed ==="
243
+ [ "$FAIL" -eq 0 ] && exit 0 || exit 1