dx: scripts/local-ci
Massimo Melina committed
Feb 25, 2026 at 18:24 UTC
8c0504ec844e00a03f66a82e4f1cd3b94b4a6a69
6 files changed
+551
-3
.gitignore
+1
-1
@@ -9,7 +9,7 @@ tests/work
9
tests/work2
10
tests/config.yaml.bak
11
12
-# Playwright
12
+.ci/
13
/test-results/
14
/playwright-report/
15
/blob-report/
package.json
+2
-2
@@ -14,7 +14,7 @@
14
"start-frontend": "npm run start --workspace=frontend",
15
"start-admin": "npm run start --workspace=admin",
16
"build-all": "rm -rf dist && npm run build-server && npm run test-with-server && (npm run build-frontend & npm run build-admin) && echo COMPLETED",
17
- "build-server": "rm -rf dist/src dist/plugins && npm i && tsc && touch package.json && cp -v -r package.json central.json README* LICENSE* hfs.ico plugins dist && find dist -name .DS_Store -o -name storage -exec rm -rf {} + && node afterbuild.js",
17
+ "build-server": "rm -rf dist/src dist/plugins && npm i && tsc && touch package.json && cp -v -r package.json central.json README* LICENSE* hfs.ico plugins dist && find dist -name .DS_Store -o -name storage -exec rm -rf {} + && node scripts/afterbuild.js",
18
"build-frontend": "npm run build --workspace=frontend",
19
"build-admin": "npm run build --workspace=admin",
20
"server-for-test": "node dist/src --cwd tests/work --config tests --debug",
@@ -28,7 +28,7 @@
28
"dist": "STASHED=; if ! git diff-index --quiet HEAD --; then git stash push -m 'dist' && STASHED=1; fi; CI=1 FORCE_COLOR=1 npm run dist-uncommitted || (EXIT_CODE=$?; [ -n \"$STASHED\" ] && git stash pop; exit $EXIT_CODE); [ -n \"$STASHED\" ] && git stash pop",
29
"dist-uncommitted": "npm audit --omit=dev --audit-level=moderate && npm run build-all && npm run test-ui && npm run dist-bin",
30
"dist-bin": "npm run dist-modules && npm run dist-bin-win && npm run dist-bin-linux && npm run dist-bin-linux-arm && npm run dist-bin-mac && npm run dist-bin-mac-arm",
31
- "dist-modules": "cp package*.json central.json README.md dist && cd dist && npm ci --omit=dev && cd .. && node prune_modules",
31
+ "dist-modules": "cp package*.json central.json README.md dist && cd dist && npm ci --omit=dev && cd .. && node scripts/prune_modules.js",
32
"dist-bin-win": "cd dist && pkg . --public -C gzip -t node20-win-x64 && npx resedit-cli --in hfs.exe --icon 1,../hfs.ico --out hfs.exe && zip hfs-windows-x64-$(jq -r .version ../package.json).zip hfs.exe -r plugins && cd ..",
33
"dist-bin-mac-arm": "cd dist && pkg . --public -C gzip -t node20-macos-arm64 && zip hfs-mac-arm64-$(jq -r .version ../package.json).zip hfs -r plugins && cd ..",
34
"dist-bin-mac": "cd dist && pkg . --public -C gzip -t node20-macos-x64 && zip hfs-mac-x64-$(jq -r .version ../package.json).zip hfs -r plugins && cd ..",
scripts/afterbuild.js
renamed
scripts/local-ci.sh
new
+548
@@ -0,0 +1,548 @@
1
+#!/bin/bash
2
+#!/bin/bash
3
+
4
+# Local CI - runs tests asynchronously on new commits
5
+# Usage:
6
+# ./scripts/local-ci.sh # Show usage
7
+# ./scripts/local-ci.sh watch # Watch mode: test HEAD when it changes
8
+# ./scripts/local-ci.sh <hash> # Test specific commit once
9
+# ./scripts/local-ci.sh <hash> --screenshots <branch> # Force screenshots for given branch
10
+
11
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
12
+PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
13
+CI_DIR="$PROJECT_ROOT/.ci"
14
+QUEUE_FILE="$CI_DIR/queue.json"
15
+TESTED_FILE="$CI_DIR/tested.json"
16
+LOG_FILE="$CI_DIR/test-results.log"
17
+WORKTREE_BASE="$CI_DIR/worktrees"
18
+IN_PROGRESS_FILE="$CI_DIR/in_progress.json"
19
+
20
+POLL_INTERVAL=30
21
+MAX_QUEUE=20
22
+TEST_PORT=18081 # Use different port to avoid conflicts with manual tests
23
+
24
+# Handle command line arguments
25
+MODE=""
26
+COMMIT_REQUEST=""
27
+FORCE_SCREENSHOTS=""
28
+FORCE_BRANCH=""
29
+STOP_REQUESTED=0
30
+
31
+if [ $# -eq 0 ]; then
32
+ echo "Usage:"
33
+ echo " ./scripts/local-ci.sh watch # Watch mode: test HEAD when it changes"
34
+ echo " ./scripts/local-ci.sh <hash> # Test specific commit once"
35
+ echo " ./scripts/local-ci.sh <hash> --screenshots <branch> # Force screenshots for given branch"
36
+ exit 0
37
+elif [ "$1" = "watch" ]; then
38
+ MODE="watch"
39
+else
40
+ MODE="once"
41
+ COMMIT_REQUEST="$1"
42
+ # Check for --screenshots flag with branch name
43
+ if [ "$2" = "--screenshots" ] && [ -n "$3" ]; then
44
+ FORCE_SCREENSHOTS=1
45
+ FORCE_BRANCH="$3"
46
+ fi
47
+fi
48
+
49
+# Validate commit if provided
50
+if [ -n "$COMMIT_REQUEST" ]; then
51
+ if git -C "$PROJECT_ROOT" rev-parse "$COMMIT_REQUEST" >/dev/null 2>&1; then
52
+ echo "$COMMIT_REQUEST" > "$CI_DIR/test-commit"
53
+ else
54
+ echo "Error: Invalid commit hash: $COMMIT_REQUEST"
55
+ exit 1
56
+ fi
57
+fi
58
+
59
+mkdir -p "$CI_DIR"
60
+mkdir -p "$WORKTREE_BASE"
61
+
62
+log() {
63
+ echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
64
+}
65
+
66
+log_stderr() {
67
+ echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE" >&2
68
+}
69
+
70
+notify() {
71
+ # Use osascript alert for persistent notification
72
+ osascript -e 'tell app "System Events" to display dialog "'"$1"'" with title "HFS CI" buttons {"OK"} default button 1 giving up after 0'
73
+}
74
+
75
+is_interrupted_exit_code() {
76
+ local exit_code="$1"
77
+ [ "$exit_code" -eq 130 ] || [ "$exit_code" -eq 143 ]
78
+}
79
+
80
+on_stop_signal() {
81
+ local signal_name="$1"
82
+
83
+ if [ "$STOP_REQUESTED" -eq 1 ]; then
84
+ exit 130
85
+ fi
86
+
87
+ STOP_REQUESTED=1
88
+ log_stderr "Received $signal_name, stopping local-ci"
89
+ clear_in_progress
90
+
91
+ # kill children to avoid leaving test/build processes alive after ctrl+c
92
+ pkill -TERM -P $$ >/dev/null 2>&1 || true
93
+ sleep 0.2
94
+ pkill -KILL -P $$ >/dev/null 2>&1 || true
95
+ exit 130
96
+}
97
+
98
+get_current_branch() {
99
+ git -C "$PROJECT_ROOT" rev-parse --abbrev-ref HEAD
100
+}
101
+
102
+get_recent_commits() {
103
+ local branch="$1"
104
+ git -C "$PROJECT_ROOT" rev-list --max-count=50 "$branch"
105
+}
106
+
107
+load_queue() {
108
+ if [ -f "$QUEUE_FILE" ] && [ -s "$QUEUE_FILE" ]; then
109
+ if jq -e . "$QUEUE_FILE" >/dev/null 2>&1; then
110
+ cat "$QUEUE_FILE"
111
+ else
112
+ # previous local-ci versions could accidentally mix log lines with JSON; recover automatically to keep watch mode running
113
+ log_stderr "Invalid queue file, resetting to empty queue"
114
+ echo "[]"
115
+ fi
116
+ else
117
+ echo "[]"
118
+ fi
119
+}
120
+
121
+save_queue() {
122
+ echo "$1" > "$QUEUE_FILE"
123
+}
124
+
125
+load_tested() {
126
+ if [ -f "$TESTED_FILE" ] && [ -s "$TESTED_FILE" ]; then
127
+ if jq -e . "$TESTED_FILE" >/dev/null 2>&1; then
128
+ cat "$TESTED_FILE"
129
+ else
130
+ log_stderr "Invalid tested file, resetting tested history"
131
+ echo "[]"
132
+ fi
133
+ else
134
+ echo "[]"
135
+ fi
136
+}
137
+
138
+save_tested() {
139
+ echo "$1" > "$TESTED_FILE"
140
+}
141
+
142
+is_tested() {
143
+ local commit="$1"
144
+ local tested="$2"
145
+ echo "$tested" | grep -q "\"$commit\""
146
+}
147
+
148
+add_to_tested() {
149
+ local commit="$1"
150
+ local tested="$2"
151
+ local branch="$3"
152
+
153
+ # Remove this commit from tested list (we'll re-add it with fresh timestamp)
154
+ local new_tested
155
+ new_tested=$(echo "$tested" | jq --arg commit "$commit" --arg branch "$branch" \
156
+ 'map(select(.commit != $commit)) + [{"commit": $commit, "branch": $branch, "timestamp": (now | todate)}]')
157
+
158
+ echo "$new_tested"
159
+}
160
+
161
+save_in_progress() {
162
+ local commit="$1"
163
+ local branch="$2"
164
+ echo '{"commit": "'"$commit"'", "branch": "'"$branch"'"}' > "$IN_PROGRESS_FILE"
165
+}
166
+
167
+clear_in_progress() {
168
+ rm -f "$IN_PROGRESS_FILE"
169
+}
170
+
171
+load_in_progress() {
172
+ if [ -f "$IN_PROGRESS_FILE" ] && [ -s "$IN_PROGRESS_FILE" ]; then
173
+ cat "$IN_PROGRESS_FILE"
174
+ else
175
+ echo "null"
176
+ fi
177
+}
178
+
179
+queue_contains() {
180
+ local commit="$1"
181
+ local queue="$2"
182
+ echo "$queue" | grep -q "\"$commit\""
183
+}
184
+
185
+add_to_queue() {
186
+ local queue="$1"
187
+ local commit="$2"
188
+ local branch="$3"
189
+
190
+ local new_queue
191
+ new_queue=$(echo "$queue" | jq --arg commit "$commit" --arg branch "$branch" \
192
+ '. + [{"commit": $commit, "branch": $branch, "added_at": (now | todate)}]')
193
+
194
+ echo "$new_queue"
195
+}
196
+
197
+is_commit_reachable_from_head() {
198
+ local commit="$1"
199
+ local head="$2"
200
+ git -C "$PROJECT_ROOT" merge-base --is-ancestor "$commit" "$head" >/dev/null 2>&1
201
+}
202
+
203
+prune_queue_for_head() {
204
+ local queue="$1"
205
+ local head="$2"
206
+ local pruned_queue='[]'
207
+ local commit=""
208
+ local branch=""
209
+ local added_at=""
210
+
211
+ while IFS=$'\t' read -r commit branch added_at; do
212
+ if [ -z "$commit" ]; then
213
+ continue
214
+ fi
215
+ if ! is_commit_reachable_from_head "$commit" "$head"; then
216
+ log_stderr "Dropping stale queued commit $commit (not reachable from current HEAD)"
217
+ continue
218
+ fi
219
+ if queue_contains "$commit" "$pruned_queue"; then
220
+ # queue duplication is expected after interrupted runs, so keep only one entry per commit to avoid retesting the same hash
221
+ continue
222
+ fi
223
+ pruned_queue=$(add_to_queue "$pruned_queue" "$commit" "$branch")
224
+ done < <(echo "$queue" | jq -r '.[] | [.commit, .branch, .added_at] | @tsv')
225
+
226
+ echo "$pruned_queue"
227
+}
228
+
229
+remove_from_queue() {
230
+ local queue="$1"
231
+ local commit="$2"
232
+
233
+ local new_queue
234
+ new_queue=$(echo "$queue" | jq --arg commit "$commit" \
235
+ '. = [.[] | select(.commit != $commit)]')
236
+
237
+ echo "$new_queue"
238
+}
239
+
240
+# Strip ANSI escape codes from output
241
+strip_ansi() {
242
+ # playwright/vite emit cursor-control escapes and occasional NUL bytes; strip both so the saved log stays plain text
243
+ perl -pe 's/\e\[[0-9;?]*[ -\/]*[@-~]//g; s/\x00//g'
244
+}
245
+
246
+count_log_lines() {
247
+ if [ -f "$LOG_FILE" ]; then
248
+ wc -l < "$LOG_FILE"
249
+ else
250
+ echo "0"
251
+ fi
252
+}
253
+
254
+log_failure_summary() {
255
+ local from_line="$1"
256
+ local stage_name="$2"
257
+ local summary
258
+
259
+ # this log can contain NUL bytes from osascript output, so force text mode and keep only lines that identify the failing test
260
+ summary=$(tail -n "+$((from_line + 1))" "$LOG_FILE" \
261
+ | grep -aE "not ok [0-9]+ - |error: |failureType: |location: |# fail [0-9]+" \
262
+ | tail -n 12)
263
+
264
+ if [ -z "$summary" ]; then
265
+ return
266
+ fi
267
+
268
+ log "$stage_name failure summary (last relevant lines):"
269
+ while IFS= read -r line; do
270
+ log " $line"
271
+ done <<< "$summary"
272
+}
273
+
274
+run_test() {
275
+ local commit="$1"
276
+ local branch="$2"
277
+ local worktree_path="$WORKTREE_BASE/$commit"
278
+
279
+ log "Starting test for $commit (branch: $branch)"
280
+
281
+ # Remove existing worktree if any
282
+ if [ -d "$worktree_path" ]; then
283
+ rm -rf "$worktree_path"
284
+ fi
285
+
286
+ # Create worktree
287
+ git -C "$PROJECT_ROOT" worktree add "$worktree_path" "$commit"
288
+
289
+ # Modify port in worktree to avoid conflicts (only for detached HEAD or non-tip commits)
290
+ if [ -z "$branch" ] || [ "$branch" = "detached" ]; then
291
+ log "Changing port from 8081 to $TEST_PORT in worktree files"
292
+ sed -i '' "s/8081/$TEST_PORT/g" "$worktree_path/e2e/common.ts" 2>/dev/null || true
293
+ sed -i '' "s/8081/$TEST_PORT/g" "$worktree_path/playwright.config.ts" 2>/dev/null || true
294
+ sed -i '' "s/port: 8081/port: $TEST_PORT/g" "$worktree_path/tests/config.yaml" 2>/dev/null || true
295
+ fi
296
+
297
+ local exit_code=0
298
+ local log_start=0
299
+
300
+ # Build includes backend tests already, so running test-with-server again here would duplicate the suite and add flaky noise
301
+ log_start=$(count_log_lines)
302
+ cd "$worktree_path" && env -u NO_COLOR FORCE_COLOR=1 npm run build-all 2>&1 | tee >(strip_ansi >> "$LOG_FILE")
303
+ build_result=${PIPESTATUS[0]}
304
+ if is_interrupted_exit_code "$build_result"; then
305
+ log "BUILD-ALL interrupted for $commit with exit code $build_result"
306
+ exit_code="$build_result"
307
+ elif [ $build_result -ne 0 ]; then
308
+ log "BUILD-ALL FAILED for $commit with exit code $build_result"
309
+ log_failure_summary "$log_start" "BUILD-ALL"
310
+ exit_code=1
311
+ fi
312
+
313
+ if [ $exit_code -eq 0 ]; then
314
+ # Check if this commit is the tip of the branch or if forced
315
+ local tip=""
316
+ local can_use_screenshots=0
317
+ local screenshot_branch="$branch"
318
+
319
+ if [ -n "$FORCE_BRANCH" ]; then
320
+ can_use_screenshots=1
321
+ screenshot_branch="$FORCE_BRANCH"
322
+ log "Forcing screenshots with branch $FORCE_BRANCH for commit $commit"
323
+ elif [ -n "$branch" ] && [ "$branch" != "detached" ]; then
324
+ tip=$(git -C "$PROJECT_ROOT" rev-parse "$branch" 2>/dev/null || echo "")
325
+ if [ "$commit" = "$tip" ]; then
326
+ can_use_screenshots=1
327
+ fi
328
+ fi
329
+
330
+ if [ $can_use_screenshots -eq 1 ]; then
331
+ # Enable screenshots by creating symlink to snapshots
332
+ local snapshot_dir="$worktree_path/e2e/frontend.spec.ts-snapshots-${screenshot_branch}"
333
+ mkdir -p "$(dirname "$snapshot_dir")"
334
+ if [ -d "$PROJECT_ROOT/e2e/frontend.spec.ts-snapshots-${screenshot_branch}" ]; then
335
+ ln -sf "$PROJECT_ROOT/e2e/frontend.spec.ts-snapshots-${screenshot_branch}" "$snapshot_dir"
336
+ log "Screenshots enabled for commit $commit (branch: $screenshot_branch)"
337
+ fi
338
+ # Pass branch name to Playwright so it uses correct snapshot folder
339
+ log_start=$(count_log_lines)
340
+ cd "$worktree_path" && {
341
+ env -u NO_COLOR PLAYWRIGHT_SNAPSHOT_BRANCH="$screenshot_branch" FORCE_COLOR=1 npx playwright test frontend --reporter=line &&
342
+ env -u NO_COLOR PLAYWRIGHT_SNAPSHOT_BRANCH="$screenshot_branch" FORCE_COLOR=1 npx playwright test serial --reporter=line
343
+ } 2>&1 | tee >(strip_ansi >> "$LOG_FILE")
344
+ test_ui_result=${PIPESTATUS[0]}
345
+ if is_interrupted_exit_code "$test_ui_result"; then
346
+ log "TEST-UI interrupted for $commit with exit code $test_ui_result"
347
+ exit_code="$test_ui_result"
348
+ elif [ $test_ui_result -ne 0 ]; then
349
+ log "TEST-UI FAILED for $commit with exit code $test_ui_result"
350
+ log_failure_summary "$log_start" "TEST-UI"
351
+ exit_code=3
352
+ fi
353
+ else
354
+ # Not the tip or detached HEAD - run test-ui with screenshots disabled via Playwright flag
355
+ log "Running test-ui with --ignore-snapshots for non-tip/detached commit $commit"
356
+ log_start=$(count_log_lines)
357
+ cd "$worktree_path" && {
358
+ env -u NO_COLOR FORCE_COLOR=1 npx playwright test frontend --ignore-snapshots --reporter=line &&
359
+ env -u NO_COLOR FORCE_COLOR=1 npx playwright test serial --ignore-snapshots --reporter=line
360
+ } 2>&1 | tee >(strip_ansi >> "$LOG_FILE")
361
+ test_ui_result=${PIPESTATUS[0]}
362
+ if is_interrupted_exit_code "$test_ui_result"; then
363
+ log "TEST-UI interrupted for $commit with exit code $test_ui_result"
364
+ exit_code="$test_ui_result"
365
+ elif [ $test_ui_result -ne 0 ]; then
366
+ log "TEST-UI FAILED for $commit with exit code $test_ui_result"
367
+ log_failure_summary "$log_start" "TEST-UI"
368
+ exit_code=3
369
+ fi
370
+ fi
371
+ fi
372
+
373
+ # keep detached failing worktree for investigation, but don't keep it when interrupted manually
374
+ if [ $exit_code -ne 0 ] && ! is_interrupted_exit_code "$exit_code" && ( [ -z "$branch" ] || [ "$branch" = "detached" ] ); then
375
+ log "Test FAILED on detached HEAD. Worktree kept for investigation at: $worktree_path"
376
+ log "To clean up manually: git worktree remove --force $worktree_path"
377
+ else
378
+ git -C "$PROJECT_ROOT" worktree remove "$worktree_path" --force 2>/dev/null || true
379
+ fi
380
+
381
+ log "Test for $commit completed with exit code $exit_code"
382
+
383
+ return $exit_code
384
+}
385
+
386
+# Main loop
387
+trap 'on_stop_signal SIGINT' INT
388
+trap 'on_stop_signal SIGTERM' TERM
389
+
390
+log "Local CI started"
391
+
392
+# Load state from files (survives restarts)
393
+tested_commits=$(load_tested)
394
+queue=$(load_queue)
395
+current_branch=$(get_current_branch)
396
+head_commit=$(git -C "$PROJECT_ROOT" rev-parse "$current_branch")
397
+
398
+# after rebases, persisted queue entries can point to obsolete history and must be dropped to keep watch mode aligned with current HEAD
399
+queue=$(prune_queue_for_head "$queue" "$head_commit")
400
+save_queue "$queue"
401
+
402
+# Check for interrupted test
403
+in_progress=$(load_in_progress)
404
+if [ "$in_progress" != "null" ]; then
405
+ commit=$(echo "$in_progress" | jq -r '.commit')
406
+ branch=$(echo "$in_progress" | jq -r '.branch')
407
+ if is_commit_reachable_from_head "$commit" "$head_commit"; then
408
+ if ! queue_contains "$commit" "$queue"; then
409
+ log "Found interrupted test for $commit, re-queuing"
410
+ queue=$(add_to_queue "$queue" "$commit" "$branch")
411
+ save_queue "$queue"
412
+ fi
413
+ else
414
+ log "Dropping stale interrupted commit $commit (not reachable from current HEAD)"
415
+ fi
416
+ clear_in_progress
417
+fi
418
+
419
+# explicit once mode should test only the requested commit and ignore persisted queue state
420
+if [ "$MODE" = "once" ]; then
421
+ if [ "$(echo "$queue" | jq 'length')" -gt 0 ]; then
422
+ log "Once mode: ignoring persisted queue and testing only requested commit"
423
+ fi
424
+ queue='[]'
425
+ save_queue "$queue"
426
+ clear_in_progress
427
+fi
428
+
429
+log "Loaded tested: $(echo "$tested_commits" | jq 'length') commits"
430
+log "Loaded queue: $(echo "$queue" | jq 'length') commits"
431
+
432
+# Clean up stale worktrees from previous interrupted runs
433
+for worktree in "$WORKTREE_BASE"/*; do
434
+ if [ -d "$worktree" ]; then
435
+ commit=$(basename "$worktree")
436
+ log "Cleaning up stale worktree for $commit"
437
+ git -C "$PROJECT_ROOT" worktree remove "$worktree" --force 2>/dev/null || true
438
+ fi
439
+done
440
+
441
+while true; do
442
+ # Load state
443
+ queue=$(load_queue)
444
+
445
+ # Check for manual commit request
446
+ if [ -f "$CI_DIR/test-commit" ]; then
447
+ requested_commit=$(cat "$CI_DIR/test-commit" | tr -d ' \n')
448
+ if [ -n "$requested_commit" ]; then
449
+ if [ "$requested_commit" = "HEAD" ]; then
450
+ # Use current HEAD
451
+ requested_commit=$(git -C "$PROJECT_ROOT" rev-parse HEAD)
452
+ requested_branch=$(get_current_branch)
453
+ else
454
+ # Verify it's a valid commit
455
+ if git -C "$PROJECT_ROOT" rev-parse "$requested_commit" >/dev/null 2>&1; then
456
+ requested_branch=$(git -C "$PROJECT_ROOT" rev-parse --symbolic-full-name "$requested_commit" 2>/dev/null | sed 's|refs/heads/||' || echo "detached")
457
+ else
458
+ log "Invalid commit hash: $requested_commit"
459
+ rm -f "$CI_DIR/test-commit"
460
+ requested_commit=""
461
+ fi
462
+ fi
463
+
464
+ if [ -n "$requested_commit" ] && ( [ "$MODE" = "once" ] || ( ! is_tested "$requested_commit" "$tested_commits" && ! queue_contains "$requested_commit" "$queue" ) ); then
465
+ queue=$(add_to_queue "$queue" "$requested_commit" "$requested_branch")
466
+ log "Added requested commit $requested_commit to queue"
467
+ rm -f "$CI_DIR/test-commit"
468
+ fi
469
+ fi
470
+ fi
471
+
472
+ # Find new commits to test - only HEAD
473
+ current_branch=$(get_current_branch)
474
+
475
+ # Get HEAD commit only
476
+ head_commit=$(git -C "$PROJECT_ROOT" rev-parse "$current_branch")
477
+
478
+ # Check if HEAD is not yet tested and not in queue
479
+ if ! is_tested "$head_commit" "$tested_commits" && ! queue_contains "$head_commit" "$queue"; then
480
+ queue_size=$(echo "$queue" | jq 'length')
481
+ if [ "$queue_size" -lt $MAX_QUEUE ]; then
482
+ queue=$(add_to_queue "$queue" "$head_commit" "$current_branch")
483
+ log "Added HEAD commit $head_commit to queue"
484
+ else
485
+ log "Queue full ($queue_size/$MAX_QUEUE), waiting..."
486
+ fi
487
+ fi
488
+
489
+ save_queue "$queue"
490
+
491
+ # Get queue and start test if available
492
+ queue=$(load_queue)
493
+ queue_size=$(echo "$queue" | jq 'length')
494
+
495
+ if [ "$queue_size" -gt 0 ]; then
496
+ # Get first item from queue
497
+ item=$(echo "$queue" | jq -r '.[0]')
498
+ commit=$(echo "$item" | jq -r '.commit')
499
+ branch=$(echo "$item" | jq -r '.branch')
500
+
501
+ log "Testing commit $commit from queue (remaining: $((queue_size - 1)))"
502
+
503
+ # Save in-progress state (survives restarts)
504
+ save_in_progress "$commit" "$branch"
505
+
506
+ # Run test
507
+ if run_test "$commit" "$branch"; then
508
+ # Test passed - add to tested list
509
+ tested_commits=$(add_to_tested "$commit" "$tested_commits" "$branch")
510
+ save_tested "$tested_commits"
511
+ log "TEST PASSED for $commit"
512
+
513
+ # Clear in-progress state and remove from queue
514
+ clear_in_progress
515
+ queue=$(load_queue)
516
+ queue=$(remove_from_queue "$queue" "$commit")
517
+ save_queue "$queue"
518
+
519
+ # If running in "once" mode, exit after test completes
520
+ if [ "$MODE" = "once" ]; then
521
+ log "Test completed in once mode, exiting."
522
+ exit 0
523
+ fi
524
+ else
525
+ exit_code=$?
526
+ if is_interrupted_exit_code "$exit_code"; then
527
+ log "TEST INTERRUPTED for $commit - STOPPING CI"
528
+ clear_in_progress
529
+ exit "$exit_code"
530
+ else
531
+ # Failed - notify and stop
532
+ log "TEST FAILED for $commit - STOPPING CI"
533
+ notify "HFS CI: Test FAILED for ${commit:0:7}. CI STOPPED."
534
+ clear_in_progress
535
+
536
+ # Remove from queue
537
+ queue=$(load_queue)
538
+ queue=$(remove_from_queue "$queue" "$commit")
539
+ save_queue "$queue"
540
+
541
+ log "CI stopped due to test failure. Run 'npm run local-ci' to restart."
542
+ exit 1
543
+ fi
544
+ fi
545
+ fi
546
+
547
+ sleep $POLL_INTERVAL
548
+done