build.sh
Rama main · réplica de lectura del repositorio original.
#!/bin/bash
if [ -n "${ZSH_VERSION:-}" ]; then exec /bin/bash "$0" "$@"; fi
echo "Recommended for development: Newest Debian, Ubuntu, Fedora or Arch/Omarchy amd64 based distro, directly to SSD disk or dual boot, not VM. Works fast."
echo "Note1: If you use other locale than en_US.UTF-8 , you need to additionally install en_US.UTF-8"
echo " so that MongoDB works correctly: Debian/Ubuntu 'sudo dpkg-reconfigure locales',"
echo " Arch/Omarchy: uncomment en_US.UTF-8 in /etc/locale.gen and run 'sudo locale-gen'."
echo " You can still use any other locale as your main locale."
echo "Note2: Console output is also logged to the operation-specific path printed below."
echo "Note3: All logs use .tools/log/<operation>/YYYY-MM-DD_HH-MM-SS/."
echo " .tools/log/ inside this repository. The path is printed when a run"
echo " starts."
echo "Note4: Two build directories, and they are not the same thing:"
echo " .build/ - the RELEASE bundle, from 'meteor build .build --directory'."
echo " .build/bundle is what is deployed, tested and packaged."
echo " _build/ - rspack's compiled output, written by ANY Meteor compile."
echo " Meteor reads its main modules from _build/main-prod/, so"
echo " it is a handoff, not a leftover: it is gitignored but must"
echo " NOT be added to .meteorignore (see the note in that file)."
echo " Both are generated. Never edit them, never commit them, and skip them"
echo " in any tool that walks the repo - _build holds a second copy of every"
echo " source file."
# Give the Meteor build tool and Node processes a larger heap so long
# development sessions and test runs don't crash with
# "FATAL ERROR: Ineffective mark-compacts near heap limit - JavaScript heap out
# of memory". TOOL_NODE_FLAGS controls the Meteor command-line/build process
# (the one that hits the limit during `meteor run`/`meteor test`/`meteor build`);
# NODE_OPTIONS covers child Node/rspack processes. Defaults use available
# memory and honor any value already exported.
# The size is worked out from the machine rather than fixed at 8192, because
# 8192 is where a build died:
#
# FATAL ERROR: Ineffective mark-compacts near heap limit
# Allocation failed - JavaScript heap out of memory
#
# at 8146 MB of an 8192 MB limit, on a machine with 30 GiB of RAM. The build had
# not run out of memory, it had run out of the ceiling this line gave it - and
# because that ceiling was a constant, the same number was too small on a large
# machine and too large on a small one.
#
# Half of available host or cgroup RAM, capped at 16384. Half leaves the
# rest usable while a build runs; a small machine
# gets a proportionally smaller heap, and the ceiling is there because a heap
# larger than that means something is wrong rather than something is big. At
# 16 GiB this works out to 8192, which is exactly what was hard-coded here
# before, so nothing changes on the machine that value was chosen for.
#
# Anything already exported still wins, which is how to override it.
# Always computed, never conditionally: if only ONE of the two variables were
# already set, skipping this would leave the other with an empty size and Node
# would be handed "--max-old-space-size=".
_mem_total_mb=$(awk '/^MemTotal:/ { print int($2 / 1024) }' /proc/meminfo 2>/dev/null)
# Containers and Flatpak may expose host MemTotal while enforcing a smaller cgroup.
# Use the smaller finite cgroup limit so the heap cannot exceed its real sandbox.
for _cg_file in /sys/fs/cgroup/memory.max /sys/fs/cgroup/memory/memory.limit_in_bytes; do
[ -r "$_cg_file" ] || continue
_cg_bytes=$(cat "$_cg_file" 2>/dev/null || true)
case "$_cg_bytes" in ''|max|*[!0-9]*) continue ;; esac
_cg_mb=$(( _cg_bytes / 1048576 ))
[ "$_cg_mb" -gt 0 ] && { [ -z "${_mem_total_mb:-}" ] || [ "$_cg_mb" -lt "$_mem_total_mb" ]; } && _mem_total_mb=$_cg_mb
break
done
# macOS has no /proc; hw.memsize is bytes.
[ -n "${_mem_total_mb:-}" ] || _mem_total_mb=$(( $(sysctl -n hw.memsize 2>/dev/null || echo 0) / 1048576 ))
[ "${_mem_total_mb:-0}" -gt 0 ] 2>/dev/null || _mem_total_mb=16384
_heap_mb=$(( _mem_total_mb / 2 ))
[ "$_heap_mb" -gt 16384 ] && _heap_mb=16384
if [ -z "${TOOL_NODE_FLAGS:-}${NODE_OPTIONS:-}" ]; then
echo "Node heap limit for builds: ${_heap_mb} MB (half of ${_mem_total_mb} MB of RAM)."
echo " Override by exporting TOOL_NODE_FLAGS and NODE_OPTIONS yourself."
fi
export TOOL_NODE_FLAGS="${TOOL_NODE_FLAGS:---max-old-space-size=$_heap_mb}"
export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=$_heap_mb}"
# Every log this script writes goes into a `log/<operation>/<date>/<time>/` directory, and
# WEKAN_LOG_ROOT is where those directories live.
#
# `.tools/log` is inside the repository's ignored tool area, so test output is
# kept out of the source tree without depending on a writable parent directory.
#
# WEKAN_LOG_ROOT can be set to put them somewhere else entirely.
if [ -z "${WEKAN_LOG_ROOT:-}" ]; then
WEKAN_LOG_ROOT=".tools/log"
fi
export WEKAN_LOG_ROOT
mkdir -p "$WEKAN_LOG_ROOT"
# ── Companion repositories: wekan/.tools/<name> ──────────────────────────────
#
# The repos WeKan needs to run everything - wekan/FerretDB for the database
# conformance run and FerretDB's own suites, and the patch repos - are separate
# git repositories, not part of this one. They live in .tools/ inside this
# checkout, which .gitignore and .meteorignore already exclude, so a clone of
# them can never end up in a commit or in a Meteor rebuild.
#
# They used to be cloned as subdirectories of the repo root (wekan/FerretDB),
# each needing its own ignore entry; one ignored directory holds all of them.
# The repo directory, from the script's own path rather than the caller's cwd:
# ./build.sh is normally run from the checkout, but a clone must not land in
# whatever directory somebody happened to be in.
WEKAN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WEKAN_TOOLS_DIR="$WEKAN_DIR/.tools"
export WEKAN_DIR WEKAN_TOOLS_DIR
export PATH="$WEKAN_TOOLS_DIR/bin:${GOBIN:+$GOBIN:}$PATH"
. "$WEKAN_DIR/releases/ensure-tools.sh"
# Everything this script and the releases/ scripts write - temporary files,
# downloads, Meteor, Node.js, the npm cache - stays in .tools/, on Linux, macOS
# and Windows alike (releases/tools-env.sh).
. "$WEKAN_DIR/releases/tools-env.sh"
# Prefer the repository-local toolchain installed under .tools. A fresh shell
# does not normally have .tools/.meteor on PATH, which made unattended test and
# build entries fail with "meteor: command not found" even after setup had
# installed the requested Meteor release.
if [ -x "$WEKAN_TOOLS_DIR/.meteor/meteor" ]; then
PATH="$WEKAN_TOOLS_DIR/.meteor:$PATH"
fi
_wekan_node_version="$(sed -n 's/.*NODE_VERSION=v\([^ \\]*\).*/\1/p' "$WEKAN_DIR/Dockerfile" | head -1)"
# npm comes from the Dockerfile too (CLAUDE.md), never a number typed here.
_wekan_npm_version="$(sed -n 's/.*NPM_VERSION=\([^ \\]*\).*/\1/p' "$WEKAN_DIR/Dockerfile" | head -1)"
case "$(uname -m 2>/dev/null)" in
x86_64|amd64) _wekan_node_arch=x64 ;;
aarch64|arm64) _wekan_node_arch=arm64 ;;
*) _wekan_node_arch="" ;;
esac
_wekan_local_node="$WEKAN_TOOLS_DIR/node-v${_wekan_node_version}-linux-${_wekan_node_arch}/bin"
if { [ -z "$_wekan_node_version" ] || [ -z "$_wekan_node_arch" ] ||
[ ! -x "$_wekan_local_node/node" ]; } && [ -n "$_wekan_node_arch" ]; then
# A freshly bumped Dockerfile can be one patch ahead of the toolchain already
# installed for tests. Keep plain Node stages and Playwright version discovery
# working with the newest repository-local Node until setup fetches the exact
# release; Meteor builds still use Meteor's own pinned dev-bundle Node.
_wekan_local_node="$(find "$WEKAN_TOOLS_DIR" -maxdepth 1 -type d \
-name "node-v*-linux-${_wekan_node_arch}" -print 2>/dev/null |
sort -V | tail -n 1)/bin"
fi
if [ -n "$_wekan_node_version" ] && [ -n "$_wekan_node_arch" ] && [ -x "$_wekan_local_node/node" ]; then
PATH="$_wekan_local_node:$PATH"
fi
export PATH
# ensure_tool_repo <name> [git-url] - the path to .tools/<owner>/<name>, cloning
# it if it is not there yet. Prints the path on stdout; everything else goes to
# stderr, so a caller can do `dir="$(ensure_tool_repo FerretDB)"`.
#
# The companion repositories are grouped by their GitHub owner, the way they are
# cloned: github.com/wekan/* in .tools/w/, github.com/xet7/* in .tools/xet7/ and
# github.com/Secretchronicles/* in .tools/Secretchronicles/. tool_repo_dir says
# which directory that is, so every script finds a repo in the same place.
#
# SSH first, HTTPS second: a maintainer has a key and wants to push, and anyone
# else still gets a working clone. Returns non-zero when neither works, so the
# caller can say what it cannot do rather than cd into nothing.
function tool_repo_dir(){
local owner="$1" name="$2"
[ "$owner" = "wekan" ] && owner="w"
printf '%s\n' "$WEKAN_TOOLS_DIR/$owner/$name"
}
function ensure_tool_repo(){
local name="$1"
local url="${2:-git@github.com:wekan/$name}"
local https_url="${url/git@github.com:/https://github.com/}"
local owner="${url#*github.com[:/]}"
owner="${owner%%/*}"
local dir
dir="$(tool_repo_dir "$owner" "$name")"
if [ -d "$dir/.git" ]; then
printf '%s\n' "$dir"
return 0
fi
mkdir -p "$(dirname "$dir")" || { echo "ERROR: cannot create $(dirname "$dir")" >&2; return 1; }
echo "==> $name is not in ${dir#"$WEKAN_DIR"/} yet; cloning $url" >&2
if ! git clone "$url" "$dir" >&2; then
echo "==> SSH clone failed (no key for github.com?); trying HTTPS." >&2
if ! git clone "$https_url" "$dir" >&2; then
echo "ERROR: could not clone $name from $url or $https_url" >&2
return 1
fi
fi
printf '%s\n' "$dir"
}
function is_dev_server_running(){
if command -v pgrep >/dev/null 2>&1; then
pgrep -af 'meteor run --port 3000' | grep -v 'meteor test' | grep -q '.'
return $?
fi
return 1
}
function ensure_rspack_public_dirs(){
mkdir -p public/build-chunks public/build-assets
}
# The rspack dev-server port that `meteor run` starts alongside the app (see
# rspack.config.js / @meteorjs/rspack: Meteor.devServerPort || 8080). It is the
# same regardless of the app --port, and the rspack watcher is a separate process
# that can outlive the meteor parent, so restarting a dev server must free this
# port too or the new server dies with "Error: listen EADDRINUSE ... :8080".
# Override RSPACK_DEV_PORT in the environment if you changed devServerPort.
RSPACK_DEV_PORT="${RSPACK_DEV_PORT:-8080}"
# All ports the dev/test servers in this script use: the dev app (3000) and its
# bundled Mongo (3001), the Mocha test server (3100) and its Mongo (3101), a
# Sandstorm standalone dev server (4000) and its Mongo (4001), and the rspack
# dev server (8080). Used by the "Kill all dev servers" menu option.
DEV_SERVER_PORTS="3000 3001 3100 3101 4000 4001 8080"
# True (return 0) if something is LISTENING on TCP port $1. Checks the socket
# directly (ss/lsof/fuser) rather than making an HTTP request, so it also detects
# a server that is still building and not yet answering HTTP. Uses ss with an
# exact source-port filter first (fast, Linux); lsof is given -nP so it never
# blocks on host/port name resolution.
function port_in_use(){
local p="$1"
if command -v ss >/dev/null 2>&1; then
[ -n "$(ss -ltnH "sport = :$p" 2>/dev/null)" ] && return 0
return 1
fi
if command -v lsof >/dev/null 2>&1; then
lsof -nP -iTCP:"$p" -sTCP:LISTEN -t >/dev/null 2>&1 && return 0
return 1
fi
if command -v fuser >/dev/null 2>&1; then
fuser "$p/tcp" >/dev/null 2>&1 && return 0
return 1
fi
# Universal fallback (no external tools): try to open a TCP connection to the
# port with bash's /dev/tcp. Succeeds if something is listening there.
(exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null && return 0
return 1
}
# Kill whatever is LISTENING on TCP port $1. Optional $2 = signal name (default
# TERM; pass KILL to force). fuser is preferred on Linux (kills by port with no
# PID parsing); lsof -nP is the macOS fallback; ss is the last resort so this still
# works on a minimal Linux that has neither fuser (psmisc) nor lsof installed — which
# is exactly when the old code silently did nothing and the port never freed, even
# though port_in_use (which DOES use ss) kept reporting it in use. ss -p shows the pid
# of the owning process for the current user without root.
function free_tcp_port(){
local p="$1" sig="${2:-TERM}" pids
if command -v fuser >/dev/null 2>&1; then
fuser -k -"$sig" "$p/tcp" >/dev/null 2>&1
elif command -v lsof >/dev/null 2>&1; then
pids="$(lsof -nP -iTCP:"$p" -sTCP:LISTEN -t 2>/dev/null)"
[ -n "$pids" ] && kill -"$sig" $pids 2>/dev/null
elif command -v ss >/dev/null 2>&1; then
pids="$(ss -ltnpH "sport = :$p" 2>/dev/null | grep -oE 'pid=[0-9]+' | cut -d= -f2 | sort -u)"
[ -n "$pids" ] && kill -"$sig" $pids 2>/dev/null
fi
return 0
}
# Stop any Meteor dev server already running before we start a new one on the
# same app port, so picking a "Run Meteor for dev" option always gives you a
# fresh server instead of failing because a port is taken. Frees BOTH the app
# port ($1) and the rspack dev-server port ($RSPACK_DEV_PORT): the rspack watcher
# can outlive the meteor parent and keep holding 8080 even after the app port is
# free, which is what makes a restart crash with EADDRINUSE :8080. Escalates to
# SIGKILL if a port does not free up. Returns 0 when both ports are free (or were
# never in use), 1 if one is still stuck.
function kill_meteor_on_port(){
local app_port="$1" rspack_port="$RSPACK_DEV_PORT" mongo_port=$(($1 + 1)) i pids
# Nothing on any of the three ports? Nothing to do. (Meteor runs its bundled
# MongoDB on app_port+1, so a leftover mongo there must be freed too.)
port_in_use "$app_port" || port_in_use "$rspack_port" || port_in_use "$mongo_port" || return 0
echo "==> A Meteor dev server is already running (app port $app_port, rspack dev-server port $rspack_port, MongoDB port $mongo_port); stopping it before starting a new one."
# Kill the meteor parent for this app port and the rspack watcher (matched by
# its devServerPort env). Killing these tears down most of the process tree.
pids="$(pgrep -f "meteor run --port $app_port" 2>/dev/null; pgrep -f "devServerPort=$rspack_port" 2>/dev/null)"
if [ -n "$pids" ]; then
echo " Killing Meteor/rspack PIDs:$(echo " $pids" | tr '\n' ' ')"
kill $pids 2>/dev/null
fi
# Free anything still holding any of the three ports. The bundled MongoDB on
# app_port+1 is commonly ORPHANED when the meteor parent is SIGKILLed (the mongo
# child survives and keeps the port), which then makes the next `meteor run` fail
# with "Unexpected mongo exit code 48 ... port was closed, or was already taken".
# Freeing app_port+1 here also clears a leftover standalone test mongod on :3001.
free_tcp_port "$app_port"
free_tcp_port "$rspack_port"
free_tcp_port "$mongo_port"
# Wait for all three ports to actually free up, escalating to SIGKILL at 15s.
for i in $(seq 1 30); do
port_in_use "$app_port" || port_in_use "$rspack_port" || port_in_use "$mongo_port" || break
if [ "$i" -eq 15 ]; then
echo " Still in use after 15s; sending SIGKILL."
pkill -9 -f "meteor run --port $app_port" 2>/dev/null
pkill -9 -f "devServerPort=$rspack_port" 2>/dev/null
free_tcp_port "$app_port" KILL
free_tcp_port "$rspack_port" KILL
free_tcp_port "$mongo_port" KILL
fi
printf '.'; sleep 1
done
echo
if port_in_use "$app_port" || port_in_use "$rspack_port" || port_in_use "$mongo_port"; then
echo "ERROR: Port $app_port, $rspack_port or $mongo_port is still in use after attempting to stop the existing server. Stop it manually and retry."
return 1
fi
echo " Ports $app_port, $rspack_port and $mongo_port are now free."
return 0
}
# Kill every dev/test server this script can start, freeing all $DEV_SERVER_PORTS
# at once. For the "Kill all dev servers" menu option: a blunt "make my dev ports
# free" that stops the dev app, the Mocha test server, the rspack watcher, and
# their bundled Mongos regardless of which port each is on.
function kill_all_dev_servers(){
echo "==> Killing any dev/test servers on ports: $DEV_SERVER_PORTS"
# Graceful first: kill the known dev processes by command line (works without
# lsof/fuser) so meteor can shut down cleanly. mongod is matched by Meteor's
# bundled '--replSet meteor' so we never touch a production/system Mongo. Then
# free any remaining port listeners as a bonus (no-op if lsof/fuser absent).
pkill -f 'meteor run --port' 2>/dev/null
pkill -f 'meteor test' 2>/dev/null
pkill -f 'rspack build --watch' 2>/dev/null
pkill -f 'mongod.*--replSet meteor' 2>/dev/null
local p i any
for p in $DEV_SERVER_PORTS; do free_tcp_port "$p"; done
# Wait for the ports to free, escalating to SIGKILL at 10s.
for i in $(seq 1 20); do
any=0
for p in $DEV_SERVER_PORTS; do port_in_use "$p" && any=1; done
[ "$any" -eq 0 ] && break
if [ "$i" -eq 10 ]; then
echo " Still busy after 10s; sending SIGKILL."
pkill -9 -f 'meteor run --port' 2>/dev/null
pkill -9 -f 'meteor test' 2>/dev/null
pkill -9 -f 'rspack build --watch' 2>/dev/null
pkill -9 -f 'mongod.*--replSet meteor' 2>/dev/null
for p in $DEV_SERVER_PORTS; do free_tcp_port "$p" KILL; done
fi
printf '.'; sleep 1
done
echo
local stuck=""
for p in $DEV_SERVER_PORTS; do port_in_use "$p" && stuck="$stuck $p"; done
if [ -n "$stuck" ]; then
echo " WARNING: still in use after trying to stop them:$stuck"
else
echo " All dev server ports are now free: $DEV_SERVER_PORTS"
fi
}
# Announce quiet build commands without buffering their own output.
function build_stage(){
(
local label="$1"; shift
local started=$SECONDS command_pid status elapsed
echo "==> $label"
printf ' Command:'; printf ' %q' "$@"; printf '\n'
"$@" <&0 &
command_pid=$!
trap 'kill "$command_pid" 2>/dev/null; exit 130' INT
trap 'kill "$command_pid" 2>/dev/null; exit 143' TERM
# Polling also reaps short commands promptly; SECONDS works in macOS Bash.
local next_report=15
while kill -0 "$command_pid" 2>/dev/null; do
elapsed=$((SECONDS - started))
if [ "$elapsed" -ge "$next_report" ]; then
echo " $label: still running (${elapsed}s; PID $command_pid)"
next_report=$((elapsed + 15))
fi
sleep 1
done
wait "$command_pid"; status=$?
echo "<== $label finished ($((SECONDS - started))s; exit $status)"
exit "$status"
)
}
# Build WeKan from scratch: reinstall npm deps and produce the .build directory.
# Used by menu option 2 and auto-invoked by option 9 when .build is missing.
# Also clears the rspack dev-build caches (_build and node_modules/.cache) so the
# next `meteor run` recompiles from scratch instead of serving stale modules.
# Reserve one operation/type/datetime directory, including same-second collisions.
function log_directory(){
local type="$1" day dir suffix=1 candidate
case "$type" in ""|*[!a-zA-Z0-9_-]*) echo "ERROR: invalid log type: $type" >&2; return 1 ;; esac
day="${WEKAN_LOG_ROOT:-.tools/log}/$type"
mkdir -p "$day" || return $?
dir="$day/$(date '+%Y-%m-%d_%H-%M-%S')"
if ! mkdir "$dir" 2>/dev/null; then
while :; do
candidate="$dir-$suffix"
if mkdir "$candidate" 2>/dev/null; then dir="$candidate"; break; fi
if [ ! -d "$candidate" ]; then echo "ERROR: cannot create log directory: $candidate" >&2; return 1; fi
suffix=$((suffix + 1))
done
fi
local absolute
absolute="$(cd "$dir" && pwd)" || return $?
echo "Log directory: $absolute" >&2
printf '%s\n' "$absolute"
}
function build_log(){
local type="$1" name="$2" dir
case "$type/$name" in
build-dev-bundle/dev|build-release-bundle/release) ;;
*) echo "ERROR: unknown build log type: $type/$name" >&2; return 1 ;;
esac
dir="$(log_directory "$type")" || return $?
printf '%s/%s.txt' "$dir" "$name"
}
function build_wekan(){
echo "Building WeKan."
# The build's output goes to the run's log directory, not only to the
# terminal. A test run that fails IN THE BUILD used to leave nothing behind
# to read: log/<datetime>/ held the FerretDB and conformance logs and not
# one line about why WeKan itself never got as far as a test, so "check the
# newest test logs" could not answer the question the run had just raised.
# It is teed, so an interactive build still scrolls past as it always did.
local buildlog
# What the build is actually given - the computed limit, unless the
# snapshot mode below caps it. The failure message quoted the computed one
# either way, which read as "allowed 15542 MB and reached 4280 MB".
_effective_heap_mb="$_heap_mb"
local build_type=build-dev-bundle build_name=dev
if [ "${WEKAN_BUILD_RELEASE_BUNDLE:-0}" = "1" ]; then
build_type=build-release-bundle; build_name=release
fi
buildlog="$(build_log "$build_type" "$build_name")" || return $?
local buildlog_start_line=1
if [ -f "$buildlog" ]; then
buildlog_start_line=$(( $(wc -l < "$buildlog") + 1 ))
fi
local buildlogs=("$buildlog")
# EVERYTHING retains its existing run-level build log as well.
if [ -n "${WEKAN_LOGDIR:-}" ]; then
buildlogs+=("$(one_log build)")
fi
echo "Build log: $buildlog"
# WEKAN_BUILD_HEAP_SNAPSHOT=1 makes the build write a heap snapshot just
# before it would run out of memory, instead of only dying.
#
# This exists because guessing at the cause did not work. The build has
# exhausted its heap three runs running - 8146 MB of 8192, then 15526 of
# 15542, then 15520 with the second JS minifier removed on the theory that
# it was the consumer. The last of those settled the theory: taking the
# minifier out changed the peak by 6 MB, which is noise. Something else is
# holding 15 GB, and the only way to find out what is to look.
#
# Node writes Heap.<pid>.<seq>.heapsnapshot into the working directory when
# it gets near the limit. Open it in Chrome DevTools (Memory -> Load) and
# sort by retained size. It is roughly as large as the heap, so this is
# off by default rather than something every build pays for.
if [ "${WEKAN_BUILD_HEAP_SNAPSHOT:-0}" = "1" ]; then
# A SMALLER heap on purpose, not the computed one.
#
# The first attempt at this kept the full limit and the run was killed
# outright - "Päätetty" from the kernel, and a 0-byte
# Heap.*.heapsnapshot left behind. Writing a snapshot costs memory on
# top of the heap being dumped, so asking a 15.5 GB heap to write a
# 15.5 GB snapshot on a 30 GB machine that already had 12 GB in use
# does not fit, and the OOM killer arrives before the file does.
#
# A snapshot is not more useful for being bigger. What is wanted is
# WHAT is retaining memory, and whatever grows to 15 GB is already the
# largest thing on the heap at 4 GB - it just gets there sooner and
# writes a file that fits. The build still fails; that is expected.
local snap_mb="${WEKAN_BUILD_HEAP_SNAPSHOT_MB:-4096}"
_effective_heap_mb="$snap_mb"
export TOOL_NODE_FLAGS="--max-old-space-size=$snap_mb --heapsnapshot-near-heap-limit=1"
export NODE_OPTIONS="--max-old-space-size=$snap_mb --heapsnapshot-near-heap-limit=1"
echo "Heap snapshot: ON, with the heap capped at ${snap_mb} MB."
echo " The build WILL fail sooner than usual - that is the point: it"
echo " writes $(pwd)/Heap.<date>.<pid>.<n>.heapsnapshot, about ${snap_mb} MB,"
echo " just before it dies. A snapshot at the full ${_heap_mb} MB limit"
echo " cannot be written on this machine - the last attempt was killed"
echo " by the kernel and left a 0-byte file."
echo " Open it in Chrome DevTools: Memory -> Load, sort by retained size."
echo " Raise it with WEKAN_BUILD_HEAP_SNAPSHOT_MB if 4096 is too early."
fi
{
echo "===== wekan build started $(date '+%F %T') ====="
export npm_config_loglevel=verbose npm_config_foreground_scripts=true
export METEOR_PROFILE="${METEOR_PROFILE:-100}"
# Node module resolution, one line per require. OFF unless
# WEKAN_BUILD_TRACE_MODULES=1: a Meteor build writes it at gigabytes a
# minute - 1.1 GB in the first three minutes of a menu dev build, over
# 12 GB in the first half hour of EVERYTHING on macOS, written twice
# through tee - and slows the build it is tracing.
if [ "${WEKAN_BUILD_TRACE_MODULES:-0}" = 1 ]; then
export NODE_DEBUG="${NODE_DEBUG:+$NODE_DEBUG,}module"
fi
# NODE_OPTIONS parses quoted paths, including checkouts with spaces.
export NODE_OPTIONS="${NODE_OPTIONS:-} --require=\"$(pwd)/tools/build-command-output.cjs\""
echo "Build diagnostics: resolver command/output tracing, npm verbose output, foreground install scripts, METEOR_PROFILE=$METEOR_PROFILE (milliseconds), Node module resolution $([ "${WEKAN_BUILD_TRACE_MODULES:-0}" = 1 ] && echo on || echo 'off (WEKAN_BUILD_TRACE_MODULES=1 turns it on)')."
build_stage "1/4 Remove dependencies and build caches" rm -rf node_modules node_modules/.cache .meteor/local .build _build || return $?
# Updating npm metadata has historically been best effort; installation is required.
build_stage "2/4 Compile app to resolve Meteor plugin npm dependencies" meteor update --npm || echo "WARNING: Meteor plugin npm dependency compilation failed; trying dependency installation."
build_stage "3/4 Install npm dependencies" meteor npm install || return $?
build_stage "4/4 Compile Meteor development bundle" meteor build .build --directory --verbose
local rc=$?
echo "===== wekan build finished $(date '+%F %T') (exit $rc) ====="
return $rc
} 2>&1 | tee -a "${buildlogs[@]}"
# The exit status of the pipeline is tee's; take the build's.
local rc="${PIPESTATUS[0]}"
if [ "$rc" -ne 0 ] || [ ! -d .build/bundle ]; then
echo "ERROR: the WeKan build failed. Its output is in $buildlog"
# Name the failure when it is one we can recognise, rather than leaving
# a V8 stack trace as the last word. Running out of heap and failing to
# compile look identical at this level and have nothing in common.
if tail -n +"$buildlog_start_line" "$buildlog" 2>/dev/null | grep -q "JavaScript heap out of memory"; then
local peak
peak="$(tail -n +"$buildlog_start_line" "$buildlog" | grep -ao 'Mark-Compact ([a-z ]*) [0-9.]*' \
| tail -1 | awk '{print $NF}')"
# Appended to the log as well as printed. The whole point of the
# build log is that "check the newest test logs" answers the
# question, and a diagnosis that exists only in terminal scrollback
# is one the next person does not have.
{
echo
echo " The build ran out of JavaScript heap. It was allowed ${_effective_heap_mb} MB${peak:+ and reached ${peak} MB}."
echo
echo " Raising that number is NOT the next step. It has been raised once"
echo " already - 8192 to ${_heap_mb} - and the build simply used the extra;"
echo " removing the second JS minifier on the theory that it was the"
echo " consumer moved the peak by 6 MB. The build really is holding that"
echo " much, in the phase after both rspack compiles report done."
echo
if [ "${WEKAN_BUILD_HEAP_SNAPSHOT:-0}" = "1" ]; then
echo " A heap snapshot was requested, so look for"
echo " $(pwd)/Heap.*.heapsnapshot and read it with:"
echo
echo " python3 releases/analyse-heapsnapshot.py Heap.*.heapsnapshot"
else
echo " To find out WHAT is holding it, run once with a heap snapshot:"
echo
echo " WEKAN_BUILD_HEAP_SNAPSHOT=1 ./build.sh"
echo
echo " That caps the heap so the snapshot can be written at all, and"
echo " leaves Heap.*.heapsnapshot here. Read it with:"
echo
echo " python3 releases/analyse-heapsnapshot.py Heap.*.heapsnapshot"
fi
} | tee -a "$buildlog"
fi
return 1
fi
# THE REST OF WHAT A RELEASE BUNDLE IS.
#
# `meteor build` produces a bundle nobody downloads. What a release ships is
# that bundle plus the server's npm modules, three prunes, the sockjs/legacy
# client/source-map trim, a verified Node.js, FerretDB, the MongoDB tools and
# a launcher - and everything WeKan has shipped broken lately broke in that
# difference, where nothing local could reach it.
#
# Menu option 2 sets this, so "Build WeKan" answers "does the bundle a
# release would publish start on this machine". The test path does not: it
# runs the bundle under its own node and its own mongod, and downloading a
# hundred megabytes of binaries it will not use to test WeKan's source is
# the wrong trade.
if [ "${WEKAN_BUILD_RELEASE_BUNDLE:-0}" = "1" ]; then
build_stage "Prepare release bundle" bash releases/build-release-bundle.sh .build/bundle 2>&1 | tee -a "${buildlogs[@]}"
local rrc="${PIPESTATUS[0]}"
if [ "$rrc" -ne 0 ]; then
echo "ERROR: the bundle built, but the release post-processing failed. Its output is in $buildlog"
return 1
fi
fi
echo "Done. Build log: $buildlog" | tee -a "${buildlogs[@]}"
}
# Detect OS (linux/macos) and CPU arch (amd64/arm64) so tests run on
# Linux amd64, Linux arm64 and macOS arm64.
function detect_platform(){
case "$OSTYPE" in
linux*) PLATFORM_OS="linux" ;;
darwin*) PLATFORM_OS="macos" ;;
*) PLATFORM_OS="$OSTYPE" ;;
esac
case "$(uname -m)" in
x86_64|amd64) PLATFORM_ARCH="amd64" ;;
arm64|aarch64) PLATFORM_ARCH="arm64" ;;
*) PLATFORM_ARCH="$(uname -m)" ;;
esac
}
detect_platform
echo "Platform: $PLATFORM_OS $PLATFORM_ARCH"
# Whether a Playwright browser project (chromium / firefox / webkit) should run
# inside the official Playwright Docker image instead of natively. This lets the
# full Chromium + Firefox + WebKit matrix run anywhere, including hosts where the
# bundled browsers can't launch natively (e.g. Linux arm64, where WebKit links
# against old system libs, or where Chromium/Firefox need libraries the host lacks).
#
# Resolution order:
# 1. WEKAN_PLAYWRIGHT_DOCKER=1 -> ALL browsers via Docker; =0 -> ALL native.
# 2. Per browser: WEKAN_CHROMIUM_DOCKER / WEKAN_FIREFOX_DOCKER / WEKAN_WEBKIT_DOCKER (1/0).
# 3. Auto (default): on Linux arm64 ALL browsers (Chromium, Firefox, WebKit)
# go through Docker; on every other platform they run natively.
function browser_needs_docker(){
local browser="$1"
case "${WEKAN_PLAYWRIGHT_DOCKER:-}" in
1) return 0 ;;
0) return 1 ;;
esac
local var="WEKAN_$(printf '%s' "$browser" | tr '[:lower:]' '[:upper:]')_DOCKER"
case "${!var:-auto}" in
1) return 0 ;;
0) return 1 ;;
esac
# auto: the native Playwright browser binaries have no working runtime on
# Linux arm64 (missing system deps on distros like Asahi) - they SKIP every
# project with "browser cannot launch on this host", so Chromium and Firefox
# would exit immediately with no tests run. Route ALL three through the
# official Playwright Docker image there, which ships working browsers. On
# x86_64 Linux, macOS and Windows the native browsers work, so run natively.
if [ "$PLATFORM_OS" = "linux" ] && [ "$PLATFORM_ARCH" = "arm64" ]; then
return 0
fi
# Playwright builds WebKit for Debian/Ubuntu only. On Arch and Omarchy its
# WebKit is linked against other ICU/libjpeg/libwebp versions than the
# rolling release has and does not launch, while Chromium and Firefox do.
# So WebKit alone goes to the Docker image there, when Docker is installed.
if [ "$browser" = webkit ] && [ "$PLATFORM_OS" = "linux" ] && \
[ "$(_et_linux_family)" = arch ] && docker_available; then
return 0
fi
return 1
}
# Back-compat: existing callers/env (WEKAN_WEBKIT_DOCKER) keep working.
function webkit_needs_docker(){ browser_needs_docker webkit; }
# VS Code's Flatpak sandbox does not put the host Docker CLI in PATH, even
# though `flatpak-spawn --host docker ...` is available. Keep Docker calls in
# one wrapper so the ARM64 Playwright route works both in an ordinary terminal
# and in the documented sandbox.
function docker_available(){
command -v docker >/dev/null 2>&1 && return 0
command -v flatpak-spawn >/dev/null 2>&1 || return 1
flatpak-spawn --host sh -lc 'command -v docker >/dev/null 2>&1' >/dev/null 2>&1
}
function docker_exec(){
if command -v docker >/dev/null 2>&1; then
docker "$@"
else
flatpak-spawn --host docker "$@"
fi
}
# Run a Playwright browser project (chromium / firefox / webkit) inside the
# official Playwright container. First arg is the browser; any extra args are
# passed through to `playwright test`. WeKan must already be running on the host
# (default http://localhost:3000, Meteor's bundled Mongo on 3001); the container
# shares the host network so it can reach both.
#
# The browser must use the test server's ROOT_URL origin, http://localhost:3000.
# Meteor-Files sends every upload to ROOT_URL + /cdn/storage/.../__upload, and
# answers no CORS preflight for another origin, so a page opened on
# http://127.0.0.1:3000 could not upload anything: the drawing specs' Save never
# finished. Health checks with curl may still use 127.0.0.1; a browser may not.
function run_playwright_docker(){
local browser="$1"; shift
local reporoot="$WEKAN_DIR"
local pwdir="$reporoot/tests/playwright"
local filesroot="${WEKAN_FILES_PATH_HOST:-$reporoot/.build/bundle/files}"
if ! docker_available; then
echo "ERROR: Docker is required to run $browser in the Playwright container, but 'docker' was not found."
echo " Install Docker, or run this browser natively (set WEKAN_PLAYWRIGHT_DOCKER=0)."
return 127
fi
ensure_playwright_test_dependencies || return 1
local pwver
pwver="$(node -e "console.log(require('$pwdir/node_modules/@playwright/test/package.json').version)" 2>/dev/null)"
[ -z "$pwver" ] && pwver="1.60.0"
local image="mcr.microsoft.com/playwright:v${pwver}-noble"
local docker_base_url="${WEKAN_BASE_URL:-http://localhost:3000}"
local docker_mongo_url="${WEKAN_MONGO_URL:-mongodb://127.0.0.1:3001/meteor}"
local docker_host_proxy_port=""
local docker_network="host"
# Docker Desktop on macOS keeps the host outside the container's loopback.
if [ "$(uname -s)" = Darwin ]; then
# Its host network loses connections to host.docker.internal: from a
# container on that network about 1 connect in 75 never completed, so a
# test page waited 60 s for a document that never came (Firefox, 2026-11-01:
# 8 of 600 direct connects, against 0 of 1000 from the bridge network).
# Nothing here needs the host network on macOS - WeKan and Mongo are reached
# through host.docker.internal and SMTP through docker exec - so the
# container uses Docker's bridge network, where the proxy below still
# serves localhost.
docker_network="bridge"
# Meteor's dynamic imports use ROOT_URL (localhost in the test server).
# Keep the browser on that same origin and bridge its loopback to the host.
# Browsing host.docker.internal while ROOT_URL is localhost makes Firefox
# fetch dynamic modules from the container's unreachable localhost.
if [[ "$docker_base_url" =~ ^http://(127\.0\.0\.1|localhost):([0-9]+)$ ]]; then
docker_host_proxy_port="${BASH_REMATCH[2]}"
docker_base_url="http://localhost:$docker_host_proxy_port"
fi
docker_mongo_url="${docker_mongo_url//127.0.0.1/host.docker.internal}"
docker_mongo_url="${docker_mongo_url//localhost/host.docker.internal}"
# The source server's replica set advertises host loopback addresses.
# Pin this test connection to the gateway instead of rediscovering them.
if [[ "$docker_mongo_url" == *host.docker.internal* && "$docker_mongo_url" != *directConnection=* ]]; then
if [[ "$docker_mongo_url" == *\?* ]]; then
docker_mongo_url="${docker_mongo_url}&directConnection=true"
else
docker_mongo_url="${docker_mongo_url}?directConnection=true"
fi
fi
fi
mkdir -p "$filesroot"
echo "Running Playwright $browser in Docker ($image)."
echo "Expecting WeKan at $docker_base_url."
local container_name="wekan-playwright-${browser}-$$"
# Docker Desktop keeps the container's loopback from the host, so a server a
# spec starts in the container cannot be reached by WeKan outside it. Each
# port below is forwarded from the host into the container through docker
# exec (helpers/docker-smtp-proxy.cjs, which carries any TCP): the test SMTP
# server, and a small pool for the mock Jira/GitLab servers of the list-sync
# specs, which take a free port from WEKAN_TEST_REVERSE_PORTS
# (helpers/reachable-server.cjs). Elsewhere they listen on any port.
local forward_pids="" forward_ready="" reverse_ports=""
if [ "$(uname -s)" = Darwin ]; then
reverse_ports="${WEKAN_TEST_REVERSE_PORTS:-39101 39102 39103 39104}"
local fport fready fpid i
for fport in ${WEKAN_TEST_SMTP_PORT:-} $reverse_ports; do
fready="$(mktemp "$WEKAN_DIR/.tools/tmp/docker-port-proxy.XXXXXX")"
node "$pwdir/helpers/docker-smtp-proxy.cjs" "$container_name" "$fport" "$fready" &
fpid=$!
forward_pids="$forward_pids $fpid"; forward_ready="$forward_ready $fready"
for i in 1 2 3 4 5 6 7 8 9 10; do
[ -s "$fready" ] && break
kill -0 "$fpid" 2>/dev/null || break
sleep 0.1
done
if [ ! -s "$fready" ]; then
kill $forward_pids 2>/dev/null; wait $forward_pids 2>/dev/null
rm -f $forward_ready
echo "ERROR: local test proxy for port $fport did not start."
return 1
fi
done
fi
# Mount the whole repo so specs that reach the repo-root node_modules
# (e.g. @wekanteam/exceljs) and .tools resolve; run from tests/playwright.
# The repo and the files root are mounted at their OWN host paths, so a path
# names the same file for the specs in here and for the server outside:
# admin-continuous-backup gives the server a key file it wrote and reads the
# stream the server wrote, and with the repo at /repo neither side found the
# other's file.
# Run as the host user (--user) with a writable HOME so the container does
# not leave root-owned files under test-results/ (which would later make
# native Chromium/Firefox runs fail with "EACCES: permission denied, mkdir
# .../test-results/.playwright-artifacts-N").
docker_exec run --rm --init --ipc=host --network "$docker_network" \
--name "$container_name" \
--label org.wekan.test-run=everything \
--user "$(id -u):$(id -g)" \
-e HOME="$reporoot/.tools/tmp" \
-e TMPDIR="$reporoot/.tools/tmp" \
-e WEKAN_BASE_URL="$docker_base_url" \
-e WEKAN_DOCKER_HOST_PROXY_PORT="$docker_host_proxy_port" \
-e WEKAN_MONGO_URL="$docker_mongo_url" \
-e WEKAN_PLAYWRIGHT_ALL=1 \
-e WEKAN_PLAYWRIGHT_PROJECT="$browser" \
-e WEKAN_PLAYWRIGHT_WORKERS="${WEKAN_PLAYWRIGHT_WORKERS:-1}" \
-e WEKAN_PLAYWRIGHT_PROBE=0 \
-e WEKAN_TEST_BAIL="${WEKAN_TEST_BAIL:-0}" \
-e WEKAN_TEST_SERIAL="${WEKAN_TEST_SERIAL:-0}" \
-e WEKAN_TEST_SERVER_MODE="${WEKAN_TEST_SERVER_MODE:-bundle}" \
-e WEKAN_TEST_SMTP_PORT="${WEKAN_TEST_SMTP_PORT:-}" \
-e WEKAN_TEST_REVERSE_PORTS="$reverse_ports" \
-e PLAYWRIGHT_HTML_OPEN=never \
-e WEKAN_FILES_PATH="$filesroot" \
-e PLAYWRIGHT_JSON_OUTPUT_NAME="${PLAYWRIGHT_JSON_OUTPUT_NAME:-}" \
-v "$filesroot":"$filesroot" \
-v "$reporoot":"$reporoot" -w "$pwdir" \
"$image" \
sh -c '
export PATH="$PWD/node_modules/.bin:$PATH"
if [ -n "$WEKAN_DOCKER_HOST_PROXY_PORT" ]; then
node "$PWD/helpers/docker-host-proxy.cjs" &
proxy_pid=$!
trap '\''kill "$proxy_pid" 2>/dev/null || true; wait "$proxy_pid" 2>/dev/null || true'\'' EXIT
ready=0
for i in 1 2 3 4 5 6 7 8 9 10; do
if curl -fsS --max-time 3 -o /dev/null "$WEKAN_BASE_URL/sign-in"; then ready=1; break; fi
sleep 1
done
[ "$ready" -eq 1 ] || { echo "Local WeKan proxy did not become ready" >&2; exit 1; }
fi
npx playwright test --project="$0" "$@"
' "$browser" "$@"
local playwright_rc=$?
if [ -n "$forward_pids" ]; then
kill $forward_pids 2>/dev/null; wait $forward_pids 2>/dev/null
rm -f $forward_ready
fi
return "$playwright_rc"
}
# Run the older Puppeteer-based Node E2E regression suite in the same browser
# image used by Playwright. On Linux arm64 there is no compatible host Chromium,
# but the official image already provides one and can reach the shared test
# server and MongoDB through the host network.
function run_node_e2e_docker(){
local reporoot="$WEKAN_DIR"
local pwdir="$reporoot/tests/playwright"
if ! docker_available; then
echo "ERROR: Docker is required for Node E2E on this platform, but 'docker' was not found."
return 127
fi
ensure_playwright_test_dependencies || return 1
local pwver
pwver="$(node -e "console.log(require('$pwdir/node_modules/@playwright/test/package.json').version)" 2>/dev/null)"
[ -z "$pwver" ] && pwver="1.60.0"
local image="mcr.microsoft.com/playwright:v${pwver}-noble"
echo "Running Node E2E in Docker ($image)."
docker_exec run --rm --init --ipc=host --network host \
--label org.wekan.test-run=everything \
--user "$(id -u):$(id -g)" \
-e HOME=/repo/.tools/tmp \
-e TMPDIR=/repo/.tools/tmp \
-e NODE_OPTIONS="${NODE_OPTIONS:-}" \
-e WEKAN_BASE_URL="${WEKAN_BASE_URL:-http://localhost:3000}" \
-e WEKAN_MONGO_URL="${WEKAN_MONGO_URL:-mongodb://127.0.0.1:3001/meteor}" \
-v "$reporoot":/repo -w /repo \
"$image" \
sh -c 'browser_path="$(find /ms-playwright -type f -path "*/chrome-linux*/chrome" -perm -111 | sort -r | head -n 1)"; test -n "$browser_path" || { echo "No Chromium executable found in Playwright image" >&2; exit 127; }; CHROMIUM_PATH="$browser_path" exec node tests/e2e/list-regressions.js'
}
# Back-compat wrapper: run the WebKit project in Docker.
function run_playwright_webkit_docker(){ run_playwright_docker webkit "$@"; }
# Install the Chromium + Firefox + WebKit browsers Playwright uses, both for
# native runs (`playwright install --with-deps`) and for Docker runs (pull the
# official Playwright image that already bundles all three). Honors the
# WEKAN_PLAYWRIGHT_DOCKER / WEKAN_<BROWSER>_DOCKER settings: a browser configured
# to run in Docker is covered by the image pull rather than a native install.
function install_playwright_browsers(){
ORIG_HOME="$HOME"
local reporoot="$WEKAN_DIR"
local pwdir="$reporoot/tests/playwright"
ensure_playwright_test_dependencies || return 1
# Native install for whichever browsers are NOT configured for Docker.
local nativeList=""
for b in chromium firefox webkit; do
browser_needs_docker "$b" || nativeList="$nativeList $b"
done
if [ -n "$nativeList" ]; then
if [ "$PLATFORM_OS" = "linux" ] && [ "$(_et_linux_family)" != debian ]; then
# `--with-deps` runs apt-get, which only Debian/Ubuntu have. Elsewhere
# install the libraries the browsers load with the native package
# manager, then the browsers themselves without --with-deps.
echo "Installing native Playwright browsers:$nativeList (system deps from the distribution's packages; may use sudo)."
ensure_playwright_system_deps
( cd "$pwdir" && export HOME="$reporoot/.tools" && npx playwright install $nativeList )
else
echo "Installing native Playwright browsers:$nativeList (with system deps; may use sudo)."
( cd "$pwdir" && export HOME="$reporoot/.tools" && npx playwright install --with-deps $nativeList )
fi
else
echo "All browsers are configured to run via Docker; skipping native browser install."
fi
# Pull the Playwright Docker image if any browser is configured for Docker
# (e.g. WebKit on Linux arm64, or WEKAN_PLAYWRIGHT_DOCKER=1 for the whole matrix).
if browser_needs_docker chromium || browser_needs_docker firefox || browser_needs_docker webkit; then
if docker_available; then
local pwver
pwver="$(node -e "console.log(require('$pwdir/node_modules/@playwright/test/package.json').version)" 2>/dev/null)"
[ -z "$pwver" ] && pwver="1.60.0"
echo "Pulling Playwright Docker image mcr.microsoft.com/playwright:v${pwver}-noble ..."
docker_exec pull "mcr.microsoft.com/playwright:v${pwver}-noble"
else
echo "NOTE: some browsers are configured for Docker, but 'docker' is not installed."
echo " Install Docker, or set WEKAN_PLAYWRIGHT_DOCKER=0 to run all browsers natively."
fi
fi
echo "Done. Run a browser suite from the menu, or: WEKAN_PLAYWRIGHT_DOCKER=1 ./build.sh"
}
# Reset test-results/ ownership when an older WebKit-in-Docker run left it owned
# by root. Removal needs write permission on test-results/ itself (which the
# host user lacks when it is root-owned), so this needs sudo; it is a no-op when
# the directory is already writable or absent.
function ensure_test_results_writable(){
local pwdir="$WEKAN_DIR/tests/playwright"
[ -e "$pwdir/test-results" ] || return 0
[ -w "$pwdir/test-results" ] && return 0
echo "Fixing test-results/ ownership (left as root by an earlier Docker WebKit run)."
sudo chown -R "$(id -u):$(id -g)" "$pwdir/test-results" 2>/dev/null || \
sudo rm -rf "$pwdir/test-results" 2>/dev/null || true
}
# Print "<n> passed, <n> failed, ..." for a Playwright JSON report file.
function pw_stats_of(){
[ -f "$1" ] || return 0
node -e 'const fs=require("fs");let r;try{r=JSON.parse(fs.readFileSync(process.argv[1],"utf8"))}catch(e){process.exit(0)}const s=r.stats||{};console.log(`${s.expected||0} passed, ${s.unexpected||0} failed, ${s.flaky||0} flaky, ${s.skipped||0} skipped`);' "$1"
}
# Print "[label] file:line › title" for each failing spec in a Playwright JSON report.
function pw_failures_of(){
[ -f "$1" ] || return 0
node -e 'const fs=require("fs");let r;try{r=JSON.parse(fs.readFileSync(process.argv[1],"utf8"))}catch(e){process.exit(0)}const label=process.argv[2]||"";const out=[];function walk(su,ti){const t=[...ti,su.title].filter(Boolean);for(const s of su.suites||[])walk(s,t);for(const sp of su.specs||[]){if(sp.ok)continue;const loc=sp.file?`${sp.file}:${sp.line}`:"";out.push(`[${label}] ${loc} › ${[...t,sp.title].join(" › ")}`);}}for(const s of r.suites||[])walk(s,[]);out.forEach(l=>console.log(l));' "$1" "$2"
}
# Select the repository-local browser cache used by the Flatpak bootstrap.
# Explicit caller configuration wins; every automatic install uses the ignored
# .tools cache on both ordinary Fedora and Flatpak.
function set_playwright_browser_path(){
[ -n "${PLAYWRIGHT_BROWSERS_PATH:-}" ] || \
export PLAYWRIGHT_BROWSERS_PATH="$WEKAN_DIR/.tools/ms-playwright"
}
function ensure_playwright_test_dependencies(){
local pwdir="$WEKAN_DIR/tests/playwright"
if [ ! -x "$pwdir/node_modules/.bin/playwright" ] ||
! ( cd "$pwdir" && meteor npm ls --depth=0 ) >/dev/null 2>&1; then
echo "Installing current Playwright test dependencies under tests/playwright/node_modules."
( cd "$pwdir" && meteor npm install ) || return 1
fi
}
function native_browser_is_installed(){
local browser="$1" pwdir="$WEKAN_DIR/tests/playwright"
set_playwright_browser_path
( cd "$pwdir" && node -e "const fs=require('fs');const p=require('@playwright/test').${browser}.executablePath();fs.accessSync(p,fs.constants.X_OK)" ) >/dev/null 2>&1
}
function ensure_native_playwright_browser(){
local browser="$1" pwdir="$WEKAN_DIR/tests/playwright"
set_playwright_browser_path
if native_browser_is_installed "$browser"; then
echo "Using cached Playwright $browser browser from $PLAYWRIGHT_BROWSERS_PATH."
return 0
fi
mkdir -p "$PLAYWRIGHT_BROWSERS_PATH"
echo "Ensuring the matching Playwright $browser browser is installed in $PLAYWRIGHT_BROWSERS_PATH."
( cd "$pwdir" && "$pwdir/node_modules/.bin/playwright" install "$browser" )
}
function native_browser_can_launch(){
local browser="$1" pwdir="$WEKAN_DIR/tests/playwright"
(
cd "$pwdir" || exit 1
export HOME="$WEKAN_DIR/.tools"
unset CHROME_DEVEL_SANDBOX
set_playwright_browser_path
node helpers/browser-launch.cjs "$browser"
) >/dev/null 2>&1
}
# Run one Playwright browser project for the whole-suite flows.
# Writes test-results/all-tests-<browser>.json and returns playwright's exit code.
# On Linux arm64 every browser goes through Docker (see browser_needs_docker).
function run_pw_all_browser(){
local browser="$1"
local pwdir="$WEKAN_DIR/tests/playwright"
local json="test-results/all-tests-${browser}.json"
# Per-browser output dir so suites can run in parallel without their
# artifacts (.playwright-artifacts-N, screenshots, traces) colliding or
# wiping each other when Playwright clears its output dir at startup.
local outdir="test-results/${browser}"
ensure_playwright_test_dependencies || return 1
if ! browser_needs_docker "$browser"; then
if ! ensure_native_playwright_browser "$browser"; then
docker_available || { echo "ERROR: could not install Playwright $browser and Docker is unavailable."; return 1; }
fi
fi
# Prefer a working native browser, but do not turn missing immutable Flatpak
# runtime libraries into one failure per test. The host-Docker wrapper is
# available through flatpak-spawn and carries the matching browser runtime.
if ! browser_needs_docker "$browser" && ! native_browser_can_launch "$browser" && docker_available; then
echo "Native Playwright $browser cannot launch; using the official container instead."
( cd "$pwdir" && export PLAYWRIGHT_JSON_OUTPUT_NAME="$json" && run_playwright_docker "$browser" --output="$outdir" --reporter=list,json )
return $?
fi
ensure_test_results_writable
rm -f "$pwdir/$json"
if browser_needs_docker "$browser"; then
( cd "$pwdir" && export PLAYWRIGHT_JSON_OUTPUT_NAME="$json" && run_playwright_docker "$browser" --output="$outdir" --reporter=list,json )
return $?
fi
(
cd "$pwdir"
export HOME="$WEKAN_DIR/.tools"
set_playwright_browser_path
unset CHROME_DEVEL_SANDBOX
export WEKAN_PLAYWRIGHT_ALL=1
export WEKAN_PLAYWRIGHT_PROJECT="$browser"
export PLAYWRIGHT_JSON_OUTPUT_NAME="$json"
PLAYWRIGHT_HTML_OPEN=never meteor npm exec playwright test -- --project="$browser" --output="$outdir" --reporter=list,json
)
return $?
}
# Run Chromium, Firefox and WebKit suites concurrently against a WeKan server
# that is already running on http://localhost:3000 (menu option 3). Each suite
# streams to $RUN_LOGDIR/wekan-playwright-<browser>.log; once all finish we print each
# log followed by a per-browser PASS/FAIL summary. Tests seed their own random
# users/boards and clean up by id, so running the three browsers at once is safe.
function run_playwright_parallel(){
ORIG_HOME="$HOME"
local pwdir="$WEKAN_DIR/tests/playwright"
ensure_test_results_writable
if ! curl -fsS --connect-timeout 2 --max-time 4 http://127.0.0.1:3000/sign-in >/dev/null 2>&1; then
echo "ERROR: WeKan does not appear to be running on http://localhost:3000."
echo " Start it first with menu option 3, then re-run this option."
return 1
fi
read -p "Install Playwright test dependencies first? [y/N] " INSTALL_DEPS
case "$INSTALL_DEPS" in [Yy]*) ( cd "$pwdir" && meteor npm install ) ;; esac
# This run's own .tools/log/<operation>/YYYY-MM-DD_HH-MM-SS/ dir, so logs are never overwritten.
local RUN_LOGDIR
RUN_LOGDIR="$(log_directory test-playwright-all)" || return $?
mkdir -p "$RUN_LOGDIR"
echo "Running Chromium, Firefox and WebKit Playwright suites sequentially (one browser at a time)."
echo "Each browser's output (Playwright 'list' reporter, one line per test) streams live below and is also saved to $RUN_LOGDIR/wekan-playwright-<browser>.log."
# Run the three browser suites one after another rather than in parallel:
# running all three at once against a single dev server uses too much RAM and
# swap on lower-memory machines and may crash. Each browser streams live via
# tee (so progress is visible while it runs) AND writes its own log; the
# combined summary is printed once all have finished.
local rc_chromium rc_firefox rc_webkit
local ts
# Stream live to the console with tee while also saving to this run's
# .tools/log/<operation>/YYYY-MM-DD_HH-MM-SS/ dir. PIPESTATUS[0] is run_pw_all_browser's exit code (the
# left side of the pipe), not tee's, so the pass/fail result stays accurate.
for entry in "chromium:Chromium" "firefox:Firefox" "webkit:WebKit"; do
browser="${entry%%:*}"; label="${entry#*:}"
ts="$(date '+%Y-%m-%d %H:%M:%S %Z')"
echo
echo "==================== Playwright $label (live) ===================="
{ echo "===== Playwright $label - test run started $ts ====="; echo; run_pw_all_browser "$browser"; } 2>&1 | tee "$RUN_LOGDIR/wekan-playwright-${browser}.log"
local rc=${PIPESTATUS[0]}
case "$browser" in
chromium) rc_chromium=$rc ;;
firefox) rc_firefox=$rc ;;
webkit) rc_webkit=$rc ;;
esac
done
local PW_FAILURES=""
SUMMARY=()
record() { SUMMARY+=("$1|$2|${3:-}"); }
for entry in "chromium:Chromium:$rc_chromium" "firefox:Firefox:$rc_firefox" "webkit:WebKit:$rc_webkit"; do
browser="${entry%%:*}"; rest="${entry#*:}"; label="${rest%%:*}"; rc="${rest#*:}"
# Output already streamed live above; here we only compute the summary.
local json="$pwdir/test-results/all-tests-${browser}.json"
local stats; stats="$(pw_stats_of "$json")"
if [ "$rc" -eq 0 ]; then record PASS "Playwright $label" "$stats"; else record FAIL "Playwright $label" "$stats"; fi
local fails; fails="$(pw_failures_of "$json" "$label")"
[ -n "$fails" ] && PW_FAILURES="${PW_FAILURES}${fails}"$'\n'
done
echo
echo "==================== PLAYWRIGHT SUMMARY ===================="
local FAILED=0
for line in "${SUMMARY[@]}"; do
status="${line%%|*}"; rest="${line#*|}"; name="${rest%%|*}"; stats="${rest#*|}"
suffix=""; [ -n "$stats" ] && suffix=" ($stats)"
printf ' %-6s %s%s\n' "$status" "$name" "$suffix"
[ "$status" = "FAIL" ] && FAILED=1
done
echo "==========================================================="
if [ -n "$PW_FAILURES" ]; then
echo
echo "Failing Playwright tests:"
while IFS= read -r f; do
[ -n "$f" ] && printf ' FAIL %s\n' "$f"
done <<< "$PW_FAILURES"
echo "(full per-browser output above; HTML report in tests/playwright/playwright-report)"
echo "==========================================================="
fi
if [ "$FAILED" -eq 0 ]; then echo "RESULT: All Playwright browsers passed."; else echo "RESULT: Some Playwright browsers FAILED (see details above)."; fi
return $FAILED
}
# Run one Playwright browser project interactively (single-browser menu items).
# one_log <name> — an operation/date/time directory for a single test option, and the
# path of the file to tee into. Every option in the Tests menu writes there, so
# "the newest test logs" is one directory whichever option produced them. When a
# larger run is driving this (EVERYTHING), WEKAN_LOGDIR is already set and is used
# instead, so one run stays in one directory.
one_log() {
local name="$1" dir
if [ -n "${WEKAN_LOGDIR:-}" ]; then dir="$WEKAN_LOGDIR"
elif [ "$name" = dev-server ]; then dir="$(log_directory dev-server)" || return $?
else dir="$(log_directory "test-$name")" || return $?; fi
mkdir -p "$dir" || return $?
printf '%s/wekan-%s.log' "$(cd "$dir" && pwd)" "$name"
}
function run_playwright_single(){
local browser="$1"
ORIG_HOME="$HOME"
if browser_needs_docker "$browser"; then
echo "Running $browser via the official Playwright Docker image."
echo "Make sure WeKan is running on http://localhost:3000 (a whole-suite option starts it for you)."
( cd "$WEKAN_DIR/tests/playwright" && run_playwright_docker "$browser" )
return $?
fi
cd "$WEKAN_DIR/tests/playwright"
export HOME="$WEKAN_DIR/.tools"
set_playwright_browser_path
unset CHROME_DEVEL_SANDBOX
export WEKAN_PLAYWRIGHT_ALL=1
export WEKAN_PLAYWRIGHT_PROJECT="$browser"
read -p "Install Playwright test dependencies first? [y/N] " INSTALL_DEPS
case "$INSTALL_DEPS" in [Yy]*) meteor npm install ;; esac
local log
log="$(cd "$WEKAN_DIR" && one_log "playwright-$browser")"
echo "Log: $log"
meteor npm exec playwright test -- --project="$browser" 2>&1 | tee "$log"
return "${PIPESTATUS[0]}"
}
# Run the full test matrix (Mocha, import regression, Node E2E and the three
# Playwright browser suites) against ONE WeKan server on http://localhost:3000,
# then print a combined PASS/FAIL summary.
# $1 = "parallel" -> start every job at once and show a live progress table.
# Fast, but memory-hungry (fine on a 32 GB machine).
# $1 = "sequential" -> run one job at a time to keep RAM/swap usage low on
# smaller machines.
# Does a real MongoDB answer on this port? A TCP connect proves only that
# something accepted the socket; this asks the database itself. Uses the driver
# already in the built bundle, so it needs nothing installed.
mongo_answers() {
local port="$1"
local modules="$BUNDLE_DIR/programs/server/node_modules"
[ -d "$modules" ] || return 1
[ -x "$NODE_BIN" ] || return 1
NODE_PATH="$modules" "$NODE_BIN" -e '
const { MongoClient } = require("mongodb");
const url = "mongodb://127.0.0.1:" + process.argv[1] + "/meteor";
const client = new MongoClient(url, { serverSelectionTimeoutMS: 3000, connectTimeoutMS: 3000 });
client.connect()
.then(() => client.db("admin").command({ ping: 1 }))
.then(() => client.close(true))
.then(() => process.exit(0))
.catch(() => process.exit(1));
' "$port" >/dev/null 2>&1
}
function run_all_tests(){
local REQUESTED_MODE="${1:-two-worker}"
local RUN_MODE="$REQUESTED_MODE" PLAYWRIGHT_WORKERS=1
case "$REQUESTED_MODE" in
two-worker) RUN_MODE=sequential; PLAYWRIGHT_WORKERS=2 ;;
sequential) RUN_MODE=sequential ;;
parallel) RUN_MODE=parallel ;;
*) echo "ERROR: unknown EVERYTHING mode: $REQUESTED_MODE" >&2; return 2 ;;
esac
export WEKAN_PLAYWRIGHT_WORKERS="$PLAYWRIGHT_WORKERS"
export WEKAN_TEST_SERIAL=0
[ "$REQUESTED_MODE" = sequential ] && export WEKAN_TEST_SERIAL=1
local modeword
[ "$REQUESTED_MODE" = parallel ] && modeword="at once (concurrently)" || modeword="one stage at a time (${PLAYWRIGHT_WORKERS} Playwright worker(s))"
# The large heap at the top of this file is for compiling WeKan. Do not hand
# that same half-of-all-RAM allowance to every runtime process in the test
# matrix: the bundle server stays alive while E2E/Playwright run, and allowing
# each Node child 8-16 GiB lets one leaking suite drive the workstation into
# swap or the OOM killer. Test processes get a quarter of RAM, clamped to a
# useful maximum of 4 GiB; callers with an exceptional fixture can override this one
# scope without lowering the heap needed by Meteor's build tool.
local TEST_HEAP_MB=$(( _mem_total_mb / 4 ))
[ "$TEST_HEAP_MB" -gt 4096 ] && TEST_HEAP_MB=4096
local TEST_NODE_OPTIONS="${WEKAN_TEST_NODE_OPTIONS:---max-old-space-size=$TEST_HEAP_MB}"
echo "Node heap limit for test runtime processes: ${TEST_HEAP_MB} MB."
echo " Override by exporting WEKAN_TEST_NODE_OPTIONS yourself."
# Each whole-suite run gets its own .tools/log/<operation>/YYYY-MM-DD_HH-MM-SS/ directory
# (stamped once, when the run starts), so logs are never overwritten and
# previous runs are kept.
local RUN_TS RUN_LOGDIR
# WEKAN_LOGDIR is set when a larger run is driving this (EVERYTHING), and
# then it is THE directory for the whole run - minting a second one here
# would split one run's logs across two directories, which is the thing the
# per-run directory exists to prevent. Standalone, this stamps its own and
# exports it, so every job of this run agrees on it rather than each calling
# date() again and landing in a different second.
if [ -n "${WEKAN_LOGDIR:-}" ]; then
RUN_LOGDIR="$WEKAN_LOGDIR"
else
RUN_TS="$(date '+%Y-%m-%d_%H-%M-%S')"
RUN_LOGDIR="$(log_directory "test-all-$RUN_MODE")" || return $?
fi
mkdir -p "$RUN_LOGDIR"
RUN_LOGDIR="$(cd "$RUN_LOGDIR" && pwd)"
export WEKAN_LOGDIR="$RUN_LOGDIR"
echo "Logs for this run: $RUN_LOGDIR/ (previous runs are kept)"
# Tests ALWAYS run against a freshly built bundle. The :3000 test server runs
# the precompiled .build/bundle, so a stale bundle means the suite passes or
# fails on code that is no longer in the working tree - the one thing a test
# run must never do. This used to build only when .build/bundle was missing,
# which is exactly the case where the bundle is present but old.
if [ "${WEKAN_TEST_SERVER_MODE:-bundle}" != source ]; then
echo "==> Deleting .build and building WeKan before running the tests (always, so the tests run against the current source)."
build_wekan
if [ ! -d .build/bundle ]; then
echo "ERROR: .build/bundle is missing after building. Aborting the test run."
return 1
fi
fi
if [ "$RUN_MODE" = parallel ]; then
echo "Running ALL tests against ONE WeKan server on http://localhost:3000 - all jobs run IN PARALLEL (concurrently). Needs plenty of RAM (fine on 32 GB)."
else
echo "Running ALL tests against ONE WeKan server on http://localhost:3000 - all jobs run SEQUENTIALLY (one at a time)."
fi
if [ "${WEKAN_TEST_SERVER_MODE:-bundle}" = source ]; then
echo " :3000 - current source loaded by Node; Meteor's bundled development MongoDB on :3001."
echo " Mocha runs first in the source process, then a fresh source process serves E2E and browsers."
else
echo "Two WeKan servers are involved (they do NOT run tests in parallel unless you chose parallel):"
echo " :3000 - the PRECOMPILED .build/bundle run as a plain Node server (Meteor's mongod on :3001)"
echo " - serves Node E2E + Playwright browser tests. Built fresh above, so the tests run against the current source."
echo " :3100 - Mocha via 'meteor test' (its own .meteor/local-test build; the in-process server-side tests"
echo " CANNOT run from a production bundle, so this one build is unavoidable)."
fi
echo " Import regression is a plain Node script (no server, no MongoDB)."
SUMMARY=()
record() { SUMMARY+=("$1|$2|${3:-}"); }
label_of() { case "$1" in
mocha) echo "Mocha (server-side)" ;;
unit) echo "Unit tests (node)" ;;
import) echo "Import regression" ;;
e2e) echo "Node E2E regressions" ;;
chromium) echo "Playwright Chromium" ;;
firefox) echo "Playwright Firefox" ;;
webkit) echo "Playwright WebKit" ;;
esac; }
# Which Meteor instance each job talks to, with the Node.js (app) port and the
# bundled MongoDB port (Meteor runs Mongo on app-port+1). The E2E and browser
# jobs use Meteor #1 (the :3000 dev server); Mocha runs its own Meteor #2 on
# :3100 (test build .meteor/local-test, Mongo :3101); the import test is a
# plain Node script and touches no server/DB.
port_of() { case "$1" in
mocha) echo "M2 node:3100 db:3101" ;;
unit) echo "no server (node)" ;;
import) echo "no server (node)" ;;
e2e|chromium|firefox|webkit) echo "M1 node:3000 db:3001" ;;
esac; }
# A live "tests passed" counter per job. Playwright (list reporter), Mocha
# (spec reporter) and the import test all print a U+2713 check mark per passing
# test/assertion; the Node E2E harness prints one "[wekan-e2e] ..." line per
# step. Both advance while the job runs.
count_pass() {
local n
case "$1" in
e2e) n=$(grep -c '\[wekan-e2e\]' "$2" 2>/dev/null) ;;
# The node suites print " ok - <name>" per assertion group.
unit) n=$(grep -cE '^\s*ok - ' "$2" 2>/dev/null) ;;
*) n=$(grep -c $'\xe2\x9c\x93' "$2" 2>/dev/null) ;;
esac
echo "${n:-0}"
}
count_fail() {
local n
case "$1" in
e2e) n=$(grep -c 'wekan-e2e\] FAIL' "$2" 2>/dev/null) ;;
# Two shapes: a suite that keeps going prints " FAIL - <name>" per failure,
# and one that throws ends the whole chain with an AssertionError dump.
# Anchored at the start of the line so the dump's own "throw err" and the
# stack frames below it are not counted as further failures.
# The runner ends with "===== node suites: N run, M failed, Ss =====", so
# the count is its own, not a guess from error text. While it is still
# running there is no summary line yet: fall back to the failing suites it
# has already listed (" x tests/foo.test.cjs (exit 1)"), so the live
# counter moves during the run too.
unit) local summary running
summary=$(grep -oE 'node suites: [0-9]+( of [0-9]+)? run, [0-9]+ failed' "$2" 2>/dev/null | tail -1)
if [ -n "$summary" ]; then
n=$(printf '%s' "$summary" | grep -oE '[0-9]+ failed' | grep -oE '[0-9]+')
else
running=$(grep -cE '^ x tests/' "$2" 2>/dev/null)
n=${running:-0}
fi ;;
*) n=$(grep -cE $'\xe2\x9c\x98|\xe2\x9c\x97' "$2" 2>/dev/null) ;;
esac
echo "${n:-0}"
}
ORIG_HOME="$HOME"
PW_FAILURES=""
TEST_SERVER_PID=""
MONGOD_PID=""
STATDIR="$(mktemp -d "$TMPDIR/wekan-stat.XXXXXX")"
BPIDS=""
# STOP WHAT THIS RUN STARTED, on EVERY way out - not only the happy path.
#
# The cleanup at the end of this function is reached when the run finishes.
# It is not reached when the run is interrupted (Ctrl-C is the usual one) or
# when one of the early `return 1`s above fires, and then the test mongod on
# :3001 and the bundle server on :3000 outlive the run. That is not tidy but
# harmless in itself; what it costs is the NEXT run, which finds the port
# taken. The harness reuses a database only when it ANSWERS - a port held by
# something else moves it aside - so a leftover mongod is silently reused
# instead, holding a database this run did not seed, and the failures land
# somewhere else entirely.
#
# So the stopping is a trap, and the function's own cleanup calls the same
# code. It is idempotent: each half clears its PID, so running twice stops
# nothing twice, and a database this run did NOT start (a reused one, whose
# PID was never recorded) is still left alone.
stop_test_databases() {
if [ -n "$TEST_SERVER_PID" ]; then
echo
echo "Stopping WeKan test server (bundle node :3000)."
kill "$TEST_SERVER_PID" >/dev/null 2>&1 || true
wait "$TEST_SERVER_PID" >/dev/null 2>&1 || true
TEST_SERVER_PID=""
fi
if [ -n "$MONGOD_PID" ]; then
echo "Stopping test MongoDB (mongod :${TEST_DB_PORT:-3001})."
kill "$MONGOD_PID" >/dev/null 2>&1 || true
wait "$MONGOD_PID" >/dev/null 2>&1 || true
MONGOD_PID=""
fi
# The database-conformance stage runs each engine in a container named
# wekan-conformance-<engine> and removes it when that engine is done. An
# interrupted run leaves the one it was on, and a container holding port
# 5432 or 3306 fails the NEXT run's engine before it starts.
if docker_available; then
local leftovers
leftovers=$(docker_exec ps -aq --filter "name=^wekan-conformance-" 2>/dev/null)
if [ -n "$leftovers" ]; then
echo "Stopping database-conformance container(s)."
docker_exec rm -f $leftovers >/dev/null 2>&1 || true
fi
fi
}
trap 'trap - EXIT INT TERM; stop_test_databases; cleanup_everything_processes own; release_everything_lock' EXIT
trap 'trap - EXIT INT TERM; stop_test_databases; cleanup_everything_processes own; release_everything_lock; exit 130' INT TERM
# Start one test job in the background: record its exit code in STATDIR/<key>
# and send all of its output to $RUN_LOGDIR/wekan-alltests-<key>.log. In "parallel"
# mode every job runs at once; in "sequential" mode we wait for each job to
# finish before starting the next, which keeps total RAM/swap usage low so the
# machine does not crash (the browser suites in particular are memory-hungry).
# The actual command for one job. Runs the suite and records its exit code in
# STATDIR/<key>. Header/footer record the datetime. Used by both modes below.
run_job_body() {
local k="$1"
echo "===== $(label_of "$k") [$(port_of "$k")] - test run started $(date '+%Y-%m-%d %H:%M:%S %Z') ====="
echo
local rc=0
case "$k" in
# --full-app: 26 integration tests in server/lib/tests (notification
# delivery, stored history, Sync hooks) need the whole app's hooks and
# skip themselves otherwise - they had never run here before. The app
# refuses to start without WRITABLE_PATH. The one-second email receipt
# sweep is what emailReceiptRetention.tests.js waits for.
mocha) EMAIL_RECEIPT_SWEEP_INTERVAL_MS=1000 WRITABLE_PATH="${WRITABLE_PATH:-..}" METEOR_LOCAL_DIR=.meteor/local-test meteor test --full-app --once --driver-package meteortesting:mocha --port 3100 || rc=$? ;;
# Every plain-node suite: the .cjs guards plus the stickers / Trello /
# OAuth2 .js tests. They need no server and no browser, and the whole-suite
# run did not run them at all once - a guard that was supposed to fail the
# suite ran nowhere.
# tests/run-node-suites.cjs: every plain-node suite, each in its own node
# process, ALL of them run even when one fails, and the failures listed
# together at the end. It used to be an && chain, where node stopped at the
# first failing suite and the ~200 after it never ran while the summary
# still read "tests:508 fail:1".
unit) NODE_OPTIONS="$TEST_NODE_OPTIONS" meteor npm run test:unit:all || rc=$? ;;
import) NODE_OPTIONS="$TEST_NODE_OPTIONS" node tests/wekanCreator.import.test.js || rc=$? ;;
e2e) if browser_needs_docker chromium; then NODE_OPTIONS="$TEST_NODE_OPTIONS" run_node_e2e_docker || rc=$?; else NODE_OPTIONS="$TEST_NODE_OPTIONS" meteor npm run test:e2e || rc=$?; fi ;;
*) NODE_OPTIONS="$TEST_NODE_OPTIONS" run_pw_all_browser "$k" || rc=$? ;;
esac
echo "$rc" > "$STATDIR/$k"
echo
echo "===== $(label_of "$k") - test run finished $(date '+%Y-%m-%d %H:%M:%S %Z') ====="
}
launch_job() {
local k="$1"
local jlog="$RUN_LOGDIR/wekan-alltests-$k.log"
if [ "$RUN_MODE" = parallel ]; then
# Parallel: run in the background to a log; the combined table below reads
# each log for the live pass/fail counts (many suites at once, so we cannot
# stream them all interleaved to one console).
echo "==> Starting $(label_of "$k") (parallel) ... live output: $jlog"
run_job_body "$k" > "$jlog" 2>&1 &
BPIDS="$BPIDS $!"
else
# Sequential: only one suite runs at a time, so stream its output STRAIGHT
# to the console (via tee, also saving the log) — you see every test tick by
# one-by-one as the reporter prints it, instead of just a counter. tee is on
# the right of the pipe, so run_job_body's exit code is captured in STATDIR.
echo "==> Running $(label_of "$k") (sequential) — streaming live below (also saved to $jlog):"
run_job_body "$k" 2>&1 | tee "$jlog"
local jrc jst jok jbad
jrc=$(cat "$STATDIR/$k" 2>/dev/null); jrc=${jrc:-1}
[ "$jrc" = "0" ] && jst="PASS" || jst="FAIL"
jok=$(count_pass "$k" "$jlog"); jbad=$(count_fail "$k" "$jlog")
printf ' [%-4s] %-22s tests:%-4s fail:%s\n' "$jst" "$(label_of "$k")" "$jok" "$jbad"
if [ "${WEKAN_TEST_BAIL:-0}" = 1 ] && [ "$jrc" -ne 0 ]; then return "$jrc"; fi
fi
}
if curl -fsS http://127.0.0.1:3000 >/dev/null 2>&1; then
echo "==> Port 3000 is already in use; stopping the existing Meteor dev server before starting our own."
# Kill the Meteor dev server(s) on :3000. pgrep matches the parent
# 'meteor run --port 3000' process; killing it also tears down the
# node child it spawned. Fall back to whatever is listening on the
# port (lsof/fuser) in case the process command line does not match.
OLD_PIDS="$(pgrep -f 'meteor run --port 3000' 2>/dev/null)"
if [ -n "$OLD_PIDS" ]; then
echo " Killing existing Meteor PIDs:$(echo " $OLD_PIDS" | tr '\n' ' ')"
kill $OLD_PIDS 2>/dev/null
fi
# Wait for the port to actually free up, escalating to SIGKILL.
for i in $(seq 1 30); do
curl -fsS http://127.0.0.1:3000 >/dev/null 2>&1 || break
if [ "$i" -eq 15 ]; then
echo " Still in use after 15s; sending SIGKILL."
STUCK_PIDS="$(pgrep -f 'meteor run --port 3000' 2>/dev/null)"
[ -n "$STUCK_PIDS" ] && kill -9 $STUCK_PIDS 2>/dev/null
if command -v lsof >/dev/null 2>&1; then
LSOF_PIDS="$(lsof -ti tcp:3000 2>/dev/null)"
[ -n "$LSOF_PIDS" ] && kill -9 $LSOF_PIDS 2>/dev/null
elif command -v fuser >/dev/null 2>&1; then
fuser -k 3000/tcp >/dev/null 2>&1
fi
fi
printf '.'; sleep 1
done
echo
if curl -fsS http://127.0.0.1:3000 >/dev/null 2>&1; then
echo "ERROR: Port 3000 is still in use after attempting to stop the existing server. Stop it manually and retry."
rm -rf "$STATDIR"
return 1
fi
echo " Port 3000 is now free."
fi
# Both server modes use the local-only SMTP sink owned by the email specs.
# Bundle mode used to omit this, failing invitations and skipping delivery tests.
export WEKAN_TEST_SMTP_PORT="${WEKAN_TEST_SMTP_PORT:-2525}"
export MAIL_URL="smtp://127.0.0.1:$WEKAN_TEST_SMTP_PORT"
export EMAIL_NOTIFICATION_TIMEOUT=100
# The List Sync specs run their mock tracker on 127.0.0.1, which SyncBleed's
# guard refuses unless the administrator allows that exact host. Only the
# test server allows it; the specs use other internal names for refusals.
export LIST_SYNC_ALLOWED_PRIVATE_HOSTS=127.0.0.1
# The Docker and Snap default, so the browser tests see the framing policy
# (FrameBleed) the way a real install sends it.
export BROWSER_POLICY_ENABLED=true
# Every page load refreshes the login cookie, and Meteor allows 30 per 10
# seconds per address; the whole browser suite comes from localhost, so a
# quick run signed its own test users out (server/accounts-common.js).
export ACCOUNTS_COOKIE_REFRESH_RATE_LIMIT="${ACCOUNTS_COOKIE_REFRESH_RATE_LIMIT:-100000}"
if [ "${WEKAN_TEST_SERVER_MODE:-bundle}" = source ]; then
export WEKAN_PLAYWRIGHT_PROBE=0
local WRITABLE_ABS="$WEKAN_DIR/.tools/test-writable"
mkdir -p "$WRITABLE_ABS/files"
export WEKAN_FILES_PATH_HOST="$WRITABLE_ABS/files"
export WEKAN_FILES_PATH="$WRITABLE_ABS/files"
export WEKAN_MONGO_URL="mongodb://127.0.0.1:3001/meteor"
local NODE_BIN="$(command -v node)"
local TAIL_PID=""
echo "==> Running Meteor server tests in the source-loaded Node process on :3000."
WEKAN_SOURCE_WATCH=0 ROOT_URL=http://localhost:3000 WRITABLE_PATH="$WRITABLE_ABS" WITH_API=true \
NODE_OPTIONS="$TEST_NODE_OPTIONS" "$NODE_BIN" scripts/dev-source/start.cjs --port 3000 --server-tests \
2>&1 | tee "$RUN_LOGDIR/wekan-alltests-mocha.log"
local source_mocha_rc=${PIPESTATUS[0]}
if [ "$source_mocha_rc" -ne 0 ]; then return "$source_mocha_rc"; fi
echo 0 > "$STATDIR/mocha"
echo "==> Starting source-loaded WeKan :3000 and Meteor's development MongoDB :3001."
WEKAN_SOURCE_WATCH=0 ROOT_URL=http://localhost:3000 WRITABLE_PATH="$WRITABLE_ABS" WITH_API=true \
NODE_OPTIONS="$TEST_NODE_OPTIONS" "$NODE_BIN" scripts/dev-source/start.cjs --port 3000 \
> "$RUN_LOGDIR/wekan-test-server.log" 2>&1 &
TEST_SERVER_PID=$!
tail -n 0 -f "$RUN_LOGDIR/wekan-test-server.log" &
TAIL_PID=$!
else
# Start the :3000 test server from the PRECOMPILED .build/bundle (NOT `meteor run`)
# so Node E2E + Playwright reuse the WeKan you already built with
# `meteor build .build --directory` — no recompile. The bundle is a plain Node
# server, so it needs (1) its own MongoDB (we start Meteor's bundled mongod on
# :3001) and (2) its server npm deps installed once. Mocha (:3100) still uses
# `meteor test` below — its in-process tests cannot run from a production bundle.
echo
local BUNDLE_DIR=".build/bundle"
if [ ! -f "$BUNDLE_DIR/main.js" ]; then
echo "==> $BUNDLE_DIR/main.js not found — building WeKan first (meteor build .build --directory)."
build_wekan
fi
echo "NOTE: tests run the PRECOMPILED $BUNDLE_DIR as-is. After changing source code,"
echo " rebuild it (Tools/menu 'build', i.e. meteor build .build --directory) or the"
echo " tests will run the old code."
# Resolve Meteor's bundled node + mongod: run the bundle with the SAME node its
# native modules were built against, and reuse the mongod Meteor already ships.
local NODE_BIN DEV_BUNDLE MONGOD_BIN
NODE_BIN="$(meteor node -e 'process.stdout.write(process.execPath)' 2>/dev/null)"
if [ -n "$NODE_BIN" ] && [ -x "$NODE_BIN" ]; then
DEV_BUNDLE="$(dirname "$(dirname "$NODE_BIN")")"
MONGOD_BIN="$DEV_BUNDLE/mongodb/bin/mongod"
fi
[ -n "$NODE_BIN" ] && [ -x "$NODE_BIN" ] || NODE_BIN="$(command -v node)"
[ -n "$MONGOD_BIN" ] && [ -x "$MONGOD_BIN" ] || MONGOD_BIN="$(command -v mongod)"
if [ -z "$NODE_BIN" ]; then
echo "ERROR: could not find node (neither Meteor's dev_bundle nor system node)."; rm -rf "$STATDIR"; return 1
fi
# Install the bundle server's npm deps once (native modules compiled for NODE_BIN).
if [ ! -d "$BUNDLE_DIR/programs/server/node_modules" ]; then
echo "==> Installing $BUNDLE_DIR/programs/server npm deps (one-time, for the bundle server). Live output:"
( cd "$BUNDLE_DIR/programs/server" && meteor npm install ) 2>&1 | tee -a "$RUN_LOGDIR/wekan-test-server.log"
if [ "${PIPESTATUS[0]}" -ne 0 ]; then
echo "ERROR: npm install in $BUNDLE_DIR/programs/server failed (see $RUN_LOGDIR/wekan-test-server.log)."; rm -rf "$STATDIR"; return 1
fi
fi
# WHICH PORT the test database is on. 3001 by default - that is what Meteor's
# own dev mongo used and what the E2E helpers default to - but it is not ours
# to insist on: on this machine 3001 turned out to belong to an "Omi Server"
# speaking HTTP, and every run either died on "Topology is closed" or stopped
# at the check below. So the port is a variable, the run moves to a free one
# when the default is taken by something that is not a database, and it tells
# the tests where it went (they already read WEKAN_MONGO_URL:
# tests/playwright/helpers/db.js).
local TEST_DB_PORT="${WEKAN_TEST_DB_PORT:-3001}"
if (exec 3<>/dev/tcp/127.0.0.1/"$TEST_DB_PORT") 2>/dev/null && ! mongo_answers "$TEST_DB_PORT"; then
local taken="$TEST_DB_PORT"
local candidate
for candidate in 3011 3021 3031 3041 3051; do
if ! (exec 3<>/dev/tcp/127.0.0.1/"$candidate") 2>/dev/null; then
TEST_DB_PORT="$candidate"; break
fi
done
if [ "$TEST_DB_PORT" = "$taken" ]; then
echo "ERROR: :$taken is held by something that is not MongoDB, and none of the"
echo " fallback ports (3011 3021 3031 3041 3051) is free either."
echo " Free one of them, or set WEKAN_TEST_DB_PORT to a port that is."
rm -rf "$STATDIR"; return 1
fi
echo "==> :$taken is held by something that is not MongoDB (it did not answer a ping)."
echo " Using :$TEST_DB_PORT for the test database instead; the tests are told where it is."
fi
local TEST_MONGO_URL="mongodb://127.0.0.1:$TEST_DB_PORT/meteor"
export WEKAN_MONGO_URL="$TEST_MONGO_URL"
# MongoDB on $TEST_DB_PORT. Reuse one that is already listening (e.g. a dev
# server's mongo); otherwise start Meteor's bundled mongod with a persistent
# test dbpath.
#
# "Listening" is not the same as "working". A TCP connect only proves that
# SOMETHING accepted, and a mongod that is shutting down - the one this script
# kills a few lines earlier, on its way out - accepts for a moment longer.
# The 2026-08-14 run took this branch, started no mongod of its own, and the
# test server died on its first query:
#
# MongoTopologyClosedError: Topology is closed
# [An error occurred when creating an index for collection "users: Topology is closed]
#
# ...with no wekan-test-mongod.log to say otherwise, because none was started.
# So ASK it: a `ping` through the bundle's own driver. Only an answer counts as
# a database to reuse.
if (exec 3<>/dev/tcp/127.0.0.1/"$TEST_DB_PORT") 2>/dev/null && mongo_answers "$TEST_DB_PORT"; then
echo "==> Reusing the MongoDB already listening on :$TEST_DB_PORT (not started or stopped by this run)."
elif (exec 3<>/dev/tcp/127.0.0.1/"$TEST_DB_PORT") 2>/dev/null; then
# Only reachable when the port was taken between the check above and here.
echo "ERROR: something is listening on :$TEST_DB_PORT but does not answer a MongoDB ping."
echo " That is usually a mongod still shutting down, or another program that"
echo " took the port just now. Nothing was started, because the port is taken."
echo " Wait a few seconds and run this again, or free it:"
echo " fuser -k $TEST_DB_PORT/tcp # or: lsof -ti :$TEST_DB_PORT | xargs kill"
rm -rf "$STATDIR"; return 1
else
if [ -z "$MONGOD_BIN" ] || [ ! -x "$MONGOD_BIN" ]; then
echo "ERROR: nothing is listening on :$TEST_DB_PORT and no mongod was found (Meteor dev_bundle or PATH)."
echo " Meteor's bundled mongod is normally at <dev_bundle>/mongodb/bin/mongod."; rm -rf "$STATDIR"; return 1
fi
# In .tools/, where everything this script writes lives (CLAUDE.md;
# tests/toolsDirOnly.test.cjs). It was in the checkout's PARENT directory,
# which a sandboxed editor cannot write - the test database never
# started there.
local DBPATH="$WEKAN_DIR/.tools/mongodb-test-$TEST_DB_PORT"
mkdir -p "$DBPATH"
echo "==> Starting MongoDB (Meteor's mongod) on :$TEST_DB_PORT, dbpath $DBPATH."
{ echo "===== mongod :$TEST_DB_PORT - started $(date '+%Y-%m-%d %H:%M:%S %Z') ====="; exec "$MONGOD_BIN" --port "$TEST_DB_PORT" --dbpath "$DBPATH" --bind_ip 127.0.0.1 --nounixsocket; } > "$RUN_LOGDIR/wekan-test-mongod.log" 2>&1 &
MONGOD_PID=$!
local db_ready=0
for i in $(seq 1 60); do
if (exec 3<>/dev/tcp/127.0.0.1/"$TEST_DB_PORT") 2>/dev/null && mongo_answers "$TEST_DB_PORT"; then db_ready=1; break; fi
sleep 1
done
if [ "$db_ready" -ne 1 ]; then
echo "ERROR: MongoDB did not become ready on :$TEST_DB_PORT (see $RUN_LOGDIR/wekan-test-mongod.log)."
kill "$MONGOD_PID" >/dev/null 2>&1 || true; MONGOD_PID=""; rm -rf "$STATDIR"; return 1
fi
fi
# Start the precompiled bundle as the :3000 server. Use an ABSOLUTE WRITABLE_PATH
# (the bundle's main.js may chdir into programs/server, which would break a
# relative "..").
# Keep browser-test files below the repository: a Docker daemon reached via
# Flatpak sees this shared checkout, but its view of the repository's parent
# directory is not necessarily the sandbox's view. Both the bundle server and
# browser containers therefore use this ignored, run-local storage root.
local WRITABLE_ABS="$WEKAN_DIR/.tools/test-writable"
mkdir -p "$WRITABLE_ABS/files"
export WEKAN_FILES_PATH_HOST="$WRITABLE_ABS/files"
# ...and the same path for a LOCAL playwright run, not only the Docker one.
# Only WEKAN_FILES_PATH_HOST was exported, which is read when the browsers run
# in a container (it is mounted there at the same path). A local run fell back
# to the spec's own default of .build/bundle/files, so a spec that writes a
# stored attachment wrote it OUTSIDE the root this server reads - and then
# tested what WeKan does with a file it does not own.
export WEKAN_FILES_PATH="$WRITABLE_ABS/files"
echo "==> Starting the WeKan test server on http://localhost:3000 from $BUNDLE_DIR (precompiled — no rebuild)."
echo " Live server log follows (scrolling) until :3000 answers:"
echo " -------------------------------------------------------------------"
: >> "$RUN_LOGDIR/wekan-test-server.log"
# Follow only NEW lines so you SEE the server boot output scroll by (Mongo connect,
# WeKan startup, "App running at ...", etc.) — real, visible progress instead of a
# single frozen summary line. The tail is stopped once :3000 answers, before the
# tests (which print their own output) start.
tail -n 0 -f "$RUN_LOGDIR/wekan-test-server.log" &
local TAIL_PID=$!
# DB name MUST be "meteor": that is what Meteor's built-in mongo used under the old
# `meteor run`, and the Playwright/E2E tests seed straight into
# mongodb://127.0.0.1:3001/meteor (tests/playwright/helpers/db.js). Using any other
# db name (e.g. /wekan) makes the app read an empty database while the tests seed a
# different one, so every test that needs seeded data fails.
{ echo "===== WeKan test server [bundle node :3000 db :$TEST_DB_PORT/meteor] - started $(date '+%Y-%m-%d %H:%M:%S %Z') ====="; echo; \
MONGO_URL="$TEST_MONGO_URL" ROOT_URL="http://localhost:3000" PORT=3000 \
WRITABLE_PATH="$WRITABLE_ABS" WITH_API=true \
DEFAULT_METEOR_REACTIVITY_ORDER="changeStreams,oplog,polling" \
NODE_OPTIONS="$TEST_NODE_OPTIONS" exec "$NODE_BIN" "$BUNDLE_DIR/main.js"; } >> "$RUN_LOGDIR/wekan-test-server.log" 2>&1 &
TEST_SERVER_PID=$!
fi
SERVER_READY=0
server_wait_start=$(date +%s)
server_wait_max=300
while :; do
if curl -fsS --connect-timeout 2 --max-time 4 http://127.0.0.1:3000/sign-in >/dev/null 2>&1; then SERVER_READY=1; fi
[ "$SERVER_READY" -eq 1 ] && break
[ "$(( $(date +%s) - server_wait_start ))" -ge "$server_wait_max" ] && break
sleep 1
done
# Stop the live tail before running the tests (they print their own output).
kill "$TAIL_PID" >/dev/null 2>&1 || true
wait "$TAIL_PID" 2>/dev/null || true
echo " -------------------------------------------------------------------"
if [ "$SERVER_READY" -eq 1 ]; then
echo "==> WeKan test server ready on http://localhost:3000 (${WEKAN_TEST_SERVER_MODE:-bundle} mode)."
fi
# Mocha and the import regression do not need the :3000 server; launch them
# now (after the server build is past, so they no longer compete with it),
# then the E2E and browser jobs below.
echo "==> Running registered server, node and import suites, $modeword."
if [ "${WEKAN_TEST_SERVER_MODE:-bundle}" != source ]; then launch_job mocha || return $?; fi
launch_job unit || return $?
launch_job import || return $?
if [ "$SERVER_READY" -ne 1 ]; then
echo "FAIL: server did not become ready on http://localhost:3000 (see $RUN_LOGDIR/wekan-test-server.log)"
record FAIL "Server startup"
record SKIP "Node E2E regressions"
record SKIP "Playwright Chromium"
record SKIP "Playwright Firefox"
record SKIP "Playwright WebKit"
ALLKEYS="mocha unit import"
else
record PASS "Server startup"
# Server is up: add the server-facing jobs to the running set.
launch_job e2e || return $?
launch_job chromium || return $?
launch_job firefox || return $?
launch_job webkit || return $?
ALLKEYS="mocha unit import e2e chromium firefox webkit"
fi
# PARALLEL only: a live combined table, one refreshing line per concurrently
# running job, until all end. In SEQUENTIAL mode there is nothing to show here —
# each job already ran one-at-a-time and streamed its own output live, so we skip
# straight to the summary (keeping sequential simple: one thing at a time).
if [ "$RUN_MODE" = parallel ]; then
BN="$(set -- $ALLKEYS; echo $#)"
echo "Results per job — [status] name (server node/db ports) tests:passed fail:failed; jobs run $modeword:"
for k in $ALLKEYS; do echo; done
while :; do
printf '\033[%dA' "$BN"
alldone=1
for k in $ALLKEYS; do
log="$RUN_LOGDIR/wekan-alltests-$k.log"
ok=$(count_pass "$k" "$log")
bad=$(count_fail "$k" "$log")
if [ -f "$STATDIR/$k" ]; then
rc=$(cat "$STATDIR/$k" 2>/dev/null)
if [ "${rc:-1}" = "0" ]; then st="PASS"; else st="FAIL"; fi
else
st="RUN "; alldone=0
fi
printf '\033[K [%-4s] %-22s %-22s tests:%-4s fail:%s\n' "$st" "$(label_of "$k")" "$(port_of "$k")" "$ok" "$bad"
done
[ "$alldone" -eq 1 ] && break
sleep 1
done
fi
for p in $BPIDS; do wait "$p" 2>/dev/null || true; done
# Roll the results into the summary (browsers carry pass/fail stats).
for k in $ALLKEYS; do
rc=$(cat "$STATDIR/$k" 2>/dev/null); rc=${rc:-1}
label="$(label_of "$k")"
case "$k" in
chromium|firefox|webkit)
json="$WEKAN_DIR/tests/playwright/test-results/all-tests-${k}.json"
stats="$(pw_stats_of "$json")"
if [ "$rc" = "0" ]; then record PASS "$label" "$stats"; else record FAIL "$label" "$stats"; fi
fails="$(pw_failures_of "$json" "$label")"
[ -n "$fails" ] && PW_FAILURES="${PW_FAILURES}${fails}"$'\n'
;;
*)
if [ "$rc" = "0" ]; then record PASS "$label"; else record FAIL "$label"; fi
;;
esac
done
rm -rf "$STATDIR"
# The same stopping the trap does, here on the normal path so the summary
# below is printed with the databases already down. Idempotent, so the trap
# firing afterwards on EXIT does nothing. A pre-existing/reused database is
# left alone either way: its PID was never recorded.
stop_test_databases
echo
echo "==================== TEST SUMMARY ===================="
FAILED=0
for line in "${SUMMARY[@]}"; do
status="${line%%|*}"; rest="${line#*|}"; name="${rest%%|*}"; stats="${rest#*|}"
suffix=""
[ -n "$stats" ] && suffix=" ($stats)"
printf ' %-6s %s%s\n' "$status" "$name" "$suffix"
[ "$status" = "FAIL" ] && FAILED=1
done
echo "====================================================="
echo "(per-job logs: $RUN_LOGDIR/wekan-alltests-<mocha|import|e2e|chromium|firefox|webkit>.log ; server: $RUN_LOGDIR/wekan-test-server.log)"
if [ -n "$PW_FAILURES" ]; then
echo
echo "Failing Playwright tests:"
while IFS= read -r f; do
[ -n "$f" ] && printf ' FAIL %s\n' "$f"
done <<< "$PW_FAILURES"
echo "(full output and traces in the per-browser logs; HTML report in tests/playwright/playwright-report)"
echo "====================================================="
fi
if [ "$FAILED" -eq 0 ]; then echo "RESULT: All tests passed."; else echo "RESULT: Some tests FAILED (see details above)."; fi
# The run directory and the verdict, for a caller that runs this as one stage of
# something larger (see run_everything below).
WEKAN_LAST_LOGDIR="$RUN_LOGDIR"
return "$FAILED"
}
# floating_promises_checks — the guard's CHECKS, with nothing that installs or
# edits anything.
#
# The menu option of the same name may install ripgrep and the @typescript-eslint
# packages and write the rule into .eslintrc.json: that is a person choosing to
# set the guard up, interactively. EVERYTHING runs unattended, must not call
# sudo, and must not modify the tree it is testing - a run that edits
# .eslintrc.json is no longer testing the commit it started from. So the checks
# themselves live here, are pure reads, and are what both callers run:
#
# * the no-floating-promises rule is configured in .eslintrc.json;
# * every Authentication.checkBoardAccess / checkBoardWriteAccess in
# server/models is awaited. An unawaited permission check returns a pending
# promise, and a promise is truthy, so the call site passes a check that
# never ran - which is a permission bug that looks like working code.
#
# grep, not ripgrep: this must work on a machine where rg is not installed and
# where nothing may be installed to make it work.
function floating_promises_checks(){
local rc=0 calls
echo "Checking that @typescript-eslint/no-floating-promises is configured in .eslintrc.json"
if grep -q '"@typescript-eslint/no-floating-promises"' .eslintrc.json 2>/dev/null; then
echo "OK: the rule is configured."
else
echo "FAIL: @typescript-eslint/no-floating-promises is NOT configured in .eslintrc.json."
echo " Tests -> 'Floating-promises guard' installs the packages and writes it."
rc=1
fi
echo
echo "Scanning server/models for unawaited Authentication.checkBoardAccess / checkBoardWriteAccess"
calls="$(grep -RInE 'Authentication\.(checkBoardAccess|checkBoardWriteAccess)\(' server/models 2>/dev/null \
| grep -vE 'await Authentication\.(checkBoardAccess|checkBoardWriteAccess)\(' || true)"
if [ -n "$calls" ]; then
echo "FAIL: these board auth checks are not awaited:"
printf '%s\n' "$calls"
rc=1
else
echo "OK: every board auth check in server/models is awaited."
fi
return $rc
}
# run_everything — every test WeKan and FerretDB have, one after another.
#
# Four stages, sequential on purpose: the static guard (seconds, so a broken
# permission check is reported before an hour of browsers), then the WeKan suite
# (which builds a fresh bundle and starts a server, and runs mocha, the node
# suites, the import regression, the node E2E and all three browsers), then the
# database conformance run (which builds FerretDB from source and runs one query
# catalogue against every database with an image for this CPU), then FerretDB's
# own tests (unit, vet - which includes its no-LFS guard - and integration).
# They share one log/<datetime>/ directory, so "the newest test logs" is one
# place.
#
# Everything in the Tests menu that is a TEST is in here. What is deliberately
# not: "Install Playwright browsers" (setup, and the browser stages install what
# they need), and "Count tests by category" (a report, not a check).
#
# FerretDB lives in .tools/w/FerretDB, cloned on demand by ensure_tool_repo (and by
# releases/db-conformance.sh, which needs it a stage earlier). .tools/ is ignored
# by git and by Meteor, so a companion repo cannot reach a commit or a rebuild.
function run_everything(){
local EVERYTHING_MODE="${1:-two-worker}"
acquire_everything_lock || return 1
trap 'trap - EXIT INT TERM; cleanup_everything_processes own; release_everything_lock' EXIT
trap 'trap - EXIT INT TERM; cleanup_everything_processes own; release_everything_lock; exit 130' INT TERM
local RUN_TS RUN_LOGDIR FAILED=0
# Bound Go package parallelism separately from Node so many compiler processes
# cannot drive the workstation into swap. Keep 1-4 workers and a proportional,
# at-most-4 GiB
# managed-heap limit per Go process; explicit overrides still win.
local FERRET_GO_JOBS=$(( _mem_total_mb / 4096 ))
[ "$FERRET_GO_JOBS" -lt 1 ] && FERRET_GO_JOBS=1
[ "$FERRET_GO_JOBS" -gt 4 ] && FERRET_GO_JOBS=4
local FERRET_GO_MEMORY_MB=$(( _mem_total_mb / 8 ))
[ "$FERRET_GO_MEMORY_MB" -gt 4096 ] && FERRET_GO_MEMORY_MB=4096
local FERRET_GOFLAGS="${WEKAN_FERRETDB_GOFLAGS:--p=$FERRET_GO_JOBS}"
if [ "$EVERYTHING_MODE" = sequential ]; then
FERRET_GOFLAGS="$FERRET_GOFLAGS -p=1 -parallel=1"
fi
if [ "${WEKAN_TEST_BAIL:-0}" = 1 ]; then
FERRET_GOFLAGS="$FERRET_GOFLAGS -failfast"
fi
local FERRET_GOMEMLIMIT="${WEKAN_FERRETDB_GOMEMLIMIT:-${FERRET_GO_MEMORY_MB}MiB}"
RUN_TS="$(date '+%Y-%m-%d_%H-%M-%S')"
RUN_LOGDIR="$(log_directory "test-everything-$EVERYTHING_MODE")" || return $?
mkdir -p "$RUN_LOGDIR"
RUN_LOGDIR="$(cd "$RUN_LOGDIR" && pwd)"
export WEKAN_LOGDIR="$RUN_LOGDIR"
echo "=============================================================================="
echo "EVERYTHING mode: $EVERYTHING_MODE. Logs: $RUN_LOGDIR/"
echo " 1/4 Floating-promises guard (seconds: the rule, and unawaited auth checks)"
echo " 2/4 WeKan's own tests (${WEKAN_TEST_SERVER_MODE:-bundle} server; mocha, the"
echo " node suites, import, node E2E, three browsers)"
echo " 3/4 Database conformance (builds FerretDB, every database this CPU runs)"
echo " 4/4 FerretDB's own tests (unit, vet, integration)"
echo "Database and FerretDB stages remain sequential; the WeKan phase follows the selected mode."
echo "FerretDB Go limit: $FERRET_GO_JOBS parallel package builds, $FERRET_GOMEMLIMIT managed heap per Go process."
echo " Override with WEKAN_FERRETDB_GOFLAGS / WEKAN_FERRETDB_GOMEMLIMIT."
echo "=============================================================================="
echo
local guard_rc=0 wekan_rc=0 conf_rc=0 ferret_rc=0
echo "### 1/4 Floating-promises guard ###############################################"
# Checks only - no installing, no editing of .eslintrc.json. It takes seconds,
# so it runs first: an unawaited permission check should not be reported after
# an hour of browser tests.
floating_promises_checks 2>&1 | tee "$RUN_LOGDIR/wekan-floating-promises.log"
guard_rc=${PIPESTATUS[0]}
if [ "${WEKAN_TEST_BAIL:-0}" = 1 ] && [ "$guard_rc" -ne 0 ]; then return "$guard_rc"; fi
echo
echo "### 2/4 WeKan tests ###########################################################"
run_all_tests "$EVERYTHING_MODE" || wekan_rc=$?
if [ "${WEKAN_TEST_BAIL:-0}" = 1 ] && [ "$wekan_rc" -ne 0 ]; then return "$wekan_rc"; fi
echo
echo "### 3/4 Database conformance ##################################################"
if [ -x ./releases/db-conformance.sh ]; then
GOFLAGS="$FERRET_GOFLAGS" GOMEMLIMIT="$FERRET_GOMEMLIMIT" \
./releases/db-conformance.sh || conf_rc=$?
else
echo "ERROR: releases/db-conformance.sh is missing."; conf_rc=1
fi
if [ "${WEKAN_TEST_BAIL:-0}" = 1 ] && [ "$conf_rc" -ne 0 ]; then return "$conf_rc"; fi
echo
echo "### 4/4 FerretDB tests ########################################################"
# .tools/w/FerretDB, cloned here if this is the first run on this machine -
# stage 3 usually got there first, but EVERYTHING must not depend on the
# order of its own stages.
ferret_dir="$(ensure_tool_repo FerretDB)" || ferret_dir=""
if [ -n "$ferret_dir" ] && [ -x "$ferret_dir/build.sh" ]; then
( cd "$ferret_dir" && WEKAN_LOGDIR="$RUN_LOGDIR" \
FERRETDB_TEST_BAIL="${WEKAN_TEST_BAIL:-0}" \
GOFLAGS="$FERRET_GOFLAGS" GOMEMLIMIT="$FERRET_GOMEMLIMIT" \
./build.sh test-all ) || ferret_rc=$?
else
echo "ERROR: .tools/w/FerretDB/build.sh is missing and could not be cloned."
echo " Clone it by hand: git clone git@github.com:wekan/FerretDB .tools/w/FerretDB"
ferret_rc=1
fi
echo
echo "=============================== EVERYTHING ==================================="
printf ' %-6s %s\n' "$([ $guard_rc -eq 0 ] && echo PASS || echo FAIL)" "Floating-promises guard (rule + unawaited board auth checks)"
printf ' %-6s %s\n' "$([ $wekan_rc -eq 0 ] && echo PASS || echo FAIL)" "WeKan tests (mocha, node suites, import, E2E, three browsers)"
printf ' %-6s %s\n' "$([ $conf_rc -eq 0 ] && echo PASS || echo FAIL)" "Database conformance (all databases for this CPU)"
printf ' %-6s %s\n' "$([ $ferret_rc -eq 0 ] && echo PASS || echo FAIL)" "FerretDB tests (unit, vet, integration)"
echo "=============================================================================="
echo "All logs: $RUN_LOGDIR/"
unset WEKAN_LOGDIR
[ $guard_rc -eq 0 ] && [ $wekan_rc -eq 0 ] && [ $conf_rc -eq 0 ] && [ $ferret_rc -eq 0 ] || FAILED=1
if [ "$FAILED" -eq 0 ]; then echo "RESULT: EVERYTHING passed."; else echo "RESULT: something FAILED - see the stage summaries above."; fi
release_everything_lock
return "$FAILED"
}
# Only one complete matrix may own the shared test ports and databases. The lock
# stores both PID and a platform process-start token, so a stale PID cannot terminate an
# unrelated process after PID reuse. A new run asks the old shell to clean up,
# waits for it, and force-stops it only if graceful shutdown does not finish.
EVERYTHING_LOCK_DIR="$WEKAN_DIR/.tools/run-everything.lock"
EVERYTHING_LOCK_OWNED=0
everything_process_start() {
if [ -r "/proc/$1/stat" ]; then
awk "{ print \$22 }" "/proc/$1/stat" 2>/dev/null
else
ps -p "$1" -o lstart= 2>/dev/null | cksum | awk "{ print \$1 }"
fi
}
everything_process_is_owner() {
local pid="$1" start="$2" actual
case "$pid:$start" in *[!0-9:]*|:*) return 1 ;; esac
actual="$(everything_process_start "$pid")"
[ -n "$actual" ] && [ "$actual" = "$start" ] && kill -0 "$pid" 2>/dev/null
}
everything_expand_descendants() {
local pids="$1" changed=1 pid ppid tree
tree="$(ps -eo pid=,ppid= 2>/dev/null)"
while [ "$changed" = 1 ]; do
changed=0
while read -r pid ppid; do
case " $pids " in
*" $pid "*) ;;
*" $ppid "*) pids="$pids $pid"; changed=1 ;;
esac
done <<< "$tree"
done
printf "%s\n" "$pids"
}
everything_pid_running() {
local state
state="$(ps -o stat= -p "$1" 2>/dev/null)"
[ -n "$state" ] && [ "${state#Z}" = "$state" ]
}
# A shell launcher can contain the test command in its -c argument. It is
# part of this run's ancestry, never an abandoned previous test process.
everything_ancestor_pids() {
local pid="${1:-$$}" parent pids=""
while [ "$pid" -gt 0 ] 2>/dev/null; do
case " $pids " in *" $pid "*) break ;; esac
pids="$pids $pid"
parent="$(ps -o ppid= -p "$pid" 2>/dev/null)"
parent="${parent//[[:space:]]/}"
case "$parent" in ''|*[!0-9]*) break ;; esac
pid="$parent"
done
printf "%s\n" "$pids"
}
cleanup_everything_processes() {
local scope="${1:-all}" pid pids="" survivors="" containers=""
local ancestors="$(everything_ancestor_pids)"
if [ "$scope" != own ] && command -v pgrep >/dev/null 2>&1; then
for pid in $(pgrep -f "[b]uild\.sh --run-everything" 2>/dev/null); do
case " $ancestors " in *" $pid "*) continue ;; esac
[ "$pid" = "$$" ] || pids="$pids $pid"
done
for pid in $(pgrep -f "$WEKAN_DIR/(tests|\.build/bundle|releases/db-conformance)" 2>/dev/null); do
case " $ancestors " in *" $pid "*) continue ;; esac
[ "$pid" = "$$" ] || pids="$pids $pid"
done
fi
pids="$(everything_expand_descendants "$pids $$")"
local filtered_pids=""
for pid in $pids; do
[ "$pid" = "$$" ] || ! everything_pid_running "$pid" || filtered_pids="$filtered_pids $pid"
done
pids="$filtered_pids"
if [ -n "${pids//[[:space:]]/}" ]; then
echo "==> Stopping previous WeKan test process(es):$pids"
kill -TERM $pids 2>/dev/null || true
sleep 5
for pid in $pids; do
everything_pid_running "$pid" && kill -KILL "$pid" 2>/dev/null || true
done
sleep 2
for pid in $pids; do
everything_pid_running "$pid" && survivors="$survivors $pid"
done
fi
if ! kill_meteor_on_port 3000; then survivors="$survivors ports:3000/3001"; fi
if docker_available; then
containers=$(docker_exec ps -aq --filter "label=org.wekan.test-run" 2>/dev/null)
containers="$containers $(docker_exec ps -aq --filter "name=^wekan-conformance-" 2>/dev/null)"
[ -n "$(printf "%s" "$containers" | tr -d "[:space:]")" ] && docker_exec rm -f $containers >/dev/null 2>&1 || true
containers=$(docker_exec ps -q --filter "label=org.wekan.test-run" 2>/dev/null)
containers="$containers $(docker_exec ps -q --filter "name=^wekan-conformance-" 2>/dev/null)"
fi
if [ -n "$survivors" ] || [ -n "$(printf "%s" "$containers" | tr -d "[:space:]")" ]; then
echo "ERROR: Could not stop previous WeKan tests and databases."
[ -n "$survivors" ] && echo " Test process or port survivor(s):$survivors"
[ -n "$(printf "%s" "$containers" | tr -d "[:space:]")" ] && echo " Test container(s) still running:$containers"
echo " No new EVERYTHING test run was started. Stop them manually and retry."
return 1
fi
return 0
}
release_everything_lock() {
[ "${EVERYTHING_LOCK_OWNED:-0}" = 1 ] || return 0
local pid start
if read -r pid start < "$EVERYTHING_LOCK_DIR/owner" 2>/dev/null && [ "$pid" = "$$" ]; then
rm -f "$EVERYTHING_LOCK_DIR/owner"
rmdir "$EVERYTHING_LOCK_DIR" 2>/dev/null || true
fi
EVERYTHING_LOCK_OWNED=0
}
acquire_everything_lock() {
mkdir -p "$WEKAN_DIR/.tools"
local old_pid old_start waited
while ! mkdir "$EVERYTHING_LOCK_DIR" 2>/dev/null; do
old_pid=""; old_start=""
read -r old_pid old_start < "$EVERYTHING_LOCK_DIR/owner" 2>/dev/null || true
if everything_process_is_owner "$old_pid" "$old_start"; then
echo "==> Stopping older EVERYTHING run (PID $old_pid) before starting over."
kill -TERM "$old_pid" 2>/dev/null || true
waited=0
while everything_process_is_owner "$old_pid" "$old_start" && [ "$waited" -lt 30 ]; do
sleep 1
waited=$((waited + 1))
done
if everything_process_is_owner "$old_pid" "$old_start"; then
echo "==> Older EVERYTHING run did not stop in 30 seconds; force-stopping it."
kill -KILL "$old_pid" 2>/dev/null || true
sleep 2
fi
if everything_process_is_owner "$old_pid" "$old_start"; then
echo "ERROR: Could not stop previous WeKan tests and databases (PID $old_pid)."
echo " No new EVERYTHING test run was started. Stop that process manually and retry."
return 1
fi
else
echo "==> Removing stale EVERYTHING run lock."
fi
rm -f "$EVERYTHING_LOCK_DIR/owner"
rmdir "$EVERYTHING_LOCK_DIR" 2>/dev/null || true
done
if ! cleanup_everything_processes; then
rmdir "$EVERYTHING_LOCK_DIR" 2>/dev/null || true
return 1
fi
printf "%s %s\n" "$$" "$(everything_process_start "$$")" > "$EVERYTHING_LOCK_DIR/owner"
EVERYTHING_LOCK_OWNED=1
}
# ============================================================================
# Multi-forge tooling (menu options below).
# * install_forge_tools: install gh-like CLIs (gh, glab, tea, git-bug, forge).
# * mirror_forge: run the active GitLab/Codeberg/SourceForge mirror scripts.
# Shared Node helpers synchronize missing issue and release data as well as Git.
# ============================================================================
function install_forge_tools(){
local tool module forge_install_status=0
local forge_bin="${GOBIN:-$WEKAN_TOOLS_DIR/bin}"
mkdir -p "$forge_bin" "$WEKAN_TOOLS_DIR/tmp" || return 1
export TMPDIR="$WEKAN_TOOLS_DIR/tmp"
export PATH="$forge_bin:$PATH"
echo "Installing tools for GitHub, GitLab, Codeberg and SourceForge mirrors."
echo "Native packages are preferred; missing Go CLIs are built for this host CPU."
# SourceForge uses Git/SSH, HTTP APIs and SCP/SFTP release transfers.
# Node runs the issue/release mirror helper; Go builds missing CLIs.
for tool in git ssh scp sftp curl jq rsync node go; do
if ! command -v "$tool" >/dev/null 2>&1; then
ensure_tools "$tool" || echo "Package installation failed for $tool."
fi
done
ensure_forge_go || echo "A current native Go toolchain could not be prepared."
for tool in gh glab tea git-bug forge; do
if command -v "$tool" >/dev/null 2>&1; then
echo "OK: $tool present"
continue
fi
# Brew has maintained Tea/git-bug packages; gh/glab use distro packages.
case "$tool" in
gh|glab) ensure_tools "$tool" || true ;;
tea|git-bug)
if [ "$(_et_os)" = macos ]; then ensure_tools "$tool" || true; fi ;;
esac
command -v "$tool" >/dev/null 2>&1 && continue
case "$tool" in
gh) module=github.com/cli/cli/v2/cmd/gh ;;
glab) module=gitlab.com/gitlab-org/cli/cmd/glab ;;
tea) module=gitea.dev/tea ;;
git-bug) module=github.com/git-bug/git-bug ;;
forge) module=github.com/git-pkgs/forge/cmd/forge ;;
esac
if command -v go >/dev/null 2>&1; then
GOBIN="$forge_bin" go install "$module@latest" || {
echo "$tool installation failed ($module); check the Go version and host CPU support."
}
else
echo "Cannot build $tool: Go installation failed."
fi
done
echo "Mirror tool status:"
for tool in git ssh scp sftp curl jq rsync node go gh glab tea git-bug forge; do
if command -v "$tool" >/dev/null 2>&1; then
echo " OK $tool: $(command -v "$tool")"
else
echo " MISSING $tool"
forge_install_status=1
fi
done
echo "Go CLI directory: $forge_bin (added to this build session's PATH)."
echo "Authenticate separately: gh auth login; glab auth login; tea login add."
echo "SourceForge uses your project account with SSH/SFTP and its API credentials."
return "$forge_install_status"
}
function mirror_forge(){
local scriptdir; scriptdir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
bash "$scriptdir/releases/mirror.sh"
}
# Run a docker compose subcommand against one of the docker-compose*.yml files
# (prebuilt ghcr.io/wekan/wekan image). $1 = compose file, rest = subcommand,
# e.g. `wekan_docker docker-compose.yml up -d` / `... logs -f` / `... down`.
wekan_docker() {
local f="$1"; shift
if [ ! -f "$f" ]; then echo "Compose file not found: $f"; return 1; fi
local dc; if docker compose version >/dev/null 2>&1; then dc="docker compose"; else dc="docker-compose"; fi
echo "Running: $dc -f $f $*"
$dc -f "$f" "$@"
}
# Build the wekan-app Docker image from the LOCAL source (the repo Dockerfile) and
# tag it as the image the given compose file references, so a following `up -d`
# runs your freshly built container instead of a possibly-stale prebuilt image
# pulled from the registry. Use this when you changed WeKan source and want Docker
# to run that change. $1 = compose file.
wekan_docker_build_image() {
local f="$1"
if [ ! -f "$f" ]; then echo "Compose file not found: $f"; return 1; fi
if [ ! -f Dockerfile ]; then echo "Dockerfile not found in $(pwd) - run this from the repo root."; return 1; fi
# The wekan-app image tag this compose file uses (e.g. ghcr.io/wekan/wekan:latest).
local img
img="$(grep -E '^[[:space:]]*image:[[:space:]]*[^#].*wekan/wekan' "$f" | head -1 | sed -E 's/.*image:[[:space:]]*//; s/[[:space:]]*$//')"
[ -z "$img" ] && img="ghcr.io/wekan/wekan:latest"
echo "==> Building wekan-app image from local source, tagging it as: $img"
echo " (equivalent to 'docker compose up -d --build'; the built image replaces the prebuilt one)"
docker build -t "$img" -f Dockerfile . || { echo "ERROR: Docker build failed."; return 1; }
echo "==> Build done: $img"
return 0
}
# ── inotify watch limit ──────────────────────────────────────────────────────
# Meteor's file watcher takes ONE inotify watch per DIRECTORY it watches, and the
# limit (fs.inotify.max_user_watches) is per USER, shared with every other
# watcher that user runs - VS Code and other editors are usually the biggest
# other consumer. On a stock kernel it is 8192 or 65536, and a WeKan checkout
# (node_modules, .meteor/local, plus whatever else lives under the repo) can sit
# close to that on its own. When it runs out, Meteor fails with a message that
# blames the DISK and sends people looking in the wrong place:
#
# Failed to start watcher for <repo>: [Error: inotify_add_watch on '<path>'
# failed: No space left on device]
#
# ENOSPC from inotify_add_watch means "watch limit reached", NOT a full disk.
#
# So: check the limit on every run and raise it when it is too low. Each watch
# costs ~1 KB of kernel memory ONLY while in use, so a high ceiling is not a
# reservation - 524288 is the value VS Code documents for the same problem.
# Override with WEKAN_INOTIFY_WATCHES=<n> (or 0 to skip this entirely).
#
# This never aborts the script: if it cannot raise the limit (no sudo, no TTY to
# prompt on, a read-only /etc) it prints the one command to run by hand and
# carries on. Nothing here applies on macOS/BSD, which have no inotify.
# The OTHER limit that is too low by default, and the one that has actually
# killed a run. macOS starts a shell with a soft limit of 256 open files.
# Meteor's bundled mongod warns about it at startup:
#
# "Soft rlimits for open file descriptors too low"
# currentValue: 256, recommendedMinimum: 64000
#
# and then, thirteen minutes into a Playwright run, runs out of them:
#
# Error accepting new connection ... "Too many open files"
# __directory_list_worker ... journal: directory-list: opendir
# WT_PANIC: WiredTiger library panic ... the process must exit and restart
# build.sh: line 1148: 22176 Abort trap: 6 mongod --port 3001 ...
#
# Every spec after that failed with `connect ECONNREFUSED 127.0.0.1:3001`, which
# reads like the database was never started rather than like it died - a whole
# browser's worth of red with the cause 10,000 lines earlier in another log.
#
# Unlike the inotify limit above this needs no root: the HARD limit is normally
# unlimited, so the process may raise its own soft limit, and the children it
# starts - mongod, the bundle server, the browsers - inherit it.
function ensure_open_files(){
local want="${WEKAN_OPEN_FILES:-64000}" # mongod's own recommendedMinimum
[ "$want" = "0" ] && return 0
local have; have="$(ulimit -n 2>/dev/null)"
[ "$have" = "unlimited" ] && return 0
case "$have" in ''|*[!0-9]*) return 0 ;; esac # unreadable: leave it alone
[ "$have" -ge "$want" ] && return 0
# Never ask for more than the hard limit, or the raise fails outright.
local hard; hard="$(ulimit -Hn 2>/dev/null)"
local target="$want"
case "$hard" in
unlimited|'') ;;
*[!0-9]*) ;;
*) [ "$hard" -lt "$want" ] && target="$hard" ;;
esac
# macOS also caps a process at kern.maxfilesperproc, which the hard limit does
# not always reflect, so step down rather than give up on one refusal.
local try
for try in "$target" 32000 16000 8000 4000 2048; do
[ "$try" -gt "$have" ] || continue
# -S: the SOFT limit only. Plain `ulimit -n` sets soft AND hard, and
# lowering a hard limit is irreversible for the process - a script that
# did that could not raise it again afterwards.
if ulimit -S -n "$try" 2>/dev/null; then
echo "==> Raised this run's open-file limit from $have to $(ulimit -n)"
echo " (mongod wants at least $want; below that it aborts mid-run with"
echo " 'Too many open files' and every later test reports ECONNREFUSED)."
return 0
fi
done
echo "==> Could not raise the open-file limit; it is $have and mongod wants $want."
echo " A long test run may abort with 'Too many open files'. Raise it with:"
echo " ulimit -n $want # in this shell, before running build.sh"
return 0
}
function ensure_inotify_watches(){
[ "$(uname -s)" = "Linux" ] || return 0
local want="${WEKAN_INOTIFY_WATCHES:-524288}"
[ "$want" = "0" ] && return 0
local proc=/proc/sys/fs/inotify/max_user_watches
[ -r "$proc" ] || return 0
local have; have="$(cat "$proc" 2>/dev/null)"
case "$have" in ''|*[!0-9]*) return 0 ;; esac # unreadable/odd: leave it alone
[ "$have" -ge "$want" ] && return 0
echo
echo "==> inotify watch limit is $have, which is too low for Meteor's file watcher."
echo " Raising fs.inotify.max_user_watches to $want (this is what makes"
echo " 'inotify_add_watch ... No space left on device' go away - that error"
echo " means the WATCH LIMIT is exhausted, not the disk)."
# How to run a privileged command: already root, sudo without a password, or
# sudo with a prompt when there is a terminal to prompt on.
local -a SUDO=()
if [ "$(id -u)" = "0" ]; then
SUDO=()
elif command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then
SUDO=(sudo)
elif command -v sudo >/dev/null 2>&1 && [ -t 0 ]; then
echo " (sudo will ask for your password)"
SUDO=(sudo)
else
echo " Cannot raise it automatically (no root and no usable sudo). Run:"
echo " echo 'fs.inotify.max_user_watches=$want' | sudo tee /etc/sysctl.d/60-wekan-inotify.conf"
echo " sudo sysctl --system"
return 0
fi
# Apply now, for this boot.
if ! "${SUDO[@]}" sysctl -q -w "fs.inotify.max_user_watches=$want" 2>/dev/null; then
echo " Could not apply it (sysctl refused). Run this by hand:"
echo " sudo sysctl -w fs.inotify.max_user_watches=$want"
return 0
fi
# Persist it, so the next boot does not go back to failing builds.
local conf=/etc/sysctl.d/60-wekan-inotify.conf
if ! "${SUDO[@]}" sh -c "printf '%s\n' '# Raised by WeKan build.sh: Meteor watches one inotify watch per directory.' 'fs.inotify.max_user_watches=$want' > '$conf'" 2>/dev/null; then
echo " Applied for this boot only - could not write $conf."
fi
# max_user_instances is the other half of the same limit family: each watcher
# process needs an instance, and editors + Meteor + chokidar add up. Only
# raised when it is at the low stock default, and never fatal.
local iproc=/proc/sys/fs/inotify/max_user_instances
if [ -r "$iproc" ]; then
local ihave; ihave="$(cat "$iproc" 2>/dev/null)"
case "$ihave" in ''|*[!0-9]*) ihave="" ;; esac
if [ -n "$ihave" ] && [ "$ihave" -lt 1024 ]; then
"${SUDO[@]}" sysctl -q -w fs.inotify.max_user_instances=1024 2>/dev/null \
&& "${SUDO[@]}" sh -c "printf '%s\n' 'fs.inotify.max_user_instances=1024' >> '$conf'" 2>/dev/null \
&& echo " Also raised fs.inotify.max_user_instances from $ihave to 1024."
fi
fi
echo " inotify watch limit is now $(cat "$proc" 2>/dev/null)."
echo
return 0
}
# The machine's LAN address, for a dev server other devices can open.
# macOS has no `ip` command and no `hostname -I`: ask which interface carries
# the default route and read that interface's address, then fall back to the
# usual Wi-Fi/Ethernet names. Linux asks the kernel which source address it
# would use. Prints localhost when nothing is found.
function current_ip_address(){
local iface addr
if [[ "$OSTYPE" == "darwin"* ]]; then
iface=$(route -n get default 2>/dev/null | awk '/interface:/{print $2; exit}')
for iface in $iface en0 en1 en2 en3; do
addr=$(ipconfig getifaddr "$iface" 2>/dev/null) && [ -n "$addr" ] && { echo "$addr"; return; }
done
else
addr=$(ip route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\1/p' | head -1)
[ -z "$addr" ] && addr=$(hostname -I 2>/dev/null | awk '{print $1}')
[ -n "$addr" ] && { echo "$addr"; return; }
fi
echo localhost
}
# Every IPv4 address of this machine, one "interface address" per line, for
# the CUSTOM-IP prompt. `ip` exists only on Linux; macOS and the BSDs have
# ifconfig.
function list_ip_addresses(){
if command -v ip >/dev/null 2>&1; then
ip -4 -o address show 2>/dev/null | awk '{sub(/\/.*/, "", $4); print $2, $4}'
elif command -v ifconfig >/dev/null 2>&1; then
ifconfig 2>/dev/null | awk '/^[^ \t]/{iface=$1; sub(/:$/, "", iface)} $1=="inet"{print iface, $2}'
fi
}
# ── Dev server URL ───────────────────────────────────────────────────────────
# Ask for the port Meteor LISTENS on and for ROOT_URL, so a dev server can run
# somewhere other than http://localhost:3000 without editing this script. Sets
# DEV_PORT and DEV_ROOT_URL for the caller.
#
# The two are NOT the same thing, and that is the point:
# * DEV_PORT is local - what `meteor run --port` binds on this machine.
# * DEV_ROOT_URL is the address a BROWSER uses. Meteor builds absolute URLs
# from it (e-mail links, OAuth redirects, attachment URLs), so it has to be
# the public address, not the local socket.
#
# Behind a reverse proxy those differ completely: Caddy terminates
# https://wekan.example.com on 443 and forwards to localhost:PORT, so ROOT_URL
# must be exactly https://wekan.example.com — appending the local port would
# produce https://wekan.example.com:4000, which nothing serves.
#
# So the port is appended ONLY when you browse the dev server directly:
# <empty> -> http://localhost:PORT (port appended)
# wekan -> http://wekan.localhost:PORT (port appended)
# (browsers and systemd-resolved resolve
# *.localhost to 127.0.0.1, no /etc/hosts entry)
# https://wekan.example.com -> used EXACTLY as given (no port)
# wekan.example.com -> https://wekan.example.com (no port; a
# dotted name is assumed to be proxied)
#
# Non-interactive: WEKAN_DEV_PORT / WEKAN_DEV_ROOT_URL (or WEKAN_DEV_HOST, the
# same answer as the prompt) skip the matching prompt.
function ask_dev_url(){
local port answer url
port="${WEKAN_DEV_PORT:-}"
if [ -z "$port" ]; then
read -r -p "Port for the dev server to listen on [3000]: " port
fi
port="${port:-3000}"
case "$port" in
''|*[!0-9]*) echo "Not a number: '$port' - using 3000."; port=3000 ;;
*) if [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then
echo "Port out of range: $port - using 3000."; port=3000
fi ;;
esac
answer="${WEKAN_DEV_ROOT_URL:-${WEKAN_DEV_HOST:-}}"
if [ -z "$answer" ]; then
echo "ROOT_URL: empty or a bare name = local (the port is added);"
echo " a full URL or a dotted name = public/proxied (the port is NOT added)."
read -r -p "ROOT_URL [http://localhost:$port]: " answer
fi
answer="${answer%/}" # a trailing slash would double up in built URLs
case "$answer" in
'')
url="http://localhost:$port" ;;
*://*)
# A complete URL: the user has said exactly what the browser uses.
# Never touch it - not the scheme, not the port, not a port they left
# off on purpose because a proxy serves 80/443.
url="$answer" ;;
*.*)
# A dotted host with no scheme: a public name, so assume it is proxied
# (that is what a public name is for) and do NOT append the local port.
url="https://$answer" ;;
*)
# A bare label: a subdomain of localhost, browsed directly.
url="http://$answer.localhost:$port" ;;
esac
DEV_PORT="$port"
DEV_ROOT_URL="$url"
echo "==> Meteor listens on port $port"
echo "==> ROOT_URL=$DEV_ROOT_URL"
case "$url" in
*localhost*) : ;;
*)
echo " (no port appended: a public ROOT_URL is expected to be served by a"
echo " reverse proxy - e.g. Caddy forwarding it to localhost:$port)" ;;
esac
}
# ── Keeping the checkout in step with origin ─────────────────────────────────
# Fetch + rebase the current branch onto its upstream, then repoint any CHANGELOG
# commit links the rebase made stale (same shared script release-all.sh uses), and
# show the resulting status. One menu action instead of the fetch/pull/rebase/
# hash-fix/status dance by hand.
# ── git pull / git push, doing the whole job ─────────────────────────────────
#
# These replace the old "Update git", which did a fetch+rebase, ran the CHANGELOG
# hash repair, printed `git status` and left the rest to the reader. The rest is
# where the mistakes were: a rebase rewrites local commits, so the CHANGELOG links
# written before it point at commits that no longer exist; the repair fixes the
# file but commits nothing, so the next push carries a dirty tree; and a push
# rejected as non-fast-forward leaves a repo that needs a pull the caller has to
# know to run.
#
# So: one action per direction, each ending in a state that is either correct or
# unchanged. Conflicts are the one thing a script must not paper over - a rebase
# that stops half-applied IS the wrong state - so on conflict the rebase is
# aborted, the working tree is exactly what it was, and the message says what to
# do by hand.
#
# Nothing here force-pushes, ever.
# The CHANGELOG links after history moved. The repair only repoints a link whose
# commit is reachable from NO ref in this clone (a link into an old release tag
# is fine and is left alone), so running it when nothing moved is a no-op.
# Returns 0 when the file was changed and committed, 1 when there was nothing to do.
function git_fix_changelog_links(){
local script="$WEKAN_DIR/releases/fix-changelog-hashes.sh"
[ -f "$script" ] || return 1
bash "$script" || return 1
if [ -n "$(git status --porcelain -- CHANGELOG-big.md CHANGELOG.md 2>/dev/null)" ]; then
git add CHANGELOG-big.md CHANGELOG.md
git commit -q -m "CHANGELOG: repoint commit links after history moved.
A rebase rewrote the commits these entries link to, so the links named commits
that no longer exist and would have 404ed on GitHub. Repointed by
releases/fix-changelog-hashes.sh, which only touches a link no ref in this clone
can reach.
Thanks to xet7 !"
echo "==> CHANGELOG commit links repointed and committed."
return 0
fi
return 0
}
# git pull: fast-forward or merge origin without rewriting local commit hashes.
# Preserve actual content conflicts for explicit resolution.
# Recover only Git's incomplete autostash-only setup, never an active rebase.
function git_recover_orphan_autostash(){
local directory stash
directory="$(git rev-parse --git-path rebase-merge)"
[ -d "$directory" ] || return 0
[ -f "$directory/autostash" ] || return 0
[ "$(find "$directory" -mindepth 1 -maxdepth 1 | wc -l)" -eq 1 ] || return 0
[ ! -e "$(git rev-parse --git-path index.lock)" ] || return 0
if ps -eo args | grep -E '^git (rebase|merge|stash|commit)( |$)' >/dev/null; then
echo "ERROR: another Git operation is running; recovery deferred."; return 1
fi
stash="$(cat "$directory/autostash")"
git cat-file -e "$stash^{commit}" || return 1
git stash store -m 'Recovered build.sh interrupted pull autostash' "$stash" || return 1
rm -- "$directory/autostash" && rmdir -- "$directory" || return 1
echo "==> Preserved interrupted pull autostash in git stash list."
}
function git_operation_ready(){
local state
for state in rebase-merge rebase-apply MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD index.lock; do
if [ -e "$(git rev-parse --git-path "$state")" ]; then
echo "ERROR: existing Git operation or lock: $state. Resolve it before pulling or pushing."
echo " Run git status; do not delete a lock while another Git process is running."
return 1
fi
done
[ "$(git rev-parse --abbrev-ref HEAD)" != HEAD ] || {
echo "ERROR: detached HEAD; select a branch first."; return 1;
}
}
function git_pull(){
git rev-parse --git-dir >/dev/null 2>&1 || { echo "Not a git repository."; return 1; }
local branch upstream before ahead behind
git_recover_orphan_autostash || return 1
git_operation_ready || return 1
branch="$(git rev-parse --abbrev-ref HEAD)"
upstream="origin/$branch"
echo "== git pull - branch $branch =="
# The exact commit to return to if anything goes wrong.
before="$(git rev-parse HEAD)"
if [ -n "$(git status --porcelain)" ]; then
echo "==> Working tree is not clean; the changes are stashed for the pull and"
echo " re-applied afterwards (git's own --autostash)."
fi
echo "--- git fetch origin $branch ---"
git fetch origin "$branch" || { echo "ERROR: fetch failed - network or remote. Nothing changed."; return 1; }
git rev-parse --verify --quiet "$upstream" >/dev/null || {
echo "No $upstream yet - nothing to pull. Push first to create it."; return 0; }
behind="$(git rev-list --count HEAD.."$upstream")"
ahead="$(git rev-list --count "$upstream"..HEAD)"
echo " $ahead commit(s) here that origin does not have, $behind the other way."
if [ "$behind" -eq 0 ]; then
echo "==> Already up to date with $upstream."
elif [ "$ahead" -eq 0 ]; then
# Nothing of ours to replay: a fast-forward moves the branch pointer and
# rewrites no commit, so no link can go stale.
echo "--- fast-forward (no local commits to replay) ---"
git -c merge.autoStash=true merge --ff-only "$upstream" || { echo "ERROR: fast-forward failed. Nothing changed."; return 1; }
else
echo "--- merge: integrating $upstream while preserving local commit hashes ---"
if ! git -c merge.autoStash=true merge --no-edit "$upstream"; then
echo "ERROR: merge did not finish. Your edits and conflict state are preserved."
git status --short
echo "Resolve the listed conflicts, git add the resolved files, then git merge --continue."
return 1
fi
fi
git_fix_changelog_links || return 1
echo "--- git status ---"
git status --short --branch
return 0
}
# git push: make sure what is about to be published is correct first, and turn
# the one rejection that has an obvious answer into that answer.
function git_push(){
git rev-parse --git-dir >/dev/null 2>&1 || { echo "Not a git repository."; return 1; }
local branch ahead
git_recover_orphan_autostash || return 1
git_operation_ready || return 1
branch="$(git rev-parse --abbrev-ref HEAD)"
echo "== git push - branch $branch =="
if [ -n "$(git status --porcelain)" ]; then
echo "==> NOTE: there are uncommitted changes; a push publishes commits only."
git status --short
fi
# Before publishing, not after: a stale link that reaches GitHub 404s for
# everyone who reads the release notes.
git_fix_changelog_links || return 1
git fetch origin "$branch" || { echo "ERROR: fetch failed; nothing pushed."; return 1; }
if git rev-parse --verify --quiet "origin/$branch" >/dev/null &&
[ "$(git rev-list --count HEAD.."origin/$branch")" -gt 0 ]; then
git_pull || { echo "ERROR: pull failed; nothing pushed."; return 1; }
fi
if git rev-parse --verify --quiet "origin/$branch" >/dev/null; then
ahead="$(git rev-list --count "origin/$branch"..HEAD)"
[ "$ahead" -eq 0 ] && { echo "==> Nothing to push; origin/$branch is already at this commit."; return 0; }
echo " $ahead commit(s) to push."
fi
echo "--- git push origin $branch ---"
if git push origin "$branch"; then
echo "==> Pushed."
return 0
fi
# The common rejection: origin moved while this was being written. That has
# one right answer - pull, then push again - so do it once, rather than
# printing the advice and leaving the caller to it. Once only: a second
# rejection is something else, and retrying a loop is not a fix.
echo
echo "==> Push was rejected. Pulling first, then trying once more."
git_pull || { echo "ERROR: the pull did not finish, so nothing was pushed."; return 1; }
echo "--- git push origin $branch (retry) ---"
if git push origin "$branch"; then
echo "==> Pushed."
return 0
fi
echo
echo "ERROR: still rejected. This is NOT a case to force - force-pushing a"
echo " branch somebody has pulled rewrites their history too. Read what"
echo " git said above; if origin has commits this clone should keep,"
echo " run this option's pull first and look at 'git log --oneline'."
return 1
}
echo
PS3='Please enter your choice: '
# Checked on every run: a too-low inotify limit breaks `meteor run` with a
# misleading "No space left on device", and a too-low open-file limit kills
# mongod partway through a test run with an equally misleading ECONNREFUSED.
ensure_inotify_watches
ensure_open_files
# ── Menu: pick a category, then an action (the handlers below are unchanged) ──
# choose <title> <"short|full"...>: show the short labels, set $opt to the chosen
# leaf's full label (matching a case handler below), or "" when Back is chosen.
choose() {
local shorts=() fulls=() it
for it in "$@"; do shorts+=("${it%%|*}"); fulls+=("${it#*|}"); done
echo; echo "== $1 =="; shorts=("${shorts[@]:1}"); fulls=("${fulls[@]:1}")
local c i
select c in "${shorts[@]}" "Back"; do
[ "$c" = "Back" ] && { opt=""; return; }
for i in "${!shorts[@]}"; do
[ "${shorts[$i]}" = "$c" ] && { opt="${fulls[$i]}"; return; }
done
done
}
# ── Releases submenu: every maintainer script in releases/ ───────────────────
# releases/ holds ~90 scripts and, until now, four of them were reachable from a
# menu (the tests, the conformance run, the changelog-hash repair, run-everything)
# - the rest existed only for whoever remembered the file name. They are grouped
# here, one line each, and tests/buildScriptParity.test.cjs fails when a script is
# added to releases/ without being added to BOTH build.sh and build.bat.
#
# Format: "Group|Label|what|argument prompt|platform".
# what a path under releases/, or a command when it starts with "!" - a
# dozen of these were one-line wrapper scripts (`sudo ufw enable`,
# `git tag --force stable HEAD`), which are the command itself now
# that the menu is where they are run from; nothing but these menus
# ever called them.
# prompt empty when the entry takes no argument; otherwise it is asked for
# and passed through, so a script that needs a version or a language
# still works from the menu.
# platform empty for everything both platforms can run, "linux" for the ones
# that are systemd, ufw, snap or multipass - build.bat does not offer
# those, because Windows cannot do them at all.
# key the name this entry answers to on the command line
# (`./build.sh release-all`). Empty = the file name without its
# extension, which is what a reader would guess; the inline commands
# keep the name of the wrapper file they replaced, so
# `./build.sh ufw-enable` still does what releases/ufw-enable.sh did.
#
# NOT here, on purpose: run-everything.sh, db-conformance.sh and
# fix-changelog-hashes.sh (already in the Tests and Setup menus), ensure-tools.sh
# (a helper other scripts source), ferretdb/* (they run INSIDE the built
# snap/bundle, not on a maintainer's machine) and the superseded old-*.sh and
# translations/fill_translations.py.
# Release notes summarize translations as affected languages; other changelog
# details stay in CHANGELOG-big.md. Notes include only In short, Security,
# Translations language list, thanks and the changelog link. Binary provenance remains
# a build artifact and must not be appended by any release entry point.
RELEASE_SCRIPTS=( "Release|Release All: audit, commit, push and build with Actions|releases/release-all.sh|||"
"Release|Release All Missing: audit, commit, push and complete release|releases/release-all-missing.sh|||"
"Release|Release (older local flow), for one version|releases/release.sh|WeKan version, e.g. 10.50||"
"Release|Show the version numbers this checkout would release|releases/version.sh|||"
"Release|Show the CHANGELOG of the release being prepared|releases/changelog.sh|||"
"Release|Rebuild the API docs (wekan.yml + wekan.html)|releases/rebuild-docs.sh|||"
"Release|Rebuild a release that already exists|releases/rebuild-release.sh|||"
"Release|Prepare the release directory for one version|releases/rel.sh|WeKan version, e.g. 10.50||"
"Release|Collect the built bundles for one version|releases/release-bundle.sh|WeKan version, e.g. 10.50||"
"Release|Link the newest bundle as wekan-latest|releases/release-ln.sh|WeKan version, e.g. 10.50||"
"Release|Move an old release out of the download directory|releases/release-x2.sh|WeKan version, e.g. 10.50||"
"Release|Clean up after a release|releases/release-cleanup.sh|WeKan version, e.g. 10.50||"
"Release|Publish the Helm chart in wekan/charts|releases/release-charts.sh|||"
"Release|Update wekan.fi with the new version and API docs|releases/release-website.sh|||"
"Release|Publish the npm packages xet7 maintains|releases/npm-publish.sh|||"
"Release|Check every download URL snapcraft.yaml uses|releases/test-download-urls.sh|||"
"Release|Clone all release-related repositories|releases/clone-release-repos.sh|||"
"Release|Create the GitHub Actions secrets a release needs|releases/create-github-secrets.sh|||"
"Release|Add a git tag for a release|releases/add-tag.sh|Version tag, e.g. v10.50||"
"Release|Delete a git tag, locally and on the remote|releases/delete-tag.sh|Version tag, e.g. v10.50||"
"Release|Move the 'stable' tag to HEAD|!git tag --force stable HEAD && git push --tags --force && git push --follow-tags|||stable-tag"
"Release|Release the wekan-ondra / wekan-gantt-gpl variants, part 1|releases/release-ondra-1.sh|||"
"Release|Release the wekan-ondra / wekan-gantt-gpl variants, part 2|releases/release-ondra-2.sh|||"
"Release|Report (or repair) the Helm chart index for past releases|releases/backfill-charts.sh|||"
"Release|Rebuild the Helm index.yaml from the chart packages|releases/reindex-charts.py|||"
"Snap|Build the snap from snapcraft.yaml|releases/snap-build.sh|||"
"Snap|Install the locally built .snap|releases/snap-install.sh|||"
"Snap|Push one .snap to the Snap Store|releases/snap-push-to-store.sh|Path to the .snap file||"
"Snap|Release the snap for one version|releases/release-snap.sh|WeKan version, e.g. 10.50||"
"Snap|List the newest Snap Store revisions|releases/snap-store-revisions.sh|||"
"Snap|Release one store revision to edge, beta and candidate|releases/snap-store-release-revision-to-channels.sh|Snap Store revision number||"
"Snap|Release every snap, every architecture, to all four channels|releases/snap-release-all-channels.sh|Version, or empty for the newest||snap-release-all-channels"
"Snap|Switch the installed snap to the edge channel|releases/snap-edge.sh|||"
"Snap|Switch the installed snap to the stable channel|releases/snap-stable.sh|||"
"Snap|snapcraft help topics|releases/snapcraft-help.sh|||"
"Snap|wekan.help of the installed snap|releases/wekan-snap-help.sh|||"
"Snap|Enable and start snapd|!sudo systemctl enable snapd && sudo systemctl start snapd||linux|snapd-start"
"Snap|Disable and stop snapd|!sudo systemctl disable snapd && sudo systemctl stop snapd||linux|snapd-stop"
"Snap|Switch between KVM, snapcraft, Waydroid and VirtualBox|releases/switch-kvm-snapcraft-waydroid-virtualbox.sh|||"
"Bundles|Build the arm64 bundle|releases/build-bundle-arm64.sh|WeKan version, e.g. 10.50||"
"Bundles|Build the armhf (arm/v7) bundle|releases/build-bundle-armhf.sh|WeKan version, e.g. 10.50||"
"Bundles|Build the ppc64el bundle|releases/build-bundle-ppc64el.sh|WeKan version, e.g. 10.50||"
"Bundles|Build the ppc64le bundle|releases/build-bundle-ppc64le.sh|WeKan version, e.g. 10.50||"
"Bundles|Build the s390x bundle|releases/build-bundle-s390x.sh|WeKan version, e.g. 10.50||"
"Bundles|Fetch the built bundle from the amd64 build host|releases/up.sh|WeKan version, e.g. 10.50||"
"Bundles|Fetch the built bundle from the arm64 build host|releases/up-a.sh|WeKan version, e.g. 10.50||"
"Bundles|Fetch the built bundle from the ppc64le build host|releases/up-o.sh|WeKan version, e.g. 10.50||"
"Bundles|Fetch the built bundle from the s390x build host|releases/up-s.sh|WeKan version, e.g. 10.50||"
"Bundles|Upload the Windows bundle to the download server|releases/up-w.sh|WeKan version, e.g. 10.50||"
"Docker images|Test Nextcloud 32-35 with native local services|tests/nextcloud/run.sh|Nextcloud majors (default 32 33 34 35)|linux|nextcloud-tests"
"Docker images|Build the WeKan Docker image|releases/docker-build.sh|||"
"Docker images|Create the multi-platform buildx builder|releases/docker-build-deps.sh|||"
"Docker images|Publish a variant image (wekan-gantt-gpl / wekan-ondra)|releases/docker-publish-variant.sh|Image and version, e.g. wekan-gantt-gpl 10.50||"
"Docker images|Push the locally built images to Docker Hub and Quay|releases/docker-push-gantt.sh|Docker build tag and WeKan version||"
"Docker images|Mirror the images between registries with skopeo|releases/docker-registry-sync.sh|||"
"Docker images|Start the Docker containers|!sudo systemctl enable docker containerd && sudo systemctl start docker containerd||linux|docker-start"
"Docker images|Stop the Docker containers|!sudo systemctl stop docker containerd && sudo systemctl disable docker containerd||linux|docker-stop"
"Sandstorm|Install the Sandstorm-related files|releases/install-sandstorm.sh|||"
"Sandstorm|Disable the Sandstorm files again|releases/disable-sandstorm.sh|||"
"Sandstorm|Make the .spk package|releases/sandstorm-make-spk.sh|||"
"Sandstorm|Run the Sandstorm dev server|releases/sandstorm-test-dev.sh|||"
"Sandstorm|Release the Sandstorm version|releases/release-sandstorm.sh|||"
"Translations|Pull the newest translations from Transifex and merge them|releases/translations/pull-translations.sh|||"
"Translations|How many strings each language still needs|releases/translations/fill-translations.mjs --missing|||"
"Translations|Push one language to Transifex|releases/translations/push-translation.sh|Language code, e.g. ja||"
"Translations|Push every language to Transifex|releases/translations/push-all-translations.sh|||"
"Translations|Push the English source to Transifex|releases/translations/push-english-base-translation.sh|||"
"Translations|Copy the English source into en-GB on Transifex|releases/translations/push-copy-en-gb-translation.sh|||"
"Translations|Report English strings that regressed|releases/translations/report-english-regressions.mjs|||"
"Translations|Prove a pull keeps human translations (no network)|releases/translations/verify-human-preference.mjs|||"
"Translations|Merge a finished pull by hand|releases/translations/merge-translations.mjs|||"
"Git and repo|git pull - fetch, fast-forward or merge, repoint moved CHANGELOG links|!git_pull|||git-pull"
"Git and repo|git push - verify the CHANGELOG links, push, pull-and-retry once if origin moved|!git_push|||git-push"
"Git and repo|Commit with the editor open for a multi-line message|releases/commit.sh|||"
"Git and repo|Add everything, then revert it again|!git restore --staged|Path to unstage||git-add-revert"
"Git and repo|Delete a branch, locally and on the remote|releases/delete-branch-local-and-remote.sh|Branch name||"
"Git and repo|Count lines of code per committer|releases/count-lines-of-code-per-committer.sh|||"
"Git and repo|Keep syncing this checkout in a loop|!while true; do sync; sleep 1; done||linux|syncloop"
"Git and repo|Convert the remaining Stylus to CSS|releases/stylus-to-css.sh|||"
"Git and repo|Update Node.js everywhere in the sources|releases/node-update.sh|||"
"Git and repo|Update the local Node.js version|releases/node-update-local.sh|||"
"Git and repo|Migrate a MongoDB database to FerretDB (--help first)|releases/migrate-mongodb-to-ferretdb.mjs|Arguments, e.g. --help||"
"Git and repo|Export local Git history to a new Fossil repository|releases/fossil.sh|||fossil-export"
"Git and repo|Open the local Fossil repository UI|releases/fossil-ui.sh|||fossil-ui"
"Server and VM|Enable and start the SSH server|!sudo systemctl enable \"\$(_et_ssh_unit)\" && sudo systemctl start \"\$(_et_ssh_unit)\"||linux|ssh-start"
"Server and VM|Disable and stop the SSH server|!sudo systemctl disable \"\$(_et_ssh_unit)\" && sudo systemctl stop \"\$(_et_ssh_unit)\"||linux|ssh-stop"
"Server and VM|Enable the ufw firewall|!sudo ufw enable||linux|ufw-enable"
"Server and VM|Disable the ufw firewall|!sudo ufw disable||linux|ufw-disable"
"Server and VM|Remove the Multipass VM|!multipass stop --all && multipass delete --all && multipass purge && sudo snap remove multipass||linux|multipass-remove"
"Server and VM|Show the VirtualBox VM's IP address|releases/virtualbox/ipaddress.sh|||"
"Server and VM|Let Node.js bind port 80 in the VM|releases/virtualbox/node-allow-port-80.sh|||"
"Server and VM|Start WeKan in the VirtualBox VM|releases/virtualbox/start-wekan.sh|||"
"Server and VM|Stop WeKan in the VirtualBox VM|releases/virtualbox/stop-wekan.sh|||"
)
# Pick a group, then a script. Returns 0 when something ran, 1 on Back.
releases_menu() {
local groups=() it g seen="|"
for it in "${RELEASE_SCRIPTS[@]}"; do
g="${it%%|*}"
# `seen` is |-delimited on purpose: a group name contains spaces
# ("Docker images"), so a space-joined list cannot be searched for one.
case "$seen" in *"|$g|"*) ;; *) groups+=("$g"); seen="$seen$g|" ;; esac
done
echo; echo "== Releases: pick a group =="
local group="" c
select c in "${groups[@]}" "Back"; do
[ "$c" = "Back" ] && return 1
for g in "${groups[@]}"; do [ "$g" = "$c" ] && group="$g"; done
[ -n "$group" ] && break
done
local labels=() paths=() prompts=() rest
for it in "${RELEASE_SCRIPTS[@]}"; do
[ "${it%%|*}" = "$group" ] || continue
rest="${it#*|}"
labels+=("${rest%%|*}")
rest="${rest#*|}"
paths+=("${rest%%|*}")
rest="${rest#*|}"
prompts+=("${rest%%|*}")
done
echo; echo "== $group =="
local i idx=-1
select c in "${labels[@]}" "Back"; do
[ "$c" = "Back" ] && return 1
for i in "${!labels[@]}"; do [ "${labels[$i]}" = "$c" ] && idx=$i; done
[ "$idx" -ge 0 ] && break
done
local script="${paths[$idx]}" prompt="${prompts[$idx]}" args=""
if [ -n "$prompt" ]; then
echo; read -r -p "$prompt: " args
fi
echo; echo "--- ${script#!} $args ---"
case "$script" in
# A command, not a script: what used to be a one-line wrapper file.
!*) eval "${script#!} $args" ;;
# .mjs is run by node, everything else by bash - and the entry keeps its
# own arguments (it may already carry a flag, e.g. --missing).
*.mjs|*.mjs\ *) node $script $args ;;
*) bash $script $args ;;
esac
return 0
}
# Docker submenu: pick a backend, then Start / Follow logs / Stop.
# Returns 0 when an action ran, 1 on Back (so the caller re-shows the menu).
# One entry per docker-compose*.yml in the repo, so every compose file can be
# started from here. Keep this list and build.bat's in step with the files
# themselves - tests/dockerComposeBackends.test.cjs fails when one drifts.
DOCKER_DBS=("FerretDB v1 SQLite (default)|docker-compose.yml"
"FerretDB v1 PostgreSQL|docker-compose-ferretdb-v1-postgresql.yml"
"FerretDB v1 MySQL (experimental)|docker-compose-ferretdb-v1-mysql.yml"
"FerretDB v1 MariaDB (experimental)|docker-compose-ferretdb-v1-mariadb.yml"
"FerretDB v1 SAP HANA (experimental)|docker-compose-ferretdb-v1-sap-hana.yml"
"FerretDB v2 PostgreSQL|docker-compose-ferretdb-v2-postgresql.yml"
"MongoDB 7|docker-compose-mongodb-v7.yml"
"MongoDB Multitenancy|docker-compose-multitenancy.yml")
docker_menu() {
local shorts=() files=() it
for it in "${DOCKER_DBS[@]}"; do shorts+=("${it%%|*}"); files+=("${it#*|}"); done
echo; echo "== Docker: pick a backend =="
local c i file=""
select c in "${shorts[@]}" "Back"; do
[ "$c" = "Back" ] && return 1
for i in "${!shorts[@]}"; do [ "${shorts[$i]}" = "$c" ] && file="${files[$i]}"; done
[ -n "$file" ] && break
done
echo; echo "== $c: action =="
local act
select act in "Start (up -d)" "Build from source & start (up -d --build)" "Follow logs (logs -f)" "Stop (down)" "Back"; do
case $act in
"Start (up -d)") wekan_docker "$file" up -d; return 0 ;;
"Build from source & start (up -d --build)") wekan_docker_build_image "$file" && wekan_docker "$file" up -d; return 0 ;;
"Follow logs (logs -f)") wekan_docker "$file" logs -f; return 0 ;;
"Stop (down)") wekan_docker "$file" down; return 0 ;;
"Back") return 1 ;;
esac
done
}
# ── CLI: run any of the above without the menu ───────────────────────────────
# `./build.sh <name> [arguments]` runs the same thing menu option would, so a
# release, a translation pull or a bundle build can go in a script or a cron
# entry. The name is the file name without its extension (release-all,
# pull-translations, build-bundle-s390x) or, for the entries that are a command
# rather than a script, the name of the wrapper file it replaced (ufw-enable).
release_entry_fields() {
# $1 = entry; sets ENT_GROUP ENT_LABEL ENT_WHAT ENT_PROMPT ENT_PLATFORM ENT_KEY
local rest="$1"
ENT_GROUP="${rest%%|*}"; rest="${rest#*|}"
ENT_LABEL="${rest%%|*}"; rest="${rest#*|}"
ENT_WHAT="${rest%%|*}"; rest="${rest#*|}"
ENT_PROMPT="${rest%%|*}"; rest="${rest#*|}"
ENT_PLATFORM="${rest%%|*}"; rest="${rest#*|}"
ENT_KEY="${rest%%|*}"
if [ -z "$ENT_KEY" ]; then
ENT_KEY="${ENT_WHAT%% *}" # drop any flags the entry carries
ENT_KEY="${ENT_KEY##*/}" # the file name
ENT_KEY="${ENT_KEY%.*}" # without its extension
fi
}
cli_list() {
local it
for it in "${RELEASE_SCRIPTS[@]}"; do
release_entry_fields "$it"
printf ' %-38s %s%s\n' "$ENT_KEY" "$ENT_LABEL" \
"$([ -n "$ENT_PROMPT" ] && echo " <$ENT_PROMPT>")"
done
}
cli_help() {
cat <<'USAGE'
WeKan build.sh - menu, or a command line.
./build.sh the menu
./build.sh --help this text
./build.sh --list every command name, with what it does
./build.sh <name> [arguments] run one, without the menu
./build.sh --run-everything every test WeKan and FerretDB have
Examples:
./build.sh release-all push the CHANGELOG, trigger the release
./build.sh version show the version numbers this would release
./build.sh rebuild-docs rebuild wekan.yml + wekan.html
./build.sh build-bundle-s390x 10.50 build the s390x bundle of that version
./build.sh release-snap 10.50 release the snap of that version
./build.sh pull-translations pull and merge the newest translations
./build.sh push-translation ja push one language to Transifex
./build.sh fill-translations how many strings each language still needs
./build.sh add-tag v10.50 tag the release
./build.sh docker-publish-variant wekan-gantt-gpl 10.50
./build.sh ufw-enable what releases/ufw-enable.sh used to do
Arguments after the name are passed to the script unchanged, so anything the
script accepts works here too. Names come from --list.
USAGE
}
cli_run() {
local want="$1"; shift
local it
for it in "${RELEASE_SCRIPTS[@]}"; do
release_entry_fields "$it"
[ "$ENT_KEY" = "$want" ] || continue
case "$ENT_WHAT" in
!*) eval "${ENT_WHAT#!}" "$@" ;;
*.mjs|*.mjs\ *) node $ENT_WHAT "$@" ;;
*) bash $ENT_WHAT "$@" ;;
esac
return $?
done
echo "build.sh: no command called '$want'." >&2
echo "Try: ./build.sh --list" >&2
return 2
}
# Non-interactive entry point for releases/run-everything.sh (which build.bat runs
# on Windows): the same "EVERYTHING (sequential)" the Tests menu offers, without
# the menu.
case "${1:-}" in
--run-everything) run_everything "${2:-two-worker}"; exit $? ;;
-h|--help|help) cli_help; exit 0 ;;
-l|--list|list) cli_help | head -8; echo; echo "Commands:"; cli_list; exit 0 ;;
"") ;; # no arguments: the menu below
-*) echo "build.sh: unknown option '$1'" >&2; cli_help; exit 2 ;;
*) cli_run "$@"; exit $? ;;
esac
opt=""
while [ -z "$opt" ]; do
echo; echo "==================== WeKan ===================="
select cat in "Setup" "Dev server" "Dev server nobuild" "Tests" "Docker" "Releases" "CLI commands" "Tools" "Quit"; do
case $cat in
"Setup")
choose "Setup" \
"Install dependencies|Install WeKan dependencies" \
"Build WeKan release bundle|Build WeKan release bundle" \
"Build WeKan development bundle|Build WeKan development bundle" \
"git pull|git pull: fetch, fast-forward or merge onto origin, repoint the CHANGELOG commit links the rebase moved, and preserve unresolved conflict state" \
"git push|git push: check the CHANGELOG commit links resolve before publishing them, push this branch to origin, and pull-then-retry once if origin moved meanwhile" ;;
"Dev server"|"Dev server nobuild")
choose "$cat" \
"localhost:3000|Run Meteor for dev on http://localhost:3000" \
"localhost:3000 + trace warnings|Run Meteor for dev on http://localhost:3000 with trace warnings, and warnings using old Meteor API that will not exist in Meteor 3.0" \
"localhost:3000 + bundle visualizer|Run Meteor for dev on http://localhost:3000 with bundle visualizer" \
"CURRENT-IP:3000|Run Meteor for dev on http://CURRENT-IP-ADDRESS:3000" \
"CURRENT-IP:3000 + MONGO_URL 27019|Run Meteor for dev on http://CURRENT-IP-ADDRESS:3000 with MONGO_URL=mongodb://127.0.0.1:27019/wekan" \
"CUSTOM-IP:PORT|Run Meteor for dev on http://CUSTOM-IP-ADDRESS:PORT" \
"CUSTOM PORT + SUBDOMAIN|Run Meteor for dev on a custom port and ROOT_URL host (asks)" \
"Kill all dev servers|Kill all dev servers (free ports 3000/3001/3100/3101/4000/4001/8080)" ;;
"Tests")
choose "Tests" \
"EVERYTHING two-worker|Run EVERYTHING with stages one by one and two Playwright workers per browser; database and FerretDB stages stay sequential; logs in log/<datetime>/" \
"EVERYTHING one by one|Run EVERYTHING one stage and one Playwright worker at a time for minimum RAM usage; database and FerretDB stages stay sequential; logs in log/<datetime>/" \
"EVERYTHING at once|Run EVERYTHING with the WeKan test jobs concurrently; database backends and FerretDB stages stay sequential; logs in log/<datetime>/" \
"EVERYTHING source one by one|Run EVERYTHING against Dev server nobuild on localhost:3000, one test at a time, all browsers" \
"Mocha (server-side)|Test Mocha unit + security + API-logic tests (server-side only, no browser)" \
"Import regression|Test import regression (tests/wekanCreator.import.test.js, fast, no server)" \
"Node E2E regressions|Test Node E2E regressions (tests/e2e/list-regressions.js, needs running server)" \
"Install Playwright browsers|Install Playwright browsers (Chromium, Firefox, WebKit; native and/or Docker)" \
"Playwright Chromium|Test Playwright Chromium" \
"Playwright Firefox|Test Playwright Firefox" \
"Playwright WebKit|Test Playwright Webkit" \
"Playwright ALL browsers|Test Playwright ALL browsers sequentially (Chromium + Firefox + WebKit, one at a time), server already running on :3000" \
"Floating-promises guard|Check floating promises guard (@typescript-eslint/no-floating-promises + auth await scan)" \
"Count tests by category|Count amount of tests by category" \
"Run all FerretDB tests - SEQUENTIAL|Run all FerretDB tests - SEQUENTIAL: unit, vet and the integration suite of the FerretDB subdirectory of this repo, one at a time, logs in log/<datetime>/" \
"Live tests|Run live tests against real services with your own accounts: GitLab write-back with the mirror's glab login (only scratch items it creates and removes) and the API imports, read only (releases/live-tests/README.md); a service with no login here is skipped, log in .tools/log/<datetime>/" \
"All databases (sequential)|Test all databases that have a Docker image for this CPU, SEQUENTIALLY: build newest FerretDB v1 from source, then run every FerretDB v1 query type against each database and compare that they all answer the same (results in log/<datetime>/)" \
"Database speed test|Time the common WeKan queries on every database that has a Docker image for this CPU, SEQUENTIALLY, with twice a production WeKan of dummy data; check they all answer the same and write docs/Databases/FerretDB/1/SpeedTest/Results.md" ;;
"Tools")
choose "Tools" \
"Save Meteor deps list|Save Meteor dependency chain to ../meteor-deps.txt" \
"Install forge CLI tools|Install forge CLI tools (gh, glab, tea, git-bug, forge) for GitHub/GitLab/Codeberg/Forgejo/Gitea" \
"Mirror repo to forges|Mirror GitHub to active mirrors: code, issues, PR conversations, releases and assets" ;;
"Docker") if docker_menu; then exit 0; fi ;;
"Releases") if releases_menu; then exit 0; fi ;;
"CLI commands")
cli_help; echo; echo "Commands:"; cli_list ;;
"Quit") exit 0 ;;
*) echo "invalid option" ;;
esac
break
done
done
# Both menus share their URL/port prompts and environment.
DEV_COMMAND=(meteor run)
if [ "$cat" = "Dev server nobuild" ]; then
DEV_COMMAND=(node "$WEKAN_DIR/scripts/dev-source/start.cjs")
fi
for _once in 1; do
case "$opt" in
"Run EVERYTHING against Dev server nobuild on localhost:3000, one test at a time, all browsers")
WEKAN_TEST_SERVER_MODE=source WEKAN_TEST_BAIL=1 WEKAN_PLAYWRIGHT_PROBE=0 run_everything sequential
;;
"Install WeKan dependencies")
if [[ "$OSTYPE" == "linux-gnu" ]]; then
echo "Linux";
case "$(_et_linux_family)" in
alpine)
sudo apk add --no-cache bash build-base git curl wget p7zip zip unzip file npm
;;
arch)
# Arch and Omarchy: no `pacman -Sy` partial upgrade (see _et_pacman).
_et_pacman base-devel git curl wget 7zip zip unzip file npm
;;
fedora)
sudo dnf group install -y development-tools
sudo dnf install -y gcc gcc-c++ make git curl wget 7zip zip unzip file npm
;;
rhel)
pm=dnf; command -v dnf >/dev/null 2>&1 || pm=yum
sudo "$pm" groupinstall -y "Development Tools"
sudo "$pm" install -y gcc gcc-c++ make git curl wget p7zip zip unzip file npm
;;
debian)
sudo apt-get update
sudo apt-get install -y libatomic1 build-essential gcc g++ make git curl wget p7zip-full zip unzip unp file npm
;;
*) echo "Unsupported Linux distribution; install a C/C++ toolchain, git, curl, wget, 7zip, zip, unzip, file and npm." >&2; exit 1 ;;
esac
# Node.js into .tools/n with n's N_PREFIX, as this user: not into
# /usr/local with sudo. releases/tools-env.sh puts .tools/n/bin on
# PATH, and npm's global installs go to that same prefix.
N_PREFIX="$WEKAN_TOOLS_DIR/n" npx -y n "$_wekan_node_version"
export PATH="$WEKAN_TOOLS_DIR/n/bin:$PATH"
hash -r
npm install -g "npm@${_wekan_npm_version:-latest}"
hash -r
# Meteor the way https://docs.meteor.com/about/install.html installs
# it on Linux, macOS and Windows alike: `npx meteor`, as this user and
# never with sudo. The meteor npm package is only an installer; npx
# runs its cli.js, which installs into $HOME/.meteor and adds that to
# PATH in the shell rc files - so HOME is .tools for the installer
# only, and Meteor and those rc lines land in .tools/. `sudo npm -g install meteor` ran the
# same installer as an install script instead, which npm 12 refuses
# to run (and which also refused --unsafe-perm and the documented
# --foreground-script).
#
# The global meteor package such a setup left behind is only that
# installer, never Meteor itself (~/.meteor is not touched).
sudo npm -g uninstall meteor >/dev/null 2>&1 || true
# npx caches in npm_config_cache (.tools/npm-cache).
mkdir -p "$npm_config_cache"
if [ -x "$WEKAN_TOOLS_DIR/.meteor/meteor" ]; then
echo "Meteor is already installed in $WEKAN_TOOLS_DIR/.meteor"
else
HOME="$WEKAN_TOOLS_DIR" npx -y meteor
fi
. "$WEKAN_DIR/releases/tools-env.sh"
elif [[ "$OSTYPE" == "darwin"* ]]; then
echo "macOS"
# Node comes from nvm, not from Homebrew's node@26 keg.
#
# `brew install node@26` gives whatever 26.x Homebrew currently has
# bottled - which trails nodejs.org - and, being keg-only, needs
# PATH, LDFLAGS and CPPFLAGS exported by hand. `nvm install 26`
# resolves to the NEWEST 26.x on nodejs.org every time it runs and
# puts that on PATH itself, so this does not go stale the way a
# pinned version does. npm comes with the Node it installs, so
# there is no `brew install npm` either.
# NVM_DIR is .tools/nvm (releases/tools-env.sh).
mkdir -p "$NVM_DIR"
if [ ! -s "$NVM_DIR/nvm.sh" ]; then
echo "Installing nvm into $NVM_DIR ..."
# A PINNED tag, not master: this pipes a downloaded script into
# a shell, so it must be a revision somebody has looked at.
# PROFILE=/dev/null: the installer edits no shell rc file.
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.6/install.sh | PROFILE=/dev/null bash
fi
# nvm is a shell FUNCTION, not a binary, so it has to be sourced -
# `command -v nvm` finds nothing until this line has run.
# shellcheck source=/dev/null
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
if ! command -v nvm >/dev/null 2>&1; then
echo "nvm did not install. Install it by hand from https://github.com/nvm-sh/nvm and run this again."
exit 1
fi
# `npm config set prefix` and nvm cannot both be right: the prefix
# overrides the per-version one nvm points at, global installs land
# outside the version they were installed for, and nvm refuses to
# switch versions while it is set. The old Homebrew path set it, so
# clear it before installing anything.
npm config delete prefix >/dev/null 2>&1 || true
# The newest 26.x, and the default for every new shell.
nvm install 26
nvm alias default 26
nvm use 26
npm install -g "npm@${_wekan_npm_version:-latest}"
echo "Node $(node --version), npm $(npm --version)"
# No shell rc file is edited: build.sh and the releases/ scripts put
# .tools/nvm's Node.js and .tools/.meteor on PATH themselves
# (releases/tools-env.sh).
# Meteor as https://docs.meteor.com/about/install.html installs it:
# `npx meteor`, as this user, never sudo, with HOME=.tools for the
# installer only (see the Linux branch).
if [ -x "$WEKAN_TOOLS_DIR/.meteor/meteor" ]; then
echo "Meteor is already installed in $WEKAN_TOOLS_DIR/.meteor"
else
HOME="$WEKAN_TOOLS_DIR" npx -y meteor
fi
. "$WEKAN_DIR/releases/tools-env.sh"
exit;
elif [[ "$OSTYPE" == "cygwin" ]]; then
# POSIX compatibility layer and Linux environment emulation for Windows
echo "TODO: Add Cygwin";
exit;
elif [[ "$OSTYPE" == "msys" ]]; then
# Lightweight shell and GNU utilities compiled for Windows (part of MinGW)
echo "TODO: Add msys on Windows";
exit;
elif [[ "$OSTYPE" == "win32" ]]; then
# I'm not sure this can happen.
echo "TODO: Add Windows";
exit;
elif [[ "$OSTYPE" == "freebsd"* ]]; then
echo "TODO: Add FreeBSD";
exit;
else
echo "Unknown"
echo ${OSTYPE}
exit;
fi
break
;;
"Build WeKan release bundle")
# WHAT A RELEASE PUBLISHES: `meteor build` plus the server's npm modules,
# the three prunes, the sockjs / legacy-client / source-map trim, a
# verified Node.js, FerretDB, the MongoDB Database Tools and a launcher -
# the same steps as the Release All workflow for this platform, minus the
# .zip. So `cd .build/bundle && ./start-wekan.sh` starts WeKan on its own
# bundled Node.js and FerretDB, and "does the thing a release would
# publish run at all" is answerable here rather than after a release.
#
# || exit 1, because build_wekan returns 1 on failure and a bare call
# throws that away: the menu breaks, the script falls off the end and
# exits 0. A build that printed "ERROR: the WeKan build failed" while
# reporting success to its caller is worse than one that just fails -
# anything driving this non-interactively (`printf '1\n2\n' | ./build.sh`,
# or CI) sees a green run and a missing bundle.
WEKAN_BUILD_RELEASE_BUNDLE=1 build_wekan || exit 1
break
;;
"Build WeKan development bundle")
# PLAIN `meteor build .build --directory`, which is what "Build WeKan"
# did before the release bundle existed. Kept as its own entry because
# the two answer different questions and cost different amounts: this one
# downloads nothing, embeds nothing and trims nothing, so it is the fast
# way to get a bundle to poke at - and it is what the test path builds,
# so it is also the bundle the test server runs.
#
# It is NOT what a release ships. A bundle from here still has the legacy
# client, the source maps, uWebSockets.js and no Node.js, FerretDB or
# launcher of its own, so it cannot answer whether a release would start.
build_wekan || exit 1
break
;;
"git pull: fetch, fast-forward or merge onto origin, repoint the CHANGELOG commit links the rebase moved, and preserve unresolved conflict state")
git_pull
break
;;
"git push: check the CHANGELOG commit links resolve before publishing them, push this branch to origin, and pull-then-retry once if origin moved meanwhile")
git_push
break
;;
"Run Meteor for dev on http://localhost:3000")
ensure_rspack_public_dirs
kill_meteor_on_port 3000 || break
#Not in use, could increase RAM usage: NODE_OPTIONS="--max_old_space_size=4096"
#---------------------------------------------------------------------
# Logging of terminal output to console and to .tools/log/wekan-log.log at end of this line: 2>&1 | tee "$(one_log dev-server)"
#WARN_WHEN_USING_OLD_API=true NODE_OPTIONS="--trace-warnings"
DEFAULT_METEOR_REACTIVITY_ORDER="changeStreams,oplog,polling" DDP_TRANSPORT=sockjs DEBUG="${DEBUG:-false}" WRITABLE_PATH=.. WITH_API=true ROOT_URL=http://localhost:3000 "${DEV_COMMAND[@]}" --port 3000 2>&1 | tee "$(one_log dev-server)"
#---------------------------------------------------------------------
break
;;
"Run Meteor for dev on http://localhost:3000 with trace warnings, and warnings using old Meteor API that will not exist in Meteor 3.0")
ensure_rspack_public_dirs
kill_meteor_on_port 3000 || break
#Not in use, could increase RAM usage: NODE_OPTIONS="--max_old_space_size=4096"
#---------------------------------------------------------------------
# Logging of terminal output to console and to .tools/log/wekan-log.log at end of this line: 2>&1 | tee "$(one_log dev-server)"
DEFAULT_METEOR_REACTIVITY_ORDER="changeStreams,oplog,polling" DDP_TRANSPORT=sockjs DEBUG="${DEBUG:-false}" WARN_WHEN_USING_OLD_API=true NODE_OPTIONS="--trace-warnings --max-old-space-size=$_heap_mb" WRITABLE_PATH=.. WITH_API=true ROOT_URL=http://localhost:3000 "${DEV_COMMAND[@]}" --port 3000 2>&1 | tee "$(one_log dev-server)"
#---------------------------------------------------------------------
break
;;
"Run Meteor for dev on http://localhost:3000 with bundle visualizer")
ensure_rspack_public_dirs
kill_meteor_on_port 3000 || break
#Not in use, could increase RAM usage: NODE_OPTIONS="--max_old_space_size=4096"
#---------------------------------------------------------------------
#Logging of terminal output to console and to .tools/log/wekan-log.log at end of this line: 2>&1 | tee "$(one_log dev-server)"
#WARN_WHEN_USING_OLD_API=true NODE_OPTIONS="--trace-warnings"
DEFAULT_METEOR_REACTIVITY_ORDER="changeStreams,oplog,polling" DDP_TRANSPORT=sockjs DEBUG="${DEBUG:-false}" WRITABLE_PATH=.. WITH_API=true ROOT_URL=http://localhost:3000 "${DEV_COMMAND[@]}" --port 3000 --extra-packages bundle-visualizer --production 2>&1 | tee "$(one_log dev-server)"
#---------------------------------------------------------------------
break
;;
"Run Meteor for dev on http://CURRENT-IP-ADDRESS:3000")
ensure_rspack_public_dirs
kill_meteor_on_port 3000 || break
IPADDRESS=$(current_ip_address)
echo "Your IP address is $IPADDRESS"
#---------------------------------------------------------------------
#Not in use, could increase RAM usage: NODE_OPTIONS="--max_old_space_size=4096"
#---------------------------------------------------------------------
#Logging of terminal output to console and to .tools/log/wekan-log.log at end of this line: 2>&1 | tee "$(one_log dev-server)"
#WARN_WHEN_USING_OLD_API=true NODE_OPTIONS="--trace-warnings"
DEFAULT_METEOR_REACTIVITY_ORDER="changeStreams,oplog,polling" DDP_TRANSPORT=sockjs DEBUG="${DEBUG:-false}" WRITABLE_PATH=.. WITH_API=true ROOT_URL=http://$IPADDRESS:3000 "${DEV_COMMAND[@]}" --port 3000 2>&1 | tee "$(one_log dev-server)"
#---------------------------------------------------------------------
break
;;
"Run Meteor for dev on http://CURRENT-IP-ADDRESS:3000 with MONGO_URL=mongodb://127.0.0.1:27019/wekan")
ensure_rspack_public_dirs
kill_meteor_on_port 3000 || break
IPADDRESS=$(current_ip_address)
echo "Your IP address is $IPADDRESS"
#---------------------------------------------------------------------
#Not in use, could increase RAM usage: NODE_OPTIONS="--max_old_space_size=4096"
#---------------------------------------------------------------------
#Logging of terminal output to console and to .tools/log/wekan-log.log at end of this line: 2>&1 | tee "$(one_log dev-server)"
#WARN_WHEN_USING_OLD_API=true NODE_OPTIONS="--trace-warnings"
DEFAULT_METEOR_REACTIVITY_ORDER="changeStreams,oplog,polling" DDP_TRANSPORT=sockjs DEBUG="${DEBUG:-false}" WEKAN_SOURCE_MONGO_PORT=27019 WEKAN_SOURCE_MONGO_DATABASE=wekan MONGO_URL=mongodb://127.0.0.1:27019/wekan WRITABLE_PATH=.. WITH_API=true ROOT_URL=http://$IPADDRESS:3000 "${DEV_COMMAND[@]}" --port 3000 2>&1 | tee "$(one_log dev-server)"
#---------------------------------------------------------------------
break
;;
"Run Meteor for dev on a custom port and ROOT_URL host (asks)")
ensure_rspack_public_dirs
ask_dev_url
kill_meteor_on_port "$DEV_PORT" || break
#---------------------------------------------------------------------
# Same environment as the plain localhost:3000 option; only the port and
# ROOT_URL differ. Logging of terminal output to console and to
# .tools/log/wekan-log.log at the end of the line: 2>&1 | tee "$(one_log dev-server)"
#---------------------------------------------------------------------
DEFAULT_METEOR_REACTIVITY_ORDER="changeStreams,oplog,polling" DDP_TRANSPORT=sockjs DEBUG="${DEBUG:-false}" WRITABLE_PATH=.. WITH_API=true ROOT_URL="$DEV_ROOT_URL" "${DEV_COMMAND[@]}" --port "$DEV_PORT" 2>&1 | tee "$(one_log dev-server)"
#---------------------------------------------------------------------
break
;;
"Run Meteor for dev on http://CUSTOM-IP-ADDRESS:PORT")
ensure_rspack_public_dirs
list_ip_addresses
DEFAULT_IPADDRESS=$(current_ip_address)
echo "From above list, what is your IP address? [$DEFAULT_IPADDRESS]"
read IPADDRESS
IPADDRESS="${IPADDRESS:-$DEFAULT_IPADDRESS}"
echo "On what port you would like to run Wekan?"
read PORT
echo "ROOT_URL=http://$IPADDRESS:$PORT"
kill_meteor_on_port "$PORT" || break
#---------------------------------------------------------------------
#Not in use, could increase RAM usage: NODE_OPTIONS="--max_old_space_size=4096"
#---------------------------------------------------------------------
#Logging of terminal output to console and to .tools/log/wekan-log.log at end of this line: 2>&1 | tee "$(one_log dev-server)"
#WARN_WHEN_USING_OLD_API=true NODE_OPTIONS="--trace-warnings"
DEFAULT_METEOR_REACTIVITY_ORDER="changeStreams,oplog,polling" DDP_TRANSPORT=sockjs DEBUG="${DEBUG:-false}" WRITABLE_PATH=.. WITH_API=true ROOT_URL=http://$IPADDRESS:$PORT "${DEV_COMMAND[@]}" --port $PORT 2>&1 | tee "$(one_log dev-server)"
#---------------------------------------------------------------------
break
;;
"Kill all dev servers (free ports 3000/3001/3100/3101/4000/4001/8080)")
kill_all_dev_servers
break
;;
"Save Meteor dependency chain to ../meteor-deps.txt")
meteor list --tree > ../meteor-deps.txt
echo "Saved Meteor dependency chain to ../meteor-deps.txt"
#---------------------------------------------------------------------
break
;;
"Run EVERYTHING with stages one by one and two Playwright workers per browser; database and FerretDB stages stay sequential; logs in log/<datetime>/")
run_everything two-worker
break
;;
"Run EVERYTHING with the WeKan test jobs concurrently; database backends and FerretDB stages stay sequential; logs in log/<datetime>/")
run_everything parallel
break
;;
"Run EVERYTHING one stage and one Playwright worker at a time for minimum RAM usage; database and FerretDB stages stay sequential; logs in log/<datetime>/")
run_everything sequential
break
;;
"Test Mocha unit + security + API-logic tests (server-side only, no browser)")
LOG="$(one_log mocha)"
echo "Running Mocha tests: meteor test --full-app --once --driver-package meteortesting:mocha --port 3100"
echo "(server-side unit/security/API-logic tests; browser/client tests are covered by Playwright options)"
echo "Log: $LOG"
# --full-app so the integration tests that need the app's hooks run too.
EMAIL_RECEIPT_SWEEP_INTERVAL_MS=1000 WRITABLE_PATH="${WRITABLE_PATH:-..}" METEOR_LOCAL_DIR=.meteor/local-test meteor test --full-app --once --driver-package meteortesting:mocha --port 3100 2>&1 | tee "$LOG"
break
;;
"Test import regression (tests/wekanCreator.import.test.js, fast, no server)")
LOG="$(one_log import)"
echo "Running import regression test (node, no server needed). Log: $LOG"
node tests/wekanCreator.import.test.js 2>&1 | tee "$LOG"
break
;;
"Test Node E2E regressions (tests/e2e/list-regressions.js, needs running server)")
LOG="$(one_log e2e)"
echo "Running Node E2E regressions (puppeteer). Log: $LOG"
echo "NOTE: needs a WeKan server with WITH_API=true on http://localhost:3000."
echo " Start one yourself first, or use one of the whole-suite options,"
echo " which start the server for you."
meteor npm run test:e2e 2>&1 | tee "$LOG"
break
;;
"Install Playwright browsers (Chromium, Firefox, WebKit; native and/or Docker)")
install_playwright_browsers
;;
"Test Playwright Chromium")
run_playwright_single chromium
break
;;
"Test Playwright Firefox")
run_playwright_single firefox
break
;;
"Test Playwright Webkit")
run_playwright_single webkit
break
;;
"Test Playwright ALL browsers sequentially (Chromium + Firefox + WebKit, one at a time), server already running on :3000")
run_playwright_parallel
break
;;
"Check floating promises guard (@typescript-eslint/no-floating-promises + auth await scan)")
LOG="$(one_log floating-promises)"
echo "Log: $LOG"
{
if ! command -v rg >/dev/null 2>&1; then
echo "ripgrep (rg) not found. Installing dependency."
ensure_tools ripgrep || echo "WARNING: Could not auto-install ripgrep. Falling back to grep."
fi
MISSING_TS_ESLINT=0
meteor npm ls --depth=0 @typescript-eslint/eslint-plugin >/dev/null 2>&1 || MISSING_TS_ESLINT=1
meteor npm ls --depth=0 @typescript-eslint/parser >/dev/null 2>&1 || MISSING_TS_ESLINT=1
if [ "$MISSING_TS_ESLINT" -eq 1 ]; then
echo "Installing missing ESLint dependencies for no-floating-promises rule."
meteor npm install --save-dev @typescript-eslint/eslint-plugin @typescript-eslint/parser
fi
echo "Ensuring .eslintrc.json includes @typescript-eslint plugin and no-floating-promises rule"
node -e "const fs=require('fs');const p='.eslintrc.json';const c=JSON.parse(fs.readFileSync(p,'utf8'));c.plugins=Array.isArray(c.plugins)?c.plugins:[];if(!c.plugins.includes('@typescript-eslint'))c.plugins.push('@typescript-eslint');c.rules=c.rules||{};c.rules['@typescript-eslint/no-floating-promises']='error';fs.writeFileSync(p,JSON.stringify(c,null,2)+'\\n');"
echo "Quick note: @typescript-eslint/no-floating-promises is a type-aware rule and may require further parser/project setup for full enforcement in all files."
echo
# The checks themselves - the same function EVERYTHING runs, so the
# menu option and the whole-run stage can never drift apart. Above
# this line is the setting-up half, which only a person asking for
# this option gets: it installs packages and writes the rule into
# .eslintrc.json, and a test run must do neither.
floating_promises_checks
} 2>&1 | tee "$LOG"
break
;;
"Run live tests against real services with your own accounts: GitLab write-back with the mirror's glab login (only scratch items it creates and removes) and the API imports, read only (releases/live-tests/README.md); a service with no login here is skipped, log in .tools/log/<datetime>/")
# releases/live-tests/run.mjs: every live test there; each skips a
# service it has no login for. Human-run only - they write to the
# services (scratch items they remove), so no automatic run calls them.
node "$WEKAN_DIR/releases/live-tests/run.mjs"
break
;;
"Mirror GitHub to active mirrors: code, issues, PR conversations, releases and assets")
mirror_forge
break
;;
"Install forge CLI tools (gh, glab, tea, git-bug, forge) for GitHub/GitLab/Codeberg/Forgejo/Gitea")
install_forge_tools
break
;;
"Run all FerretDB tests - SEQUENTIAL: unit, vet and the integration suite of the FerretDB subdirectory of this repo, one at a time, logs in log/<datetime>/")
ferret_dir="$(ensure_tool_repo FerretDB)" || ferret_dir=""
if [ -n "$ferret_dir" ] && [ -x "$ferret_dir/build.sh" ]; then
( cd "$ferret_dir" && ./build.sh test-all )
else
echo "ERROR: .tools/w/FerretDB/build.sh is missing and could not be cloned."
fi
;;
"Run EVERYTHING sequentially: the floating-promises guard (seconds), then WeKan's own tests (mocha, the node suites, import regression, node E2E and all three browsers), then the database conformance run for every database with a Docker image for this CPU, then all of FerretDB's own tests (unit, vet, integration) - one stage at a time, all logs in log/<datetime>/")
run_everything
;;
"Test all databases that have a Docker image for this CPU, SEQUENTIALLY: build newest FerretDB v1 from source, then run every FerretDB v1 query type against each database and compare that they all answer the same (results in log/<datetime>/)")
# Everything this needs - the FerretDB source, the Go toolchain, the module
# dependencies - is fetched or built by the script itself; see the note at its
# top for why it is sequential and why SAP HANA is opt-in.
./releases/db-conformance.sh
;;
"Time the common WeKan queries on every database that has a Docker image for this CPU, SEQUENTIALLY, with twice a production WeKan of dummy data; check they all answer the same and write docs/Databases/FerretDB/1/SpeedTest/Results.md")
# docs/Databases/FerretDB/1/SpeedTest/README.md: db-conformance.sh's databases,
# the speed test's queries.
./docs/Databases/FerretDB/1/SpeedTest/speedtest.sh
;;
"Count amount of tests by category")
LOG="$(one_log test-counts)"
echo "Log: $LOG"
{
SPECDIR="tests/playwright/specs"
# --- Category 1: Mocha (server + client, meteortesting:mocha) ---
# Count it( calls across the testModule trees, never describe().
mocha_count=0
for mf in client/lib/tests/*.tests.js server/lib/tests/*.tests.js imports/i18n/i18n.test.js; do
[ -e "$mf" ] || continue
c=$(grep -cE '(^|[^A-Za-z.])it[[:space:]]*\(' "$mf")
mocha_count=$((mocha_count + c))
done
# --- Category 2: Import regression (node tests/wekanCreator.import.test.js) ---
import_count=0
if [ -e tests/wekanCreator.import.test.js ]; then
import_count=$(grep -cE '^function test' tests/wekanCreator.import.test.js)
fi
# --- Category 3: Node E2E regressions (tests/e2e/list-regressions.js) ---
nodee2e_count=0
if [ -e tests/e2e/list-regressions.js ]; then
nodee2e_count=$(grep -cE "logStep\('Testing" tests/e2e/list-regressions.js)
fi
# --- Category 4: Playwright e2e specs (tests/playwright/specs/*.e2e.js) ---
pw_count=0
if [ -d "$SPECDIR" ]; then
for f in "$SPECDIR"/*.e2e.js; do
[ -e "$f" ] || continue
c=$(grep -cE '(^|[^a-zA-Z.])test(\.(only|skip|fixme))?[[:space:]]*\(' "$f")
pw_count=$((pw_count + c))
done
fi
grand_total=$((mocha_count + import_count + nodee2e_count + pw_count))
# --- Summary table by category ---
echo "| Category | Tests |"
echo "|----------|-------|"
echo "| Mocha (server + client, meteortesting:mocha) | $mocha_count |"
echo "| Import regression (tests/wekanCreator.import.test.js) | $import_count |"
echo "| Node E2E regressions (tests/e2e/list-regressions.js) | $nodee2e_count |"
echo "| Playwright e2e specs (tests/playwright/specs/*.e2e.js) | $pw_count |"
echo "| **Total** | **$grand_total** |"
echo
# --- Detailed Playwright per-spec table ---
if [ ! -d "$SPECDIR" ]; then
echo "Spec directory not found: $SPECDIR"
break
fi
echo "| Spec | Area | Tests |"
echo "|------|------|-------|"
total=0
for f in "$SPECDIR"/*.e2e.js; do
[ -e "$f" ] || continue
base=$(basename "$f")
# Spec number: leading digits of the filename
spec=$(printf '%s' "$base" | sed -E 's/^([0-9]+).*/\1/')
# Area: strip leading number and separator, strip .e2e.js,
# turn - and _ into spaces, capitalize the first letter.
area=$(printf '%s' "$base" \
| sed -E 's/^[0-9]+[-_]?//; s/\.e2e\.js$//; s/[-_]+/ /g' \
| awk '{ if (length($0) > 0) { $0 = toupper(substr($0,1,1)) substr($0,2) } print }')
# Tests: count test( / test.only( / test.skip( / test.fixme(
# calls, but never test.describe(
count=$(grep -cE '(^|[^a-zA-Z.])test(\.(only|skip|fixme))?[[:space:]]*\(' "$f")
echo "| $spec | $area | $count |"
total=$((total + count))
done
echo
echo "**Total: $total tests**"
} 2>&1 | tee "$LOG"
break
;;
# No arm matched. $opt is the menu entry's FULL DESCRIPTION, written twice -
# once in the choose() list far above, once as the arm here - so an edit to
# the menu text that misses the arm leaves an option that matches nothing.
# Without this, the case simply falls through, the `for _once` loop ends and
# the script EXITS: the option looks like it worked, printed nothing and ran
# nothing. That is what renaming "EVERYTHING (sequential)" did to it.
# tests/buildScriptParity.test.cjs fails on the mismatch itself; this is what
# the person in front of the menu sees if one ever gets past it.
*)
echo "build.sh: no handler for this menu option - that is a bug in build.sh." >&2
echo " The menu entry's description and its case arm must be the SAME string:" >&2
echo " $opt" >&2
echo " Nothing was run. Please report it, or run the same thing from the command line:" >&2
echo " ./build.sh --list" >&2
;;
esac
done