dx: local-ci wasn't always using correct screenshots folder

Massimo Melina committed Apr 19, 2026 at 11:26 UTC 63e0dfc01513224a94ff9aa631bc448737f1a45b
2 files changed +102 -33
playwright.config.ts
+11 -4
@@ -124,9 +124,16 @@ function getSnapshotBranch() {
124 }
125
126 function getGitBranchName() {
127 - try {
128 - return execSync('git branch -a --contains HEAD', { encoding: 'utf8' }).trim().split('\n').at(-1)?.trim()
129 - .replace(/^(\*\s*)?(remotes\/[^/]+\/)?/, '')
130 - }
127 + return (gitOutput('git branch --show-current')
128 + || gitOutput('git for-each-ref --format="%(refname:short)" --contains HEAD refs/heads refs/remotes'))
129 + ?.split('\n')
130 + .map(x => x.trim())
131 + .filter(x => x && !x.endsWith('/HEAD'))
132 + .map(x => x.replace(/^[^/]+\//, ''))
133 + .at(0)
134 +}
135 +
136 +function gitOutput(command: string) {
137 + try { return execSync(command, { encoding: 'utf8' }).trim() }
138 catch {}
139 }
scripts/local-ci.sh
+91 -29
@@ -1,12 +1,16 @@
1 #!/bin/bash
2 -#!/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
8 -# ./scripts/local-ci.sh <hash> # Test specific commit once
9 -# ./scripts/local-ci.sh <hash> --screenshots <branch> # Force screenshots for given branch
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")"
@@ -24,26 +28,47 @@ TEST_PORT=18081 # Use different port to avoid conflicts with manual tests
28 # Handle command line arguments
29 MODE=""
30 COMMIT_REQUEST=""
27 -FORCE_SCREENSHOTS=""
31 +FORCE_TEST=0
32 FORCE_BRANCH=""
33 STOP_REQUESTED=0
34
31 -if [ $# -eq 0 ]; then
35 +usage() {
36 echo "Usage:"
37 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"
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"
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
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
@@ -96,7 +121,21 @@ on_stop_signal() {
121 }
122
123 get_current_branch() {
99 - git -C "$PROJECT_ROOT" rev-parse --abbrev-ref HEAD
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,7 +181,10 @@ save_tested() {
181 is_tested() {
182 local commit="$1"
183 local tested="$2"
145 - echo "$tested" | grep -q "\"$commit\""
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() {
@@ -275,6 +317,7 @@ 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
@@ -286,8 +329,12 @@ run_test() {
329 # Create worktree
330 git -C "$PROJECT_ROOT" worktree add "$worktree_path" "$commit"
331
289 - # Modify port in worktree to avoid conflicts (only for detached HEAD or non-tip commits)
290 - if [ -z "$branch" ] || [ "$branch" = "detached" ]; then
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
@@ -313,7 +360,6 @@ run_test() {
360
361 if [ $exit_code -eq 0 ]; then
362 # Check if this commit is the tip of the branch or if forced
316 - local tip=""
363 local can_use_screenshots=0
364 local screenshot_branch="$branch"
365
@@ -322,7 +368,6 @@ run_test() {
368 screenshot_branch="$FORCE_BRANCH"
369 log "Forcing screenshots with branch $FORCE_BRANCH for commit $commit"
370 elif [ -n "$branch" ] && [ "$branch" != "detached" ]; then
325 - tip=$(git -C "$PROJECT_ROOT" rev-parse "$branch" 2>/dev/null || echo "")
371 if [ "$commit" = "$tip" ]; then
372 can_use_screenshots=1
373 fi
@@ -352,12 +397,12 @@ run_test() {
397 exit_code=3
398 fi
399 else
355 - # Not the tip or detached HEAD - run test-ui with screenshots disabled via Playwright flag
356 - log "Running test-ui with --ignore-snapshots for non-tip/detached commit $commit"
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" && {
359 - env -u NO_COLOR FORCE_COLOR=1 npx playwright test frontend --ignore-snapshots --reporter=line &&
360 - env -u NO_COLOR FORCE_COLOR=1 npx playwright test serial --ignore-snapshots --reporter=line
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
@@ -371,9 +416,9 @@ run_test() {
416 fi
417 fi
418
374 - # keep detached failing worktree for investigation, but don't keep it when interrupted manually
375 - if [ $exit_code -ne 0 ] && ! is_interrupted_exit_code "$exit_code" && ( [ -z "$branch" ] || [ "$branch" = "detached" ] ); then
376 - log "Test FAILED on detached HEAD. Worktree kept for investigation at: $worktree_path"
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
@@ -394,7 +439,7 @@ log "Local CI started"
439 tested_commits=$(load_tested)
440 queue=$(load_queue)
441 current_branch=$(get_current_branch)
397 -head_commit=$(git -C "$PROJECT_ROOT" rev-parse "$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")
@@ -427,6 +472,16 @@ if [ "$MODE" = "once" ]; then
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
@@ -454,7 +509,8 @@ while true; do
509 else
510 # Verify it's a valid commit
511 if git -C "$PROJECT_ROOT" rev-parse "$requested_commit" >/dev/null 2>&1; then
457 - requested_branch=$(git -C "$PROJECT_ROOT" rev-parse --symbolic-full-name "$requested_commit" 2>/dev/null | sed 's|refs/heads/||' || echo "detached")
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"
@@ -462,7 +518,13 @@ while true; do
518 fi
519 fi
520
465 - if [ -n "$requested_commit" ] && ( [ "$MODE" = "once" ] || ( ! is_tested "$requested_commit" "$tested_commits" && ! queue_contains "$requested_commit" "$queue" ) ); then
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"
@@ -474,7 +536,7 @@ while true; do
536 current_branch=$(get_current_branch)
537
538 # Get HEAD commit only
477 - head_commit=$(git -C "$PROJECT_ROOT" rev-parse "$current_branch")
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