regress.sh — один вход: preflight (main = origin/main, чистые, нет чужих потребителей NATS), venv, lint+unit всех репозиториев и docs make check, фронт в чистой копии (npm ci --dry-run как проверка lock, установка без lock), интеграционные с циклом миграций, реальные данные парсера, образы, e2e ядра, изолированный стенд с нуля (compose-проект arm-ka-regress на своих портах) со сценарием по API, UI-смоук Playwright, парсер по doc-bank. Не останавливается на первой ошибке; итог — summary.md с SHA и статусом шагов. Контрольные цифры — expected.json (qa-guide §7.1–7.3, прогоны 08.10/14.10, golden шахматки); снимки свода, шахматки и форм сравниваются с прошлым прогоном. Ссылки из dev/checks.md и чек-листа синхронизации контура. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
310 lines
16 KiB
Bash
Executable File
310 lines
16 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# Полная регрессия main всех репозиториев workspace перед выкладкой (arm-ka-docs#179). Описание — README.md рядом.
|
||
#
|
||
# scripts/regression/regress.sh [--out DIR] [--only st1,st2] [--skip st1,st2] [--keep-stand] [--allow-dirty]
|
||
# [--bank-baseline DIR] [--list]
|
||
#
|
||
# Этапы идут по порядку; упавший шаг не останавливает следующие (нужна вся картина), кроме preflight: без чистых
|
||
# main = origin/main прогон не имеет смысла (--allow-dirty — прогнать как есть, с пометкой в итоге).
|
||
# Итог — $OUT/summary.md (SHA репозиториев, статус и время шагов, счётчики pytest, пропуски), логи — $OUT/logs/.
|
||
# Код возврата: 0 — все шаги зелёные, 1 — есть красные, 2 — preflight не пройден или неверные аргументы.
|
||
#
|
||
# Workspace (каталог с arm-core/, tabular-parser/, … на main) — ARM_KA_WORKSPACE, по умолчанию родитель docs. Скрипты
|
||
# можно запускать из worktree docs: тогда задайте ARM_KA_WORKSPACE, проверяются всё равно основные checkout'ы.
|
||
set -uo pipefail
|
||
|
||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||
WS="${ARM_KA_WORKSPACE:-$(cd "$HERE/../../.." && pwd)}"
|
||
COMPOSE_FILE="$WS/docs/dev/docker-compose.yml"
|
||
# Изолированный стенд: свой compose-проект и порты, общий стенд arm-ka (данные, тома) не трогается
|
||
REGRESS_PROJECT="${REGRESS_PROJECT:-arm-ka-regress}"
|
||
# Порты — только для команд этого проекта (dcr): глобальный export пересоздал бы контейнеры arm-ka на чужих портах
|
||
ARM_CORE_PORT=18000
|
||
REGRESS_PORTS="POSTGRES_PORT=15432 NATS_PORT=14222 NATS_MONITOR_PORT=18222 MINIO_API_PORT=19000 MINIO_CONSOLE_PORT=19001
|
||
ARM_CORE_PORT=$ARM_CORE_PORT TABULAR_PARSER_PORT=18001 SCAN_GATEWAY_PORT=18002"
|
||
ALL_STAGES="preflight venv static web integration fixtures images e2e stand ui bank"
|
||
# Репозитории под проверкой; pylib_ai_arm_statements (AURORA-1677) появится здесь после выпуска 0.1.2
|
||
REPOS="docs fixtures arm-statements arm-core tabular-parser scan-gateway"
|
||
|
||
OUT="" ONLY="" SKIP="" KEEP_STAND=0 ALLOW_DIRTY=0 BANK_BASELINE=""
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--out) OUT="$2"; shift 2 ;;
|
||
--only) ONLY="$2"; shift 2 ;;
|
||
--skip) SKIP="$2"; shift 2 ;;
|
||
--keep-stand) KEEP_STAND=1; shift ;;
|
||
--allow-dirty) ALLOW_DIRTY=1; shift ;;
|
||
--bank-baseline) BANK_BASELINE="$2"; shift 2 ;;
|
||
--list) echo "$ALL_STAGES"; exit 0 ;;
|
||
-h | --help) sed -n '2,13p' "$0"; exit 0 ;;
|
||
*) echo "неизвестный аргумент: $1" >&2; exit 2 ;;
|
||
esac
|
||
done
|
||
for s in ${ONLY//,/ } ${SKIP//,/ }; do
|
||
case " $ALL_STAGES " in *" $s "*) ;; *) echo "неизвестный этап: $s (есть: $ALL_STAGES)" >&2; exit 2 ;; esac
|
||
done
|
||
|
||
OUT="${OUT:-$WS/admin/regression/$(date +%Y-%m-%d_%H%M)}"
|
||
mkdir -p "$OUT/logs"
|
||
RESULTS="$OUT/results.tsv"
|
||
export ARM_KA_DOCS="$WS/docs" ARM_KA_FIXTURES="$WS/fixtures"
|
||
# ENV_FILE в окружении перебивает pytest.ini сервисов — не даём просочиться из оболочки
|
||
unset ENV_FILE
|
||
|
||
log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"; }
|
||
|
||
# step <этап> <шаг> <команда…>: вывод — в logs/<этап>.<шаг>.log, строка итога — в results.tsv
|
||
step() {
|
||
local stage=$1 name=$2 t0 rc lf
|
||
shift 2
|
||
lf="$OUT/logs/$stage.$name.log"
|
||
t0=$(date +%s)
|
||
log "$stage/$name …"
|
||
("$@") >"$lf" 2>&1
|
||
rc=$?
|
||
printf '%s\t%s\t%s\t%s\t%s\n' "$stage" "$name" "$rc" "$(($(date +%s) - t0))" "$lf" >>"$RESULTS"
|
||
if [ $rc -eq 0 ]; then log "$stage/$name ok"; else log "$stage/$name КРАСНЫЙ (rc=$rc, $lf)"; fi
|
||
return $rc
|
||
}
|
||
|
||
want() {
|
||
local s=$1
|
||
[ -n "$ONLY" ] && { case ",$ONLY," in *",$s,"*) ;; *) return 1 ;; esac; }
|
||
[ -n "$SKIP" ] && { case ",$SKIP," in *",$s,"*) return 1 ;; esac; }
|
||
return 0
|
||
}
|
||
|
||
dc() { docker compose -f "$COMPOSE_FILE" "$@"; }
|
||
# shellcheck disable=SC2086 — REGRESS_PORTS намеренно раскладывается на слова
|
||
dcr() { env $REGRESS_PORTS docker compose -f "$COMPOSE_FILE" -p "$REGRESS_PROJECT" --profile services "$@"; }
|
||
mk() { make --no-print-directory -C "$WS/$1" "${@:2}"; }
|
||
|
||
# --- preflight -------------------------------------------------------------------------------------------------------
|
||
|
||
repo_state() {
|
||
local r=$1 dir="$WS/$1" br head origin dirty
|
||
[ -d "$dir/.git" ] || [ -f "$dir/.git" ] || { echo "$r: нет репозитория в $dir"; return 1; }
|
||
git -C "$dir" fetch -q origin || { echo "$r: fetch не удался"; return 1; }
|
||
br=$(git -C "$dir" rev-parse --abbrev-ref HEAD)
|
||
head=$(git -C "$dir" rev-parse HEAD)
|
||
origin=$(git -C "$dir" rev-parse origin/main)
|
||
dirty=$(git -C "$dir" status --porcelain --untracked-files=no | wc -l | tr -d ' ')
|
||
printf '%s\t%s\t%s\t%s\n' "$r" "$br" "$head" "$(git -C "$dir" log -1 --format='%cs %s' HEAD | cut -c1-100)" >>"$OUT/shas.tsv"
|
||
echo "$r: ветка $br, HEAD ${head:0:7}, origin/main ${origin:0:7}, изменённых файлов $dirty"
|
||
[ "$br" = main ] && [ "$head" = "$origin" ] && [ "$dirty" = 0 ]
|
||
}
|
||
|
||
st_preflight() {
|
||
: >"$OUT/shas.tsv"
|
||
local bad=0 r
|
||
for r in $REPOS; do step preflight "repo-$r" repo_state "$r" || bad=1; done
|
||
step preflight docker docker info --format '{{.ServerVersion}}' || bad=1
|
||
step preflight tools sh -c 'command -v uv && command -v npm && command -v openspec' || bad=1
|
||
# Чужие потребители общей NATS (arm-ka): контейнеры сервисов и локальное ядро на :8000 перехватывают сообщения
|
||
# queue group, интеграционные тесты и e2e тогда падают по таймауту (scan-gateway — 08.10, arm-core e2e — docstring)
|
||
step preflight shared-consumers sh -c '
|
||
running=$(docker ps --filter label=com.docker.compose.project=arm-ka --format "{{.Names}}" | grep -E "arm-core-1|tabular-parser|scan-gateway|scan-emulator" || true)
|
||
local8000=$(lsof -nP -iTCP:8000 -sTCP:LISTEN 2>/dev/null | tail -n +2 || true)
|
||
[ -z "$running" ] && [ -z "$local8000" ] && { echo "сервисов на общей NATS нет"; exit 0; }
|
||
echo "на общем стенде arm-ka работают потребители NATS — остановите их до integration/e2e:"; echo "$running"; echo "$local8000"; exit 1' || bad=1
|
||
if [ $bad -ne 0 ] && [ $ALLOW_DIRTY -eq 0 ]; then
|
||
log "preflight не пройден — прогон остановлен (--allow-dirty — прогнать как есть)"
|
||
return 2
|
||
fi
|
||
[ $bad -ne 0 ] && echo "allow-dirty" >"$OUT/allow-dirty"
|
||
return 0
|
||
}
|
||
|
||
# --- окружения -------------------------------------------------------------------------------------------------------
|
||
|
||
st_venv() {
|
||
local r
|
||
for r in docs arm-statements tabular-parser scan-gateway arm-core; do step venv "$r" mk "$r" venv; done
|
||
}
|
||
|
||
# --- lint + unit (без инфраструктуры) --------------------------------------------------------------------------------
|
||
|
||
st_static() {
|
||
step static docs-check mk docs check
|
||
step static docs-statements-check mk docs statements-check
|
||
step static arm-statements-lint mk arm-statements lint
|
||
step static arm-statements-test mk arm-statements test
|
||
local r
|
||
for r in tabular-parser scan-gateway arm-core; do
|
||
step static "$r-lint" mk "$r" lint
|
||
step static "$r-test" mk "$r" test
|
||
done
|
||
step static arm-core-dbml-check mk arm-core dbml-check
|
||
}
|
||
|
||
# --- фронт arm-core/web ----------------------------------------------------------------------------------------------
|
||
# В чистой копии web/ (без node_modules и dist) во временном каталоге, main не меняется (npm install в main переписывает
|
||
# package-lock.json, а node_modules main бывают собраны под другую ОС). Шаг npm-ci — воспроизводимость lock: красный,
|
||
# если lock расходится с package.json. Остальные шаги идут на установке без lock (npm-install): lock собран на linux, и
|
||
# по нему npm не ставит нативные пакеты другой ОС (biome, rspack, canvas — npm/cli#4828); так же ставит и контур, в
|
||
# снимке lock нет (dev/contour-transfer-guide.md §3.2).
|
||
|
||
WEB_TMP="" STAND_UP=0
|
||
PLAYWRIGHT="${PLAYWRIGHT:-playwright==1.55.0}"
|
||
web_copy() {
|
||
WEB_TMP=$(mktemp -d "${TMPDIR:-/tmp}/regress-web.XXXXXX")
|
||
rsync -a --exclude node_modules --exclude dist "$WS/arm-core/web/" "$WEB_TMP/"
|
||
echo "$WEB_TMP"
|
||
}
|
||
|
||
web_install() { rm -rf "$1/node_modules" "$1/package-lock.json" && npm --prefix "$1" install --no-audit --no-fund; }
|
||
|
||
st_web() {
|
||
web_copy >/dev/null
|
||
local w="$WEB_TMP"
|
||
echo "$w" >"$OUT/web-tmp"
|
||
step web npm-ci npm --prefix "$w" ci --no-audit --no-fund --dry-run
|
||
step web npm-install web_install "$w"
|
||
step web biome sh -c "cd '$w' && npx biome check src"
|
||
step web tsc sh -c "cd '$w' && npx tsc --noEmit"
|
||
step web eslint-prettier npm --prefix "$w" run lint
|
||
step web vitest npm --prefix "$w" test
|
||
step web build npm --prefix "$w" run build
|
||
}
|
||
|
||
# --- интеграционные (общая инфраструктура arm-ka: тесты создают свои БД, рабочую armka не трогают) ----------------------
|
||
|
||
st_integration() {
|
||
step integration infra-up mk scan-gateway up
|
||
step integration arm-core-check-infra mk arm-core check-infra
|
||
step integration tabular-parser mk tabular-parser test-integration
|
||
step integration scan-gateway mk scan-gateway test-integration
|
||
# включает цикл миграций upgrade → downgrade base → upgrade и сверку с create_all (tests/test_migrations.py)
|
||
step integration arm-core mk arm-core test-integration
|
||
}
|
||
|
||
# --- реальные данные -------------------------------------------------------------------------------------------------
|
||
|
||
st_fixtures() {
|
||
step fixtures tabular-parser-fixtures mk tabular-parser test-fixtures
|
||
step fixtures tabular-parser-golden mk tabular-parser golden-check
|
||
}
|
||
|
||
# --- образы ----------------------------------------------------------------------------------------------------------
|
||
# Сборка профиля services собирает arm-core:dev, tabular-parser:dev, scan-gateway:dev из main (Dockerfile в корне репо);
|
||
# эти же образы потом поднимают e2e и стенд. Эквивалент make docker-build трёх сервисов.
|
||
|
||
st_images() {
|
||
step images build dcr build
|
||
}
|
||
|
||
# --- e2e ядра с настоящим tabular-parser (общая NATS/MinIO из .env.test) ---------------------------------------------
|
||
|
||
e2e_run() {
|
||
local was rc
|
||
was=$(docker ps -q --filter name=arm-ka-tabular-parser-1)
|
||
dc -p arm-ka --profile services up -d --no-build --wait tabular-parser || return 1
|
||
mk arm-core e2e
|
||
rc=$?
|
||
[ -z "$was" ] && dc -p arm-ka --profile services stop tabular-parser
|
||
return $rc
|
||
}
|
||
|
||
st_e2e() {
|
||
step e2e arm-core-osv-ingest e2e_run
|
||
# пропуск («профиль services не поднят») даёт код 0 — его считаем красным
|
||
if grep -q "SKIPPED" "$OUT/logs/e2e.arm-core-osv-ingest.log"; then
|
||
printf 'e2e\tnot-skipped\t1\t0\t%s\n' "$OUT/logs/e2e.arm-core-osv-ingest.log" >>"$RESULTS"
|
||
log "e2e/not-skipped КРАСНЫЙ: e2e пропущен"
|
||
fi
|
||
}
|
||
|
||
# prev_run <подкаталог/файл>: каталог <подкаталог> последнего прошлого прогона рядом с $OUT, где файл есть (база сравнения)
|
||
prev_run() {
|
||
local f
|
||
f=$(find "$(dirname "$OUT")" -mindepth 3 -maxdepth 3 -path "*/$1" ! -path "$OUT/*" 2>/dev/null | sort | tail -1)
|
||
[ -n "$f" ] && dirname "$f"
|
||
}
|
||
|
||
# --- изолированный стенд с нуля: миграции, seed --dev, сценарий по API -----------------------------------------------
|
||
|
||
stand_down() { dcr down -v --remove-orphans; }
|
||
|
||
st_stand() {
|
||
local stand_base
|
||
stand_base=$(prev_run stand/report.json)
|
||
step stand down-before stand_down
|
||
step stand up dcr up -d --no-build --wait \
|
||
postgres nats minio arm-core tabular-parser scan-gateway scan-emulator
|
||
step stand scenario python3 -I "$HERE/stand_scenario.py" --api "http://127.0.0.1:$ARM_CORE_PORT" \
|
||
--fixtures "$WS/fixtures" --expected "$HERE/expected.json" --out "$OUT/stand" \
|
||
${stand_base:+--baseline "$stand_base"}
|
||
STAND_UP=1
|
||
}
|
||
|
||
# Стенд снимается после ui (экраны смотрят на его данные); --keep-stand — оставить для ручной проверки
|
||
stand_teardown() {
|
||
[ "$STAND_UP" = 1 ] || return 0
|
||
dcr logs --no-color >"$OUT/logs/stand.compose.log" 2>&1
|
||
if [ $KEEP_STAND -eq 1 ]; then
|
||
log "стенд оставлен: проект $REGRESS_PROJECT, ядро http://127.0.0.1:$ARM_CORE_PORT (снять: docker compose -p $REGRESS_PROJECT --profile services down -v)"
|
||
else
|
||
step stand down-after stand_down
|
||
fi
|
||
}
|
||
|
||
# --- UI-смоук: dev-сервер фронта (копия web/ из этапа web) против стенда, Playwright по основным экранам --------------
|
||
|
||
WEB_PORT=13000
|
||
st_ui() {
|
||
[ "$STAND_UP" = 1 ] && [ -f "$OUT/stand/report.json" ] || {
|
||
printf 'ui\tstand-required\t1\t0\t-\n' >>"$RESULTS"
|
||
log "ui: стенд не поднят в этом запуске (этап stand) — КРАСНЫЙ"
|
||
return 1
|
||
}
|
||
if [ -z "$WEB_TMP" ]; then
|
||
web_copy >/dev/null
|
||
step ui npm-install web_install "$WEB_TMP"
|
||
fi
|
||
step ui browser uv run --with "$PLAYWRIGHT" playwright install chromium
|
||
(cd "$WEB_TMP" && REACT_APP_ROOT_PATH=/ ARM_CORE_URL="http://127.0.0.1:$ARM_CORE_PORT" \
|
||
exec npx rsbuild dev --port $WEB_PORT) >"$OUT/logs/ui.web-server.log" 2>&1 &
|
||
step ui web-server sh -c "for i in \$(seq 1 60); do curl -sf -o /dev/null http://127.0.0.1:$WEB_PORT/ && exit 0; sleep 2; done;
|
||
echo 'dev-сервер фронта не поднялся за 120 с'; tail -20 '$OUT/logs/ui.web-server.log'; exit 1" || return 1
|
||
step ui smoke uv run --with "$PLAYWRIGHT" python "$HERE/ui_smoke.py" --web "http://127.0.0.1:$WEB_PORT" \
|
||
--api "http://127.0.0.1:$ARM_CORE_PORT" --stand-report "$OUT/stand/report.json" --out "$OUT/ui"
|
||
lsof -ti "tcp:$WEB_PORT" -sTCP:LISTEN | xargs kill 2>/dev/null
|
||
return 0
|
||
}
|
||
|
||
# --- парсер по банку первички (doc-bank) со сравнением с прошлым прогоном --------------------------------------------
|
||
|
||
st_bank() {
|
||
[ -d "$WS/doc-bank/files" ] || { log "bank: нет $WS/doc-bank — этап пропущен"; return 0; }
|
||
local base="${BANK_BASELINE:-$(prev_run bank/results.jsonl)}"
|
||
step bank parse "$WS/tabular-parser/api/.venv/bin/python" -I "$HERE/parser_bank.py" \
|
||
--parser-api "$WS/tabular-parser/api" --bank "$WS/doc-bank" --out "$OUT/bank" ${base:+--baseline "$base"}
|
||
}
|
||
|
||
# --- итог ------------------------------------------------------------------------------------------------------------
|
||
|
||
summary() { python3 -I "$HERE/summary.py" "$OUT" "$ALL_STAGES"; }
|
||
|
||
touch "$RESULTS"
|
||
# Повторный запуск этапов в тот же --out: строки этих этапов из прошлого запуска убираем, остальные остаются в итоге
|
||
for s in $ALL_STAGES; do
|
||
want "$s" || continue
|
||
awk -F'\t' -v s="$s" '$1 != s' "$RESULTS" >"$RESULTS.tmp" && mv "$RESULTS.tmp" "$RESULTS"
|
||
done
|
||
|
||
log "регрессия: workspace $WS, результат $OUT"
|
||
rc=0
|
||
for s in $ALL_STAGES; do
|
||
want "$s" || continue
|
||
"st_$s"
|
||
src=$?
|
||
if [ "$s" = preflight ] && [ $src -eq 2 ]; then rc=2; break; fi
|
||
done
|
||
stand_teardown
|
||
summary
|
||
[ -n "$WEB_TMP" ] && rm -rf "$WEB_TMP"
|
||
# awk завершается кодом 0, если есть красный шаг
|
||
if [ $rc -eq 0 ] && awk -F'\t' '$3 != 0 { bad = 1 } END { exit !bad }' "$RESULTS"; then rc=1; fi
|
||
log "итог: $OUT/summary.md (код $rc)"
|
||
exit $rc
|