2b1d0cf7d1
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
358 lines
16 KiB
Bash
Executable File
358 lines
16 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# Unified E2E runner. Builds what's needed, runs Playwright, and tears down
|
|
# cleanly — on the host or inside the kandev-ci docker image (the same one CI
|
|
# uses). Handles the sharp edges this would otherwise hit by hand:
|
|
#
|
|
# • Auto-selects docker vs host (docker if the daemon is up AND the runtime
|
|
# image is available; override with --docker / --host).
|
|
# • Docker mode builds the backend on the HOST and runs it in the runtime
|
|
# image — a host glibc that's the same or older than the image's (the usual
|
|
# case) is forward-compatible, so no build image is needed. It smoke-tests
|
|
# the binary in the image first and only falls back to the BUILD image
|
|
# (KANDEV_CI_BUILD_IMAGE) if the host glibc is newer. FE Vite builds
|
|
# on the host and are served by the Go backend.
|
|
# • Runs a resource-bounded number of shards concurrently (--shards N):
|
|
# isolated containers in docker mode, or host processes with distinct
|
|
# E2E_PORT_OFFSET + output dirs.
|
|
# • Never leaves root-owned junk in the repo: points normal Playwright output
|
|
# at a container-local dir. With CAPTURE_PR_ASSETS, captures are written to
|
|
# the mounted workspace and ownership is restored after the run. `clean`
|
|
# removes any pre-existing root-owned normal artifacts via a throwaway
|
|
# container.
|
|
#
|
|
# Usage:
|
|
# Direct: run-e2e.sh [run] [options] [-- <playwright args>] # default subcommand: run
|
|
# run-e2e.sh clean # remove build/test artifacts (incl. root-owned)
|
|
# Via pnpm: pnpm e2e:run [options] [-- <playwright args>]
|
|
# pnpm/npm forward a caller-supplied `--` verbatim, so `pnpm e2e:run -- --host`
|
|
# reaches this script as `-- --host`. The package entry point drops that leading
|
|
# artifact before option parsing; direct script calls preserve `--` as the
|
|
# documented Playwright separator. For package calls, write script options
|
|
# directly after `e2e:run` (`pnpm e2e:run --host --no-build -- --grep foo`).
|
|
# A `--` anywhere else still ends option parsing and forwards everything after it
|
|
# straight to Playwright.
|
|
#
|
|
# Options:
|
|
# --docker | --host Force runner (default: auto-detect)
|
|
# --shards N Run the project as N concurrent shards (local limit applies)
|
|
# --no-build Skip the build step (reuse existing artifacts)
|
|
# --no-strict Don't set KANDEV_E2E_WS_ASSERT=1 (it's on by default here, matching CI)
|
|
# --project NAME Playwright project (default: chromium)
|
|
# Anything after `--` (or any unrecognized arg) is passed straight to Playwright,
|
|
# e.g. tests/chat/foo.spec.ts, --grep "name", --repeat-each=3, --workers=1.
|
|
#
|
|
# Env overrides:
|
|
# KANDEV_CI_BUILD_IMAGE (default: ghcr.io/kdlbs/kandev-ci:build-latest)
|
|
# KANDEV_CI_RUNTIME_IMAGE (default: kandev-ci:runtime-local, falls back to ghcr…:runtime-latest)
|
|
# KANDEV_E2E_ALLOW_UNSAFE_PARALLELISM=1 bypass local shard/worker guards for experiments
|
|
# KANDEV_E2E_DOCKER_PROBE_TIMEOUT (default: 10) seconds to wait for `docker info`
|
|
# before treating Docker as unavailable in auto/clean mode
|
|
set -uo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
source "$SCRIPT_DIR/resource-guard.sh"
|
|
|
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
|
|
WEB_DIR="$REPO_ROOT/apps/web"
|
|
BACKEND_DIR="$REPO_ROOT/apps/backend"
|
|
BUILD_IMAGE="${KANDEV_CI_BUILD_IMAGE:-ghcr.io/kdlbs/kandev-ci:build-latest}"
|
|
RUNTIME_IMAGE="${KANDEV_CI_RUNTIME_IMAGE:-}"
|
|
DOCKER_PROBE_TIMEOUT="${KANDEV_E2E_DOCKER_PROBE_TIMEOUT:-10}"
|
|
|
|
log() { printf '\033[36m[e2e]\033[0m %s\n' "$*" >&2; }
|
|
die() { printf '\033[31m[e2e] %s\033[0m\n' "$*" >&2; exit 1; }
|
|
|
|
# Feeds arithmetic in docker_up; validated to reject fractional, suffixed, and
|
|
# whitespace-padded values. 0 is accepted — it means "skip the probe, treat
|
|
# Docker as unavailable". No leading zeros: bash arithmetic reads them as octal,
|
|
# so "08" is a parse error and "010" silently means 8.
|
|
[[ "$DOCKER_PROBE_TIMEOUT" =~ ^(0|[1-9][0-9]*)$ ]] \
|
|
|| die "KANDEV_E2E_DOCKER_PROBE_TIMEOUT must be a non-negative integer (got '$DOCKER_PROBE_TIMEOUT')"
|
|
|
|
# Bounded in pure bash (no `timeout`/`gtimeout` dependency — bare macOS has
|
|
# neither). Backgrounds the probe and kills it if it outlives the budget, so a
|
|
# hung daemon degrades to host mode with a visible reason instead of hanging
|
|
# the whole runner forever.
|
|
docker_up() {
|
|
command -v docker >/dev/null 2>&1 || return 1
|
|
if (( DOCKER_PROBE_TIMEOUT == 0 )); then
|
|
log "docker info did not respond within 0s; treating Docker as unavailable (pass --host to skip this probe)"
|
|
return 1
|
|
fi
|
|
local had_monitor=0
|
|
case "$-" in
|
|
*m*) had_monitor=1 ;;
|
|
esac
|
|
set -m
|
|
docker info >/dev/null 2>&1 &
|
|
local pid=$! max_ticks=$(( DOCKER_PROBE_TIMEOUT * 10 )) tick=0
|
|
[[ "$had_monitor" -eq 1 ]] || set +m
|
|
while kill -0 "$pid" 2>/dev/null; do
|
|
if (( tick >= max_ticks )); then
|
|
# SIGKILL, not SIGTERM: a wedged docker client can be blocked in a way
|
|
# that doesn't honor SIGTERM, and `wait` below blocks until it exits.
|
|
kill -9 -- "-$pid" 2>/dev/null || true
|
|
wait "$pid" 2>/dev/null || true
|
|
log "docker info did not respond within ${DOCKER_PROBE_TIMEOUT}s; treating Docker as unavailable (pass --host to skip this probe)"
|
|
return 1
|
|
fi
|
|
sleep 0.1
|
|
tick=$(( tick + 1 ))
|
|
done
|
|
wait "$pid"
|
|
}
|
|
is_container_project() { [[ "$PROJECT" == containers || "$PROJECT" == kubernetes-compat ]]; }
|
|
|
|
resolve_runtime_image() {
|
|
if [[ -n "$RUNTIME_IMAGE" ]]; then echo "$RUNTIME_IMAGE"; return; fi
|
|
if docker image inspect kandev-ci:runtime-local >/dev/null 2>&1; then
|
|
echo kandev-ci:runtime-local
|
|
else
|
|
echo ghcr.io/kdlbs/kandev-ci:runtime-latest
|
|
fi
|
|
}
|
|
|
|
# --- clean: remove build/test artifacts, including root-owned ones from prior
|
|
# docker runs (host user can't rm root-owned files, so shell out to a container).
|
|
clean_artifacts() {
|
|
log "cleaning e2e/test-results, blob-report, PR assets, and /tmp shard logs"
|
|
rm -rf "$WEB_DIR"/e2e/test-results* "$WEB_DIR"/e2e/blob-report "$WEB_DIR"/.pr-assets 2>/dev/null
|
|
rm -f /tmp/e2e-host-shard-*.log /tmp/e2e-docker-shard-*.log 2>/dev/null
|
|
if docker_up; then
|
|
docker run --rm -v "$WEB_DIR":/web alpine sh -c \
|
|
'rm -rf /web/e2e/test-results* /web/e2e/blob-report /web/.pr-assets' \
|
|
2>/dev/null || true
|
|
fi
|
|
}
|
|
|
|
prepare_pr_assets() {
|
|
[[ -n "${CAPTURE_PR_ASSETS:-}" ]] || return
|
|
log "preparing PR asset directory"
|
|
rm -rf "$WEB_DIR/.pr-assets" 2>/dev/null || true
|
|
if [[ -e "$WEB_DIR/.pr-assets" ]]; then
|
|
docker_up || die "cannot remove root-owned PR assets without Docker; run e2e:clean where Docker is available"
|
|
docker run --rm -v "$WEB_DIR":/web alpine sh -c 'rm -rf /web/.pr-assets' \
|
|
>/dev/null || die "failed to remove root-owned PR assets"
|
|
fi
|
|
mkdir -p "$WEB_DIR/.pr-assets"
|
|
}
|
|
|
|
restore_pr_asset_ownership() {
|
|
[[ -n "${CAPTURE_PR_ASSETS:-}" ]] || return 0
|
|
docker run --rm -v "$WEB_DIR":/web alpine sh -c "chown -R $(id -u):$(id -g) /web/.pr-assets" \
|
|
>/dev/null || {
|
|
log "failed to restore PR asset ownership"
|
|
return 1
|
|
}
|
|
}
|
|
|
|
# `build:e2e`, not `build`: a plain `vite build` drops the pseudo catalog from
|
|
# the bundle (it is the largest locale and no production user can select it), and
|
|
# tests/i18n/pseudo-coverage.spec.ts is the oracle that needs it. See
|
|
# apps/web/lib/i18n/bundling.ts. Mirror any change here in the CI e2e build job
|
|
# (.github/workflows/e2e-tests.yml -> make build-web-e2e).
|
|
build_fe() {
|
|
log "building Vite web assets (with the pseudo QA locale)"
|
|
( cd "$REPO_ROOT/apps" && pnpm --filter @kandev/web build:e2e ) \
|
|
|| die "FE build failed"
|
|
}
|
|
|
|
build_backend_host() {
|
|
log "building backend (host)"
|
|
local targets=(build)
|
|
# Both real-runtime projects need Linux helpers; the deprecated docker alias
|
|
# is normalized to containers before this point.
|
|
is_container_project && targets+=(build-agentctl-linux build-mock-agent-linux)
|
|
make -C "$BACKEND_DIR" "${targets[@]}" >/dev/null || die "backend build failed"
|
|
}
|
|
|
|
# Builds the external-style fixture UI from its sole source, then packages the
|
|
# backend and UI with their immutable identity for every host/container run.
|
|
build_plugin_package() {
|
|
log "building and packaging e2e fixture plugin"
|
|
make -C "$BACKEND_DIR" e2e-plugin-ui e2e-plugin-package >/dev/null \
|
|
|| die "e2e plugin package build failed"
|
|
}
|
|
|
|
backend_runs_in() { # $1=image — does the host-built binary run there? (glibc check, ~2s)
|
|
# Mount to a distinct path, not /bin — overlaying /bin would shadow the
|
|
# container's system utilities (/bin/sh etc.).
|
|
docker run --rm -v "$REPO_ROOT/apps/backend/bin":/test-bin:ro "$1" \
|
|
/test-bin/kandev --version >/dev/null 2>&1
|
|
}
|
|
|
|
build_backend_in_build_image() {
|
|
log "building backend in $BUILD_IMAGE (glibc-matched to runtime image)"
|
|
docker image inspect "$BUILD_IMAGE" >/dev/null 2>&1 \
|
|
|| die "host-built backend is incompatible with the runtime image's glibc and $BUILD_IMAGE isn't available. Pull it (docker pull $BUILD_IMAGE) or run with --host."
|
|
local uid_gid; uid_gid="$(id -u):$(id -g)"
|
|
docker run --rm -v "$REPO_ROOT":/work -w /work/apps/backend \
|
|
-v kandev-gocache:/root/.cache/go-build -v kandev-gomod:/go/pkg/mod \
|
|
"$BUILD_IMAGE" \
|
|
bash -lc "git config --global --add safe.directory /work 2>/dev/null; make build VERSION=dev-docker && chown -R $uid_gid /work/apps/backend/bin" \
|
|
>/dev/null || die "in-container backend build failed"
|
|
}
|
|
|
|
# Build a backend binary that runs in the runtime image. Prefer the host build
|
|
# (fast, no 5GB build image): a binary linked against the host's glibc runs in
|
|
# the container as long as the host's glibc is the same or OLDER than the
|
|
# image's (the usual case — the runtime image tracks recent Ubuntu). Only when
|
|
# the host is NEWER do we fall back to the build image.
|
|
build_backend_for_docker() { # $1=runtime image
|
|
build_backend_host
|
|
if backend_runs_in "$1"; then
|
|
log "host-built backend runs in $1 (glibc OK)"
|
|
else
|
|
log "host-built backend won't run in $1 (host glibc newer than image?) — using $BUILD_IMAGE"
|
|
build_backend_in_build_image
|
|
fi
|
|
}
|
|
|
|
# --- arg parsing
|
|
SUBCMD=run
|
|
MODE=auto
|
|
SHARDS=1
|
|
DO_BUILD=1
|
|
STRICT=1
|
|
PROJECT=chromium
|
|
PW_ARGS=()
|
|
# pnpm/npm forward the caller's `--` verbatim, so `pnpm e2e:run -- --host`
|
|
# reaches this script as `-- --host`. Only the package entry point drops that
|
|
# artifact. Direct calls keep the documented `-- <playwright args>` separator.
|
|
if [[ "${npm_lifecycle_event:-}" == e2e:run && "${1:-}" == -- ]]; then
|
|
shift
|
|
fi
|
|
[[ "${1:-}" == clean ]] && { SUBCMD=clean; shift; }
|
|
[[ "${1:-}" == run ]] && shift
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--docker) MODE=docker ;;
|
|
--host) MODE=host ;;
|
|
--shards) [[ "${2:-}" =~ ^[1-9][0-9]*$ ]] || die "--shards must be a positive integer (got '${2:-}')"; SHARDS="$2"; shift ;;
|
|
--no-build) DO_BUILD=0 ;;
|
|
--no-strict) STRICT=0 ;;
|
|
--project) [[ "${2:-}" =~ ^[a-zA-Z0-9_-]+$ ]] || die "invalid --project name: '${2:-}'"; PROJECT="$2"; shift ;;
|
|
--project=*) PROJECT="${1#--project=}"; [[ "$PROJECT" =~ ^[a-zA-Z0-9_-]+$ ]] || die "invalid --project name: '$PROJECT'" ;;
|
|
--) shift; PW_ARGS+=("$@"); break ;;
|
|
*) PW_ARGS+=("$1") ;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
if [[ "$SUBCMD" == clean ]]; then clean_artifacts; log "clean done"; exit 0; fi
|
|
|
|
# `docker` is a deprecated alias for the `containers` Playwright project
|
|
# (playwright.config.ts only defines `containers`); normalize once so every
|
|
# downstream check — build targets, env export, and the `--project` value
|
|
# handed to Playwright itself — only ever has to know about `containers`.
|
|
if [[ "$PROJECT" == docker ]]; then
|
|
log "--project docker is deprecated; using containers"
|
|
PROJECT=containers
|
|
fi
|
|
|
|
e2e_validate_shards "$SHARDS" || die "unsafe shard count rejected"
|
|
e2e_validate_playwright_args "${PW_ARGS[@]}" || die "unsafe Playwright worker count rejected"
|
|
|
|
# Resolve mode
|
|
if [[ "$MODE" == auto ]]; then
|
|
if docker_up && { [[ -n "$RUNTIME_IMAGE" ]] || docker image inspect kandev-ci:runtime-local >/dev/null 2>&1 || docker image inspect ghcr.io/kdlbs/kandev-ci:runtime-latest >/dev/null 2>&1; }; then
|
|
MODE=docker
|
|
else
|
|
MODE=host
|
|
fi
|
|
fi
|
|
log "mode=$MODE shards=$SHARDS project=$PROJECT strict=$STRICT workers=1"
|
|
|
|
STRICT_ENV=()
|
|
[[ "$STRICT" == 1 ]] && STRICT_ENV=(KANDEV_E2E_WS_ASSERT=1)
|
|
CONTAINER_ENV=()
|
|
is_container_project && CONTAINER_ENV=(KANDEV_E2E_CONTAINERS=1)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HOST mode
|
|
# ---------------------------------------------------------------------------
|
|
run_host() {
|
|
prepare_pr_assets
|
|
[[ "$DO_BUILD" == 1 ]] && { build_backend_host; build_fe; build_plugin_package; }
|
|
local base_args=(playwright test --config e2e/playwright.config.ts --project="$PROJECT" --workers=1)
|
|
# `env FOO=bar cmd` inherits the rest of the parent shell's environment as-is,
|
|
# so a KANDEV_E2E_CONTAINERS/KANDEV_E2E_DOCKER left over from an earlier
|
|
# containers run in the same shell would otherwise leak into an ordinary
|
|
# run and make global setup demand Linux helper binaries it never built.
|
|
if [[ "$SHARDS" -le 1 ]]; then
|
|
( cd "$WEB_DIR" && env -u KANDEV_E2E_CONTAINERS -u KANDEV_E2E_DOCKER ${STRICT_ENV[@]+"${STRICT_ENV[@]}"} ${CONTAINER_ENV[@]+"${CONTAINER_ENV[@]}"} pnpm exec "${base_args[@]}" ${PW_ARGS[@]+"${PW_ARGS[@]}"} )
|
|
return $?
|
|
fi
|
|
log "running $SHARDS host shards (distinct E2E_PORT_OFFSET + output dirs)"
|
|
local pids=() rc=0 i
|
|
for ((i=1; i<=SHARDS; i++)); do
|
|
( cd "$WEB_DIR" && env -u KANDEV_E2E_CONTAINERS -u KANDEV_E2E_DOCKER ${STRICT_ENV[@]+"${STRICT_ENV[@]}"} ${CONTAINER_ENV[@]+"${CONTAINER_ENV[@]}"} E2E_PORT_OFFSET=$((i-1)) \
|
|
pnpm exec "${base_args[@]}" --shard="$i/$SHARDS" --output="e2e/test-results-shard-$i" ${PW_ARGS[@]+"${PW_ARGS[@]}"} \
|
|
> "/tmp/e2e-host-shard-$i.log" 2>&1 ) &
|
|
pids+=("$!")
|
|
done
|
|
for p in "${pids[@]}"; do wait "$p" || rc=1; done
|
|
for ((i=1; i<=SHARDS; i++)); do
|
|
printf ' shard %s: %s\n' "$i" "$(grep -Eo '[0-9]+ (passed|failed|flaky)' "/tmp/e2e-host-shard-$i.log" | tr '\n' ' ')" >&2
|
|
done
|
|
log "host shard logs: /tmp/e2e-host-shard-*.log"
|
|
return $rc
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DOCKER mode (one container per shard; output stays container-local so the
|
|
# mounted repo never gets root-owned junk)
|
|
# ---------------------------------------------------------------------------
|
|
run_docker() {
|
|
local img; img="$(resolve_runtime_image)"
|
|
log "runtime image: $img"
|
|
clean_artifacts
|
|
prepare_pr_assets
|
|
[[ "$DO_BUILD" == 1 ]] && { build_backend_for_docker "$img"; build_fe; build_plugin_package; }
|
|
|
|
local strict_flag=()
|
|
[[ "$STRICT" == 1 ]] && strict_flag=(-e KANDEV_E2E_WS_ASSERT=1)
|
|
local container_flag=()
|
|
is_container_project && container_flag=(-e KANDEV_E2E_CONTAINERS=1)
|
|
local capture_flag=()
|
|
[[ -n "${CAPTURE_PR_ASSETS:-}" ]] && capture_flag=(-e CAPTURE_PR_ASSETS)
|
|
local pw="git config --global --add safe.directory /work 2>/dev/null; cd /work/apps/web && pnpm exec playwright test --config e2e/playwright.config.ts --project=\"$PROJECT\" --workers=1"
|
|
|
|
run_one() { # $1=shard index (or 0 for unsharded)
|
|
local i="$1" shardflag="" out="/tmp/pw-out"
|
|
[[ "$i" != 0 ]] && shardflag="--shard=$i/$SHARDS"
|
|
docker run --rm --ipc=host \
|
|
-v "$REPO_ROOT":/work -w /work/apps/web \
|
|
${strict_flag[@]+"${strict_flag[@]}"} \
|
|
${container_flag[@]+"${container_flag[@]}"} \
|
|
${capture_flag[@]+"${capture_flag[@]}"} \
|
|
-e NODE_OPTIONS=--dns-result-order=ipv4first \
|
|
-e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
|
|
"$img" \
|
|
bash -lc "$pw $shardflag --output=$out --reporter=list \"\$@\"" e2e-runner ${PW_ARGS[@]+"${PW_ARGS[@]}"}
|
|
}
|
|
|
|
if [[ "$SHARDS" -le 1 ]]; then
|
|
run_one 0
|
|
local rc=$?
|
|
restore_pr_asset_ownership || return 1
|
|
return $rc
|
|
fi
|
|
log "running $SHARDS isolated containers"
|
|
local pids=() rc=0 i
|
|
for ((i=1; i<=SHARDS; i++)); do
|
|
run_one "$i" > "/tmp/e2e-docker-shard-$i.log" 2>&1 &
|
|
pids+=("$!")
|
|
done
|
|
for p in "${pids[@]}"; do wait "$p" || rc=1; done
|
|
for ((i=1; i<=SHARDS; i++)); do
|
|
printf ' shard %s: %s\n' "$i" "$(grep -Eo '[0-9]+ (passed|failed|flaky)' "/tmp/e2e-docker-shard-$i.log" | tr '\n' ' ')" >&2
|
|
done
|
|
restore_pr_asset_ownership || return 1
|
|
log "docker shard logs: /tmp/e2e-docker-shard-*.log"
|
|
return $rc
|
|
}
|
|
|
|
if [[ "$MODE" == docker ]]; then run_docker; else run_host; fi
|