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