Files
kandev/apps/web/e2e/scripts/run-e2e.sh
T
João Salavisa e145d48f22 feat: add plugin system with grpc runtime and native ui plugins (#1742)
* feat: add plugin system with grpc runtime and native ui plugins

- go plugin backends ship as release tarballs (per-platform binaries, checksums) that
  kandev installs from url/upload or filesystem sideload+sync, then spawns and
  supervises via hashicorp/go-plugin (unix socket / loopback tcp+automtls)
- strict typed grpc contract (kandev.plugin.v1): DeliverEvent/InvokeTool/HandleWebhook
  plus a capability-gated host service (state, secrets, emit); author sdk in
  pkg/pluginsdk; packaging cli in cmd/plugin-pack
- native frontend bundles: window.registerKandevPlugin + registry/host loader with
  dynamic routes, nav items, slots and ws handlers; plugins settings page with
  install dialog, sync button and enable/disable/uninstall
- gated behind the plugins feature flag (prod off, dev/e2e on); covered by unit and
  integration tests plus playwright e2e against a real packaged fixture plugin

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): hydrate base app context on plugin routes

Plugin-owned SPA routes classify as RouteUnknown, so the boot payload shipped
only feature flags — no workspaces, workflows, or kanban context. Native plugin
UI (e.g. host.ui.TaskCreateDialog) then had an empty store. Hydrate the same
base context as the Home route for unknown SPA routes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(plugins): expose TaskCreateDialog on host.ui

Lets a native plugin open kandev's real create-task modal (prefilled via
initialValues) instead of POSTing directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): harden filesystem/URL sinks against CodeQL findings

Add sink-level guards on the plugin trust boundary: securejoin containment on
archive extraction (zip-slip), plugin-id validation before every fs_store path,
http/https scheme validation on install-from-URL, and non-interpolated ws-handler
error logging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): address review findings (races, resource caps, lifecycle)

- serialize per-plugin startup to close a TOCTOU double-spawn race, and let a
  plugin restart after exhausting attempts
- bound plugin startup with an explicit 30s timeout
- reject backslash paths and cap decompressed bytes by actual size in pkgtar;
  only mark a package Signed when a verifier actually ran and passed
- delete plugin_state on uninstall; validate webhook keys against the manifest
  and cap webhook body size; restart an active plugin when a new version installs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): sanitize plugin-id path sinks for CodeQL

Pin the plugin id to a single path segment with filepath.Base at recordPath/
configPath and pkgtar.Remove — a barrier the taint analysis recognizes.
Behaviour-identical for valid ids (already validated by safePluginID); clears
the go/path-injection alerts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): inline plugin-id validation at path sinks for CodeQL

filepath.Base does not strip '..', so it is not a path-injection barrier. Call
the recognized safePluginID / id-pattern guard inline in Delete/GetConfig/
SetConfig and pkgtar.Remove — the same barrier that already cleared the Get sink.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): address code-review findings (backend)

- add capability-gating tests for the Host RPC service (was untested)
- close a stop-during-start orphan race so Disable/Uninstall can't leave a
  supervised subprocess behind
- on failed upgrade, remove only the new version dir (not all versions + data);
  restart the prior version
- validate manifest version and reject ids ending in .config (record-file alias)
- make Deliverer.Refresh non-blocking and cancel in-flight delivery on worker stop
- clamp out-of-range webhook status, skip-and-warn corrupt records in List,
  write records atomically, semver-aware sideload version pick

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): address code-review findings (frontend)

- wrap plugin routes + settings routes in a shared PluginErrorBoundary so a
  throwing plugin page shows a fallback instead of white-screening the SPA
- fix disable/re-enable in one session: keep the cached registration and
  re-run initialize instead of relying on a cached ES-module re-import
- prefix root-relative bundle/style URLs with apiBaseUrl (split-origin/Tauri)
- surface partial-install warnings via toast.warning instead of a bare success
- dedupe/remove injected style links on unload; gate PluginNavItems on the flag

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): serialize per-plugin lifecycle + guard config writes

