From 688a3540dd2854c05167cd2694f02b6427968d90 Mon Sep 17 00:00:00 2001 From: PAMulligan Date: Fri, 22 May 2026 18:38:25 -0400 Subject: [PATCH] refactor(scripts): extract shared bash utilities to scripts/lib MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates duplicated patterns across shell scripts into three shared libraries: - scripts/lib/common.sh — banner/step/pass/fail/skip/warn output helpers, require_cmd/have_cmd, tracked tempfiles with auto EXIT trap, cross-platform now_ms, csv_contains, build_artifact_exists, project_root resolver, and a config_get wrapper that delegates to pipeline-config.js. - scripts/lib/colors.sh — ANSI color constants honoring NO_COLOR and TTY detection. - scripts/lib/pipeline-config.js — ESM module + CLI for reading .claude/pipeline.config.json (mtime-cached, boolean/object-aware shell rendering, prints fallback on any error so set -e callers stay safe). Migrated 9 high-duplication scripts to source the libs: check-security.sh (eliminated 3 inline node -e config reads + manual TMPFILES cleanup), check-dead-code.sh, check-types.sh, check-dark-mode.sh, check-responsive.sh, capture-baselines.sh, regression-test.sh, verify-all.sh (extracted csv_contains/now_ms/build_artifact_exists), and incremental-build.sh (moved ANSI color block to colors.sh). Net -106 lines across the 9 migrated scripts. Output format preserved exactly so existing script tests (verify-all, check-security, check-dead-code, check-responsive) continue to pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/capture-baselines.sh | 34 +++---- scripts/check-dark-mode.sh | 19 ++-- scripts/check-dead-code.sh | 21 +--- scripts/check-responsive.sh | 30 ++---- scripts/check-security.sh | 62 +++--------- scripts/check-types.sh | 14 +-- scripts/incremental-build.sh | 12 +-- scripts/lib/colors.sh | 37 +++++++ scripts/lib/common.sh | 177 +++++++++++++++++++++++++++++++++ scripts/lib/pipeline-config.js | 110 ++++++++++++++++++++ scripts/regression-test.sh | 46 ++++----- scripts/verify-all.sh | 46 ++------- 12 files changed, 413 insertions(+), 195 deletions(-) create mode 100644 scripts/lib/colors.sh create mode 100644 scripts/lib/common.sh create mode 100644 scripts/lib/pipeline-config.js diff --git a/scripts/capture-baselines.sh b/scripts/capture-baselines.sh index d60d2d4..25c42e3 100755 --- a/scripts/capture-baselines.sh +++ b/scripts/capture-baselines.sh @@ -4,8 +4,10 @@ # Exit codes: 0=success, 1=error set -euo pipefail -PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "$PROJECT_ROOT" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" +cd "$(common_project_root)" # --- Args --- URL="${1:-http://localhost:3000}" @@ -35,22 +37,15 @@ echo "=== Baseline Screenshot Capture ===" echo "" # --- Read config --- -CONFIG_FILE=".claude/pipeline.config.json" -if [ -f "$CONFIG_FILE" ] && command -v node &> /dev/null; then - BASELINE_DIR=$(node -e "const c=JSON.parse(require('fs').readFileSync('$CONFIG_FILE','utf-8'));console.log(c.regressionTesting?.baselineDir||'.claude/visual-qa/baselines')") - BREAKPOINTS_JSON=$(node -e "const c=JSON.parse(require('fs').readFileSync('$CONFIG_FILE','utf-8'));console.log(JSON.stringify(c.regressionTesting?.breakpoints||{mobile:375,desktop:1440}))") - WAIT_MS=$(node -e "const c=JSON.parse(require('fs').readFileSync('$CONFIG_FILE','utf-8'));console.log(c.regressionTesting?.waitAfterLoadMs||1500)") - FULL_PAGE=$(node -e "const c=JSON.parse(require('fs').readFileSync('$CONFIG_FILE','utf-8'));console.log(c.regressionTesting?.fullPage!==false?'true':'false')") - CONFIG_ROUTES=$(node -e "const c=JSON.parse(require('fs').readFileSync('$CONFIG_FILE','utf-8'));console.log((c.regressionTesting?.routes||['/']).join(','))") - BROWSERS_JSON=$(node -e "const c=JSON.parse(require('fs').readFileSync('$CONFIG_FILE','utf-8'));console.log(JSON.stringify(c.regressionTesting?.browsers||['chromium']))") -else - BASELINE_DIR=".claude/visual-qa/baselines" - BREAKPOINTS_JSON='{"mobile":375,"desktop":1440}' - WAIT_MS=1500 - FULL_PAGE="true" - CONFIG_ROUTES="/" - BROWSERS_JSON='["chromium"]' -fi +BASELINE_DIR=$(common_config_get 'regressionTesting.baselineDir' '.claude/visual-qa/baselines') +BREAKPOINTS_JSON=$(common_config_get 'regressionTesting.breakpoints' '{"mobile":375,"desktop":1440}') +WAIT_MS=$(common_config_get 'regressionTesting.waitAfterLoadMs' 1500) +FULL_PAGE=$(common_config_get 'regressionTesting.fullPage' true) +# Routes default uses a CLI fallback; common_config_get returns the raw JSON +# array when present, so collapse it back to CSV. +CONFIG_ROUTES_RAW=$(common_config_get 'regressionTesting.routes' '["/"]') +CONFIG_ROUTES=$(node -e "try { console.log(JSON.parse(process.argv[1]).join(',')); } catch { console.log(process.argv[1]); }" "$CONFIG_ROUTES_RAW") +BROWSERS_JSON=$(common_config_get 'regressionTesting.browsers' '["chromium"]') ROUTES="${ROUTES_ARG:-$CONFIG_ROUTES}" @@ -73,6 +68,7 @@ fi # --- Capture screenshots --- TEMP_SCRIPT=$(mktemp /tmp/capture-baselines-XXXXXX.mjs) +common_track_tmpfile "$TEMP_SCRIPT" cat > "$TEMP_SCRIPT" << 'SCRIPT_EOF' import { chromium, firefox, webkit } from '@playwright/test'; import { mkdirSync } from 'fs'; @@ -135,8 +131,6 @@ npx playwright test --version > /dev/null 2>&1 || true node "$TEMP_SCRIPT" "$URL" "$BASELINE_DIR" "$BREAKPOINTS_JSON" "$WAIT_MS" "$FULL_PAGE" "$ROUTES" "$BROWSERS_JSON" EXIT_CODE=$? -rm -f "$TEMP_SCRIPT" - if [ $EXIT_CODE -eq 0 ]; then echo "" echo "=== Baselines saved to $BASELINE_DIR ===" diff --git a/scripts/check-dark-mode.sh b/scripts/check-dark-mode.sh index 9b4ed16..e75fc5b 100644 --- a/scripts/check-dark-mode.sh +++ b/scripts/check-dark-mode.sh @@ -13,7 +13,10 @@ set -e -PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" +PROJECT_ROOT="$(common_project_root)" URL="http://localhost:3000" CUSTOM_OUTPUT_DIR="" @@ -36,7 +39,7 @@ while [ $# -gt 0 ]; do esac done -# Read config from pipeline.config.json using node +# Read config from pipeline.config.json CONFIG_FILE="$PROJECT_ROOT/.claude/pipeline.config.json" if [ ! -f "$CONFIG_FILE" ]; then @@ -44,10 +47,10 @@ if [ ! -f "$CONFIG_FILE" ]; then exit 2 fi -DARK_ENABLED=$(node -e "const c=JSON.parse(require('fs').readFileSync('$CONFIG_FILE','utf-8'));console.log(c.darkMode?.enabled ?? true)") -DIFF_THRESHOLD=$(node -e "const c=JSON.parse(require('fs').readFileSync('$CONFIG_FILE','utf-8'));console.log(c.darkMode?.diffThreshold ?? 0.03)") -DARK_SCREENSHOT_DIR=$(node -e "const c=JSON.parse(require('fs').readFileSync('$CONFIG_FILE','utf-8'));console.log(c.darkMode?.screenshotDir ?? '.claude/visual-qa/screenshots/dark')") -BREAKPOINTS_JSON=$(node -e "const c=JSON.parse(require('fs').readFileSync('$CONFIG_FILE','utf-8'));const bp=c.visualDiff?.breakpoints||{mobile:375,tablet:768,desktop:1440,wide:1920};console.log(JSON.stringify(bp))") +DARK_ENABLED=$(common_config_get 'darkMode.enabled' true) +DIFF_THRESHOLD=$(common_config_get 'darkMode.diffThreshold' 0.03) +DARK_SCREENSHOT_DIR=$(common_config_get 'darkMode.screenshotDir' '.claude/visual-qa/screenshots/dark') +BREAKPOINTS_JSON=$(common_config_get 'visualDiff.breakpoints' '{"mobile":375,"tablet":768,"desktop":1440,"wide":1920}') # Check if dark mode is disabled if [ "$DARK_ENABLED" = "false" ]; then @@ -87,6 +90,7 @@ echo "" # Generate Playwright script for dark mode capture SCRIPT_FILE=$(mktemp /tmp/playwright-dark-mode-XXXXXX.mjs) +common_track_tmpfile "$SCRIPT_FILE" cat > "$SCRIPT_FILE" << SCRIPT import { chromium } from 'playwright'; @@ -136,9 +140,6 @@ echo "" node "$SCRIPT_FILE" CAPTURE_EXIT=$? -# Cleanup temp script -rm -f "$SCRIPT_FILE" - if [ $CAPTURE_EXIT -ne 0 ]; then echo "" echo "Error: Dark mode screenshot capture failed" diff --git a/scripts/check-dead-code.sh b/scripts/check-dead-code.sh index 6b51ae7..4726c84 100644 --- a/scripts/check-dead-code.sh +++ b/scripts/check-dead-code.sh @@ -3,8 +3,9 @@ # Exit codes: 0=no dead code, 1=dead code found set -euo pipefail -PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "$PROJECT_ROOT" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" +cd "$(common_project_root)" # --- Flags --- JSON_OUTPUT=false @@ -26,19 +27,7 @@ done # --- Check if dead code detection is enabled in pipeline config --- CONFIG_FILE=".claude/pipeline.config.json" -ENABLED=true - -if [[ -f "$CONFIG_FILE" ]]; then - ENABLED=$(node -e " - const fs = require('fs'); - try { - const config = JSON.parse(fs.readFileSync('$CONFIG_FILE', 'utf8')); - console.log(config.deadCode?.enabled !== false ? 'true' : 'false'); - } catch (e) { - console.log('true'); - } - " 2>/dev/null || echo "true") -fi +ENABLED=$(common_config_get 'deadCode.enabled' true) if [[ "$ENABLED" == "false" ]]; then if $JSON_OUTPUT; then @@ -80,7 +69,7 @@ if ! $JSON_OUTPUT; then fi KNIP_OUTPUT_FILE=$(mktemp) -trap 'rm -f "$KNIP_OUTPUT_FILE"' EXIT +common_track_tmpfile "$KNIP_OUTPUT_FILE" KNIP_EXIT=0 if $JSON_OUTPUT; then diff --git a/scripts/check-responsive.sh b/scripts/check-responsive.sh index db5925b..d6f121f 100644 --- a/scripts/check-responsive.sh +++ b/scripts/check-responsive.sh @@ -3,8 +3,10 @@ # Exit codes: 0=success, 1=error (Playwright not available, capture failed) set -euo pipefail -PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "$PROJECT_ROOT" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" +cd "$(common_project_root)" # --- Args --- URL="${1:-http://localhost:3000}" @@ -25,24 +27,12 @@ echo "=== Responsive Screenshot Capture ===" echo "" # --- Read breakpoints from pipeline config --- -CONFIG_FILE=".claude/pipeline.config.json" FALLBACK_BREAKPOINTS='{"small-mobile":320,"mobile":375,"tablet":768,"desktop":1440,"wide":1920}' - -BREAKPOINTS_JSON=$(node -e " - const fs = require('fs'); - const fallback = $FALLBACK_BREAKPOINTS; - try { - const config = JSON.parse(fs.readFileSync('$CONFIG_FILE', 'utf8')); - const bp = config.visualDiff?.breakpoints; - if (bp && Object.keys(bp).length > 0) { - console.log(JSON.stringify(bp)); - } else { - console.log(JSON.stringify(fallback)); - } - } catch (e) { - console.log(JSON.stringify(fallback)); - } -" 2>/dev/null || echo "$FALLBACK_BREAKPOINTS") +BREAKPOINTS_JSON=$(common_config_get 'visualDiff.breakpoints' "$FALLBACK_BREAKPOINTS") +# Treat empty object as "use fallback" to preserve old behaviour. +if [[ "$BREAKPOINTS_JSON" == "{}" ]]; then + BREAKPOINTS_JSON="$FALLBACK_BREAKPOINTS" +fi echo "▸ URL: $URL" echo "▸ Output: $OUTPUT_DIR" @@ -71,7 +61,7 @@ fi # --- Generate temporary Playwright script --- TEMP_SCRIPT=$(mktemp --suffix=.mjs) -trap 'rm -f "$TEMP_SCRIPT"' EXIT +common_track_tmpfile "$TEMP_SCRIPT" cat > "$TEMP_SCRIPT" <<'PLAYWRIGHT_EOF' import { chromium } from 'playwright'; diff --git a/scripts/check-security.sh b/scripts/check-security.sh index fd2772f..431874b 100644 --- a/scripts/check-security.sh +++ b/scripts/check-security.sh @@ -3,8 +3,9 @@ # Exit codes: 0=no issues, 1=issues found (unless --no-fail) set -euo pipefail -PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "$PROJECT_ROOT" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" +cd "$(common_project_root)" # --- Flags --- JSON_OUTPUT=false @@ -38,41 +39,9 @@ done # --- Read config from pipeline.config.json --- CONFIG_FILE=".claude/pipeline.config.json" -ENABLED=true -CONFIG_AUDIT_LEVEL="moderate" -FAIL_ON_VULN=true - -if [[ -f "$CONFIG_FILE" ]]; then - ENABLED=$(node -e " - const fs = require('fs'); - try { - const config = JSON.parse(fs.readFileSync('$CONFIG_FILE', 'utf8')); - console.log(config.security?.enabled !== false ? 'true' : 'false'); - } catch (e) { - console.log('true'); - } - " 2>/dev/null || echo "true") - - CONFIG_AUDIT_LEVEL=$(node -e " - const fs = require('fs'); - try { - const config = JSON.parse(fs.readFileSync('$CONFIG_FILE', 'utf8')); - console.log(config.security?.auditLevel || 'moderate'); - } catch (e) { - console.log('moderate'); - } - " 2>/dev/null || echo "moderate") - - FAIL_ON_VULN=$(node -e " - const fs = require('fs'); - try { - const config = JSON.parse(fs.readFileSync('$CONFIG_FILE', 'utf8')); - console.log(config.security?.failOnVulnerability !== false ? 'true' : 'false'); - } catch (e) { - console.log('true'); - } - " 2>/dev/null || echo "true") -fi +ENABLED=$(common_config_get 'security.enabled' true) +CONFIG_AUDIT_LEVEL=$(common_config_get 'security.auditLevel' moderate) +FAIL_ON_VULN=$(common_config_get 'security.failOnVulnerability' true) if [[ "$ENABLED" == "false" ]]; then if $JSON_OUTPUT; then @@ -95,14 +64,7 @@ ISSUES=0 AUDIT_RESULTS="" OUTDATED_RESULTS="" -# --- Temp file cleanup --- -TMPFILES=() -cleanup() { - for f in "${TMPFILES[@]}"; do - rm -f "$f" - done -} -trap cleanup EXIT +# --- Temp file cleanup (handled by common.sh) --- if ! $JSON_OUTPUT; then echo "=== Security Audit ===" @@ -117,7 +79,7 @@ if ! $JSON_OUTPUT; then fi AUDIT_TMPFILE=$(mktemp) -TMPFILES+=("$AUDIT_TMPFILE") +common_track_tmpfile "$AUDIT_TMPFILE" AUDIT_EXIT=0 pnpm audit --audit-level "$AUDIT_LEVEL" > "$AUDIT_TMPFILE" 2>&1 || AUDIT_EXIT=$? @@ -163,7 +125,7 @@ if [[ ${#SRC_DIRS[@]} -gt 0 ]]; then # Exclude process.env / import.meta.env references (not hardcoded) # Exclude .test. and .spec. files, type definitions, and comments SECRET_TMPFILE=$(mktemp) - TMPFILES+=("$SECRET_TMPFILE") + common_track_tmpfile "$SECRET_TMPFILE" grep -rnE "(API_KEY|SECRET|PASSWORD|TOKEN|PRIVATE_KEY)\s*[:=]\s*['\"][^'\"]{8,}['\"]" "${SRC_DIRS[@]}" \ --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" \ @@ -186,7 +148,7 @@ if [[ ${#SRC_DIRS[@]} -gt 0 ]]; then # 2b: dangerouslySetInnerHTML usage DANGER_TMPFILE=$(mktemp) - TMPFILES+=("$DANGER_TMPFILE") + common_track_tmpfile "$DANGER_TMPFILE" grep -rnE 'dangerouslySetInnerHTML' "${SRC_DIRS[@]}" \ --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" \ @@ -204,7 +166,7 @@ if [[ ${#SRC_DIRS[@]} -gt 0 ]]; then # 2c: eval() usage EVAL_TMPFILE=$(mktemp) - TMPFILES+=("$EVAL_TMPFILE") + common_track_tmpfile "$EVAL_TMPFILE" grep -rnE '\beval\s*\(' "${SRC_DIRS[@]}" \ --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" \ @@ -267,7 +229,7 @@ if ! $JSON_OUTPUT; then fi OUTDATED_TMPFILE=$(mktemp) -TMPFILES+=("$OUTDATED_TMPFILE") +common_track_tmpfile "$OUTDATED_TMPFILE" OUTDATED_EXIT=0 pnpm outdated > "$OUTDATED_TMPFILE" 2>&1 || OUTDATED_EXIT=$? diff --git a/scripts/check-types.sh b/scripts/check-types.sh index b3dcf0e..338a2d6 100644 --- a/scripts/check-types.sh +++ b/scripts/check-types.sh @@ -8,12 +8,16 @@ set -euo pipefail # ./scripts/check-types.sh # Standard type check # ./scripts/check-types.sh --strict # Also check with strict mode enabled +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" + STRICT_MODE=false if [[ "${1:-}" == "--strict" ]]; then STRICT_MODE=true fi -echo "=== TypeScript Type Check ===" +say_banner "TypeScript Type Check" echo "" # Check for tsconfig.json @@ -43,6 +47,7 @@ echo "Running tsc --noEmit..." echo "" TSC_OUTPUT_FILE=$(mktemp) +common_track_tmpfile "$TSC_OUTPUT_FILE" EXIT_CODE=0 if $RUNNER tsc --noEmit 2>&1 | tee "$TSC_OUTPUT_FILE"; then @@ -63,6 +68,7 @@ if [[ "$STRICT_MODE" == true ]]; then echo "" STRICT_OUTPUT_FILE=$(mktemp) + common_track_tmpfile "$STRICT_OUTPUT_FILE" if $RUNNER tsc --noEmit --strict 2>&1 | tee "$STRICT_OUTPUT_FILE"; then echo "" @@ -73,12 +79,8 @@ if [[ "$STRICT_MODE" == true ]]; then echo "" echo "Strict mode errors: ${STRICT_ERROR_COUNT}" fi - - rm -f "$STRICT_OUTPUT_FILE" fi -rm -f "$TSC_OUTPUT_FILE" - echo "" -echo "=== Type Check Complete ===" +say_banner "Type Check Complete" exit $EXIT_CODE diff --git a/scripts/incremental-build.sh b/scripts/incremental-build.sh index 4b1fd24..52b8eb0 100644 --- a/scripts/incremental-build.sh +++ b/scripts/incremental-build.sh @@ -16,14 +16,10 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[0;33m' -BLUE='\033[0;34m' -MAGENTA='\033[0;35m' -CYAN='\033[0;36m' -NC='\033[0m' # No Color +# shellcheck source=lib/colors.sh +source "$SCRIPT_DIR/lib/colors.sh" +# shellcheck source=lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" # Configuration CACHE_SCRIPT="$SCRIPT_DIR/pipeline-cache.js" diff --git a/scripts/lib/colors.sh b/scripts/lib/colors.sh new file mode 100644 index 0000000..ff54d3b --- /dev/null +++ b/scripts/lib/colors.sh @@ -0,0 +1,37 @@ +# colors.sh — ANSI terminal color constants +# +# Source this file to get color variables. Define COLORS_DISABLE=1 (or set +# NO_COLOR per the no-color.org convention) to emit empty strings instead of +# escape codes, so output stays clean when piped or written to logs. +# +# Usage: +# source "$SCRIPT_DIR/lib/colors.sh" +# echo -e "${RED}error${NC}" + +# Avoid redefining if sourced multiple times. +if [[ -n "${__COLORS_SH_LOADED:-}" ]]; then + return 0 2>/dev/null || true +fi +__COLORS_SH_LOADED=1 + +if [[ -n "${NO_COLOR:-}" ]] || [[ -n "${COLORS_DISABLE:-}" ]] || [[ ! -t 1 ]]; then + RED='' + GREEN='' + YELLOW='' + BLUE='' + MAGENTA='' + CYAN='' + BOLD='' + DIM='' + NC='' +else + RED=$'\033[0;31m' + GREEN=$'\033[0;32m' + YELLOW=$'\033[0;33m' + BLUE=$'\033[0;34m' + MAGENTA=$'\033[0;35m' + CYAN=$'\033[0;36m' + BOLD=$'\033[1m' + DIM=$'\033[2m' + NC=$'\033[0m' +fi diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh new file mode 100644 index 0000000..941382a --- /dev/null +++ b/scripts/lib/common.sh @@ -0,0 +1,177 @@ +# common.sh — Shared bash helpers for scripts/*.sh +# +# Source this from any script: +# +# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# source "$SCRIPT_DIR/lib/common.sh" +# +# Helpers preserve the exact output format already used across the codebase +# (=== Banners ===, ▸ steps, ✓ pass, ✗ fail, ⊘ skip, ⚠ warn). Existing tests +# assert these strings — do not change them. + +# Avoid redefining if sourced more than once (e.g. by lib scripts that source +# each other). +if [[ -n "${__COMMON_SH_LOADED:-}" ]]; then + return 0 2>/dev/null || true +fi +__COMMON_SH_LOADED=1 + +# --- Project root --------------------------------------------------------- + +# Resolve the repository root by walking up from this lib file. Scripts can +# either `cd "$(common_project_root)"` (matches `cd "$PROJECT_ROOT"` callers) +# or just read the value. +common_project_root() { + local lib_dir + lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + (cd "$lib_dir/../.." && pwd) +} + +# --- Output helpers ------------------------------------------------------- +# +# These mirror the prefixes already in use. Keep them ASCII-safe-prefixed: +# tests grep for literal "=== Title ===", "▸ ", "✓ ", "✗ ", "⊘ ", "⚠ ". + +say_banner() { + echo "=== $* ===" +} + +say_step() { + echo "▸ $*" +} + +say_pass() { + echo " ✓ $*" +} + +say_fail() { + echo " ✗ $*" +} + +say_skip() { + echo " ⊘ $*" +} + +say_warn() { + echo " ⚠ $*" +} + +say_err() { + echo "Error: $*" >&2 +} + +# --- Tool availability ---------------------------------------------------- + +# require_cmd [install hint] +# Exits with status 1 if the command is not available. +require_cmd() { + local cmd="$1" + local hint="${2:-}" + if ! command -v "$cmd" >/dev/null 2>&1; then + say_err "$cmd not found on PATH." + if [[ -n "$hint" ]]; then + echo " Install it with: $hint" >&2 + fi + exit 1 + fi +} + +# have_cmd — returns 0/1 without exiting, useful for conditional logic. +have_cmd() { + command -v "$1" >/dev/null 2>&1 +} + +# --- Tempfile tracking ---------------------------------------------------- +# +# Pattern (note: cannot wrap mktemp in a function because $( ) subshells run +# the EXIT trap and would delete the file before the caller gets it back): +# +# F=$(mktemp); common_track_tmpfile "$F" +# D=$(mktemp -d); common_track_tmpfile "$D" +# +# An EXIT trap is registered exactly once on the first call. + +__COMMON_TMPFILES=() +__COMMON_TRAP_SET=0 + +common_cleanup_tmpfiles() { + local f + for f in "${__COMMON_TMPFILES[@]+"${__COMMON_TMPFILES[@]}"}"; do + [[ -e "$f" ]] && rm -rf "$f" + done +} + +common_track_tmpfile() { + __COMMON_TMPFILES+=("$1") + if [[ "$__COMMON_TRAP_SET" -eq 0 ]]; then + trap common_cleanup_tmpfiles EXIT + __COMMON_TRAP_SET=1 + fi +} + +# --- Cross-platform timestamp -------------------------------------------- + +# Milliseconds since epoch. GNU date supports %N; macOS does not. +common_now_ms() { + if date +%s%3N >/dev/null 2>&1; then + date +%s%3N + elif have_cmd python3; then + python3 -c 'import time; print(int(time.time()*1000))' + else + echo "$(( $(date +%s) * 1000 ))" + fi +} + +# --- CSV helpers ---------------------------------------------------------- + +# common_csv_contains "a,b,c" "b" → 0/1 +common_csv_contains() { + local csv="$1" + local needle="$2" + [[ -z "$csv" ]] && return 1 + local parts part + IFS=',' read -ra parts <<< "$csv" + for part in "${parts[@]}"; do + [[ "$(echo "$part" | tr -d ' ')" == "$needle" ]] && return 0 + done + return 1 +} + +# --- Build artifact detection -------------------------------------------- + +# Returns 0 if any of dist/.next/build/out exists in the cwd. +common_build_artifact_exists() { + local d + for d in dist .next build out; do + [[ -d "$d" ]] && return 0 + done + return 1 +} + +# --- pipeline.config.json access ----------------------------------------- +# +# Thin wrapper around scripts/lib/pipeline-config.js so shell scripts no longer +# need inline `node -e` blobs. +# +# common_config_get [default] +# +# Examples: +# ENABLED=$(common_config_get 'security.enabled' true) +# LEVEL=$(common_config_get 'security.auditLevel' moderate) + +common_config_get() { + local key="$1" + local default="${2-}" + local lib_dir + lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + local helper="$lib_dir/pipeline-config.js" + if [[ ! -f "$helper" ]]; then + echo "$default" + return 0 + fi + if ! have_cmd node; then + echo "$default" + return 0 + fi + node "$helper" get "$key" "$default" 2>/dev/null || echo "$default" +} diff --git a/scripts/lib/pipeline-config.js b/scripts/lib/pipeline-config.js new file mode 100644 index 0000000..1b3fe84 --- /dev/null +++ b/scripts/lib/pipeline-config.js @@ -0,0 +1,110 @@ +#!/usr/bin/env node +/** + * pipeline-config.js — Read values from .claude/pipeline.config.json + * + * Used by shell scripts that previously inlined `node -e` blobs to fetch + * config keys. Designed to be safe to call from any cwd: the config file is + * resolved relative to the repo root (the parent of scripts/). + * + * Library use (ESM): + * + * import { getValue, loadConfig } from "./lib/pipeline-config.js"; + * const level = getValue("security.auditLevel", "moderate"); + * + * CLI use (from common.sh `common_config_get`): + * + * node scripts/lib/pipeline-config.js get [default] + * + * Exit code is always 0 — the default value is printed if anything fails so + * callers can rely on `VALUE=$(...)` without `set -e` blowing up. + */ +import { readFileSync, existsSync, statSync } from "fs"; +import { fileURLToPath } from "url"; +import { dirname, join, resolve } from "path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** Repo root = scripts/lib/.. /.. */ +const REPO_ROOT = resolve(__dirname, "..", ".."); +const CONFIG_PATH = join(REPO_ROOT, ".claude", "pipeline.config.json"); + +let cachedConfig = null; +let cachedMtimeMs = null; + +/** + * Load pipeline.config.json. Returns {} when the file is missing or invalid + * so callers can rely on optional-chaining without crashing. + */ +export function loadConfig(configPath = CONFIG_PATH) { + if (!existsSync(configPath)) return {}; + try { + // Cheap mtime-based cache for repeated calls in the same process. + let mtime; + try { + mtime = statSync(configPath).mtimeMs; + } catch { + mtime = null; + } + if (cachedConfig && cachedMtimeMs === mtime) return cachedConfig; + + const raw = readFileSync(configPath, "utf8"); + cachedConfig = JSON.parse(raw); + cachedMtimeMs = mtime; + return cachedConfig; + } catch { + return {}; + } +} + +/** + * Look up a dotted path in the config. Missing keys → defaultValue. + * + * Boolean defaults are honored exactly — passing `false` returns `false` when + * the key is absent, which matches the legacy `!== false ? 'true' : 'false'` + * pattern that scripts used inline. + */ +export function getValue(dottedPath, defaultValue = undefined) { + const config = loadConfig(); + const parts = String(dottedPath).split(".").filter(Boolean); + let cur = config; + for (const p of parts) { + if (cur === null || typeof cur !== "object" || !(p in cur)) { + return defaultValue; + } + cur = cur[p]; + } + return cur === undefined ? defaultValue : cur; +} + +/** + * Render a value for shell consumption. Booleans become "true"/"false" + * literals (matches existing scripts), nullish becomes the default, objects + * are JSON-stringified. + */ +function renderForShell(value, fallback) { + if (value === undefined || value === null) return fallback ?? ""; + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +function main(argv) { + const [cmd, key, fallback] = argv; + if (cmd !== "get" || !key) { + process.stderr.write("Usage: pipeline-config.js get [default]\n"); + // Print whatever fallback was given so set -e callers don't break. + process.stdout.write(`${fallback ?? ""}\n`); + return; + } + const v = getValue(key, fallback); + process.stdout.write(`${renderForShell(v, fallback)}\n`); +} + +// Run as CLI when invoked directly. +const invokedDirect = process.argv[1] && resolve(process.argv[1]) === __filename; +if (invokedDirect) { + main(process.argv.slice(2)); +} + +export default { loadConfig, getValue }; diff --git a/scripts/regression-test.sh b/scripts/regression-test.sh index 9ba01cf..2159b3d 100755 --- a/scripts/regression-test.sh +++ b/scripts/regression-test.sh @@ -4,8 +4,10 @@ # Exit codes: 0=pass, 1=regression detected, 2=error set -euo pipefail -PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "$PROJECT_ROOT" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" +cd "$(common_project_root)" # --- Args --- URL="${1:-http://localhost:3000}" @@ -37,32 +39,18 @@ echo "=== Visual Regression Test ===" echo "" # --- Read config --- -CONFIG_FILE=".claude/pipeline.config.json" -if [ -f "$CONFIG_FILE" ] && command -v node &> /dev/null; then - BASELINE_DIR=$(node -e "const c=JSON.parse(require('fs').readFileSync('$CONFIG_FILE','utf-8'));console.log(c.regressionTesting?.baselineDir||'.claude/visual-qa/baselines')") - SCREENSHOT_DIR=$(node -e "const c=JSON.parse(require('fs').readFileSync('$CONFIG_FILE','utf-8'));console.log(c.regressionTesting?.screenshotDir||'.claude/visual-qa/screenshots/regression')") - DIFF_DIR=$(node -e "const c=JSON.parse(require('fs').readFileSync('$CONFIG_FILE','utf-8'));console.log(c.regressionTesting?.diffDir||'.claude/visual-qa/diffs/regression')") - THRESHOLD=$(node -e "const c=JSON.parse(require('fs').readFileSync('$CONFIG_FILE','utf-8'));console.log(c.regressionTesting?.threshold||0.02)") - FAIL_ON_MISSING=$(node -e "const c=JSON.parse(require('fs').readFileSync('$CONFIG_FILE','utf-8'));console.log(c.regressionTesting?.failOnMissingBaseline?'true':'false')") - REPORT_FILE=$(node -e "const c=JSON.parse(require('fs').readFileSync('$CONFIG_FILE','utf-8'));console.log(c.regressionTesting?.reportFile||'regression-report.md')") - BREAKPOINTS_JSON=$(node -e "const c=JSON.parse(require('fs').readFileSync('$CONFIG_FILE','utf-8'));console.log(JSON.stringify(c.regressionTesting?.breakpoints||{mobile:375,desktop:1440}))") - WAIT_MS=$(node -e "const c=JSON.parse(require('fs').readFileSync('$CONFIG_FILE','utf-8'));console.log(c.regressionTesting?.waitAfterLoadMs||1500)") - FULL_PAGE=$(node -e "const c=JSON.parse(require('fs').readFileSync('$CONFIG_FILE','utf-8'));console.log(c.regressionTesting?.fullPage!==false?'true':'false')") - CONFIG_ROUTES=$(node -e "const c=JSON.parse(require('fs').readFileSync('$CONFIG_FILE','utf-8'));console.log((c.regressionTesting?.routes||['/']).join(','))") - BROWSERS_JSON=$(node -e "const c=JSON.parse(require('fs').readFileSync('$CONFIG_FILE','utf-8'));console.log(JSON.stringify(c.regressionTesting?.browsers||['chromium']))") -else - BASELINE_DIR=".claude/visual-qa/baselines" - SCREENSHOT_DIR=".claude/visual-qa/screenshots/regression" - DIFF_DIR=".claude/visual-qa/diffs/regression" - THRESHOLD=0.02 - FAIL_ON_MISSING=false - REPORT_FILE="regression-report.md" - BREAKPOINTS_JSON='{"mobile":375,"desktop":1440}' - WAIT_MS=1500 - FULL_PAGE="true" - CONFIG_ROUTES="/" - BROWSERS_JSON='["chromium"]' -fi +BASELINE_DIR=$(common_config_get 'regressionTesting.baselineDir' '.claude/visual-qa/baselines') +SCREENSHOT_DIR=$(common_config_get 'regressionTesting.screenshotDir' '.claude/visual-qa/screenshots/regression') +DIFF_DIR=$(common_config_get 'regressionTesting.diffDir' '.claude/visual-qa/diffs/regression') +THRESHOLD=$(common_config_get 'regressionTesting.threshold' 0.02) +FAIL_ON_MISSING=$(common_config_get 'regressionTesting.failOnMissingBaseline' false) +REPORT_FILE=$(common_config_get 'regressionTesting.reportFile' 'regression-report.md') +BREAKPOINTS_JSON=$(common_config_get 'regressionTesting.breakpoints' '{"mobile":375,"desktop":1440}') +WAIT_MS=$(common_config_get 'regressionTesting.waitAfterLoadMs' 1500) +FULL_PAGE=$(common_config_get 'regressionTesting.fullPage' true) +CONFIG_ROUTES_RAW=$(common_config_get 'regressionTesting.routes' '["/"]') +CONFIG_ROUTES=$(node -e "try { console.log(JSON.parse(process.argv[1]).join(',')); } catch { console.log(process.argv[1]); }" "$CONFIG_ROUTES_RAW") +BROWSERS_JSON=$(common_config_get 'regressionTesting.browsers' '["chromium"]') REPORT_PATH=".claude/visual-qa/$REPORT_FILE" @@ -95,6 +83,7 @@ rm -rf "$SCREENSHOT_DIR" 2>/dev/null || true mkdir -p "$SCREENSHOT_DIR" TEMP_SCRIPT=$(mktemp /tmp/regression-capture-XXXXXX.mjs) +common_track_tmpfile "$TEMP_SCRIPT" cat > "$TEMP_SCRIPT" << 'SCRIPT_EOF' import { chromium, firefox, webkit } from '@playwright/test'; import { mkdirSync } from 'fs'; @@ -146,7 +135,6 @@ SCRIPT_EOF node "$TEMP_SCRIPT" "$URL" "$SCREENSHOT_DIR" "$BREAKPOINTS_JSON" "$WAIT_MS" "$FULL_PAGE" "$CONFIG_ROUTES" "$BROWSERS_JSON" CAPTURE_EXIT=$? -rm -f "$TEMP_SCRIPT" if [ "$CAPTURE_EXIT" -ne 0 ]; then echo "ERROR: Screenshot capture failed" diff --git a/scripts/verify-all.sh b/scripts/verify-all.sh index 5e56434..c0ec402 100644 --- a/scripts/verify-all.sh +++ b/scripts/verify-all.sh @@ -23,6 +23,10 @@ set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" + # Run relative to the caller's cwd (expected to be the repo root). This lets # tests run against fixture directories without forcing themselves into the # real project tree, and matches how the individual check scripts behave. @@ -73,39 +77,18 @@ if [[ "$LIST_ONLY" == "true" ]]; then fi # --- Filtering --- -csv_contains() { - local csv="$1" - local needle="$2" - [[ -z "$csv" ]] && return 1 - IFS=',' read -ra parts <<< "$csv" - for p in "${parts[@]}"; do - [[ "$(echo "$p" | tr -d ' ')" == "$needle" ]] && return 0 - done - return 1 -} - should_run() { local name="$1" if [[ -n "$INCLUDE_LIST" ]]; then - csv_contains "$INCLUDE_LIST" "$name" && return 0 + common_csv_contains "$INCLUDE_LIST" "$name" && return 0 return 1 fi if [[ -n "$SKIP_LIST" ]]; then - csv_contains "$SKIP_LIST" "$name" && return 1 + common_csv_contains "$SKIP_LIST" "$name" && return 1 fi return 0 } -# --- Conditional gates --- -build_artifact_exists() { - for d in dist .next build out; do - if [[ -d "$d" ]]; then - return 0 - fi - done - return 1 -} - # --- Runner --- RESULTS_NAME=() RESULTS_STATUS=() @@ -113,17 +96,6 @@ RESULTS_EXIT=() RESULTS_MS=() RESULTS_REASON=() -now_ms() { - # GNU date supports %N; macOS date does not. Fall back to Python or seconds*1000. - if date +%s%3N >/dev/null 2>&1; then - date +%s%3N - elif command -v python3 >/dev/null 2>&1; then - python3 -c 'import time; print(int(time.time()*1000))' - else - echo "$(( $(date +%s) * 1000 ))" - fi -} - emit_progress() { $JSON_OUTPUT && return 0 echo "$@" @@ -143,7 +115,7 @@ run_check() { return 0 fi - if [[ "$name" == "bundle-size" ]] && ! build_artifact_exists; then + if [[ "$name" == "bundle-size" ]] && ! common_build_artifact_exists; then RESULTS_NAME+=("$name") RESULTS_STATUS+=("skip") RESULTS_EXIT+=("0") @@ -165,7 +137,7 @@ run_check() { emit_progress "▸ $name …" local start - start="$(now_ms)" + start="$(common_now_ms)" local exit_code=0 if [[ -n "$args" ]]; then bash "$script" $args >/dev/null 2>&1 || exit_code=$? @@ -173,7 +145,7 @@ run_check() { bash "$script" >/dev/null 2>&1 || exit_code=$? fi local end - end="$(now_ms)" + end="$(common_now_ms)" local duration_ms=$((end - start)) RESULTS_NAME+=("$name")