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