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
scripts/prune_modules.js renamed
scripts/start-hfs.bat renamed