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.
- package/README.md +5 -0
- package/bin/cli.js +267 -5
- package/bin/install.js +99 -6
- package/bin/state.js +136 -2
- package/package.json +4 -4
- package/tests/bin.test.sh +673 -0
- package/tests/hooks.test.sh +155 -25
- package/tests/state.test.sh +137 -0
- package/tests/statusline.test.sh +243 -0
package/tests/hooks.test.sh
CHANGED
|
@@ -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 (
|
|
85
|
+
# --- branch-guard.js (behavioral — real git repo + real config file) ---
|
|
86
86
|
echo ""
|
|
87
87
|
echo "branch-guard:"
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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 " ✗
|
|
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
|
-
|
|
144
|
-
|
|
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 " ✗
|
|
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
|
-
|
|
152
|
-
|
|
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 " ✗
|
|
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 ""
|
package/tests/state.test.sh
CHANGED
|
@@ -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
|