- add a per-plugin keyed lock in Service so concurrent Enable/Disable/Install/
  Uninstall/UpdateConfig for one id are serialized (no status-machine race)
- take s.mu in SetConfig/GetConfig and write config atomically
- bound each plugin initialize() with a timeout so one hung bundle can't stall
  the rest of the boot load

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(plugins): split service_test.go to satisfy file-length lint

Move the install/URL test group into service_install_test.go; no logic change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: build and ship the e2e fixture plugin package to E2E shards

The plugin E2E specs' global-setup requires apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz.
Build it in the E2E build job, upload it as an artifact, and download it in both
the regular and container shard jobs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 12:00:26 +01:00

235 lines
10 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 N shards concurrently (--shards N): N isolated containers in docker
# mode, or N host processes with distinct E2E_PORT_OFFSET + output dirs.
# • Never leaves root-owned junk in the repo: points Playwright output at a
# container-local dir. `clean`
# removes any pre-existing root-owned artifacts via a throwaway container.
#
# Usage:
# run-e2e.sh [run] [options] [-- <playwright args>] # default subcommand: run
# run-e2e.sh clean # remove build/test artifacts (incl. root-owned)
#
# Options:
# --docker | --host Force runner (default: auto-detect)
# --shards N Run the chromium project as N concurrent shards
# --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)
set -uo pipefail
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:-}"
log() { printf '\033[36m[e2e]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[31m[e2e] %s\033[0m\n' "$*" >&2; exit 1; }
docker_up() { command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; }
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, and /tmp shard logs"
rm -rf "$WEB_DIR"/e2e/test-results* "$WEB_DIR"/e2e/blob-report 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' \
2>/dev/null || true
fi
}
build_fe() {
log "building Vite web assets"
( cd "$REPO_ROOT/apps" && pnpm --filter @kandev/web build ) \
|| die "FE build failed"
}
build_backend_host() {
log "building backend (host)"
make -C "$BACKEND_DIR" build >/dev/null || die "backend build failed"
}
# Packages the plugin-fixture SDK plugin (cmd/plugin-fixture) into
# .build/kandev-plugin-e2e-1.0.0.tar.gz for tests/plugins/plugins.spec.ts.
# Always built on the host — like mock-agent, e2e/global-setup.ts only checks
# this file exists (not that it's current), so it must be rebuilt any time
# cmd/plugin-fixture changes (this runner always rebuilds it via build_fe's
# sibling call below, unless --no-build is set).
build_plugin_package() {
log "packaging e2e fixture plugin"
make -C "$BACKEND_DIR" 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=()
[[ "${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 ;;
--) shift; PW_ARGS+=("$@"); break ;;
*) PW_ARGS+=("$1") ;;
esac
shift
done
if [[ "$SUBCMD" == clean ]]; then clean_artifacts; log "clean done"; exit 0; fi
# 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"
STRICT_ENV=()
[[ "$STRICT" == 1 ]] && STRICT_ENV=(KANDEV_E2E_WS_ASSERT=1)
# ---------------------------------------------------------------------------
# HOST mode
# ---------------------------------------------------------------------------
run_host() {
[[ "$DO_BUILD" == 1 ]] && { build_backend_host; build_fe; build_plugin_package; }
local base_args=(playwright test --config e2e/playwright.config.ts --project="$PROJECT")
if [[ "$SHARDS" -le 1 ]]; then
( cd "$WEB_DIR" && env ${STRICT_ENV[@]+"${STRICT_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 ${STRICT_ENV[@]+"${STRICT_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" | paste -sd' ')" >&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
[[ "$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 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\""
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[@]}"} \
-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; return $?; 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" | paste -sd' ')" >&2
done
log "docker shard logs: /tmp/e2e-docker-shard-*.log"
return $rc
}
if [[ "$MODE" == docker ]]; then run_docker; else run_host; fi