main
sh 611 lines 20.7 KB
Raw
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