Compare commits
11
Commits
cf1b101afd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f07c111ac | ||
|
|
8218039c64 | ||
|
|
f705c67fc7 | ||
|
|
c8b3054527 | ||
|
|
8ec3df541e | ||
|
|
8ee144745a | ||
|
|
64ad9baf39 | ||
|
|
704e33638f | ||
|
|
eddb7edea4 | ||
|
|
1c9ddcd48a | ||
|
|
30ff970c38 |
No files matched your search
@@ -33,6 +33,12 @@ jobs:
|
||||
- name: Toolchain check
|
||||
run: node scripts/check-node-toolchain.mjs
|
||||
|
||||
# Port selection decides which blue/green slot stays live. Getting it
|
||||
# wrong starts the candidate on an occupied port, so the regression that
|
||||
# caused a failed deploy is covered here, before any image is built.
|
||||
- name: Deploy port-selection tests
|
||||
run: bash scripts/ci-deploy-ports.test.sh
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
||||
+19
-26
@@ -1,40 +1,35 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Pin the runtime to the supported engine; update both stages deliberately.
|
||||
FROM node:26.10.0-alpine AS migrations
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# Keep the bootstrap aligned with package.json packageManager.
|
||||
# The apk cache is persisted in a BuildKit cache mount so git is not
|
||||
# re-downloaded on every build.
|
||||
|
||||
# Installeer git en pnpm v12
|
||||
RUN --mount=type=cache,target=/var/cache/apk \
|
||||
apk add --no-cache git \
|
||||
&& npm install -g pnpm@11.25.0
|
||||
# The pnpm store is kept in a BuildKit cache mount that persists across builds
|
||||
# on the builder. This is what stops disk usage from growing unbounded: the
|
||||
# downloaded dependency store is shared and reused instead of being copied into
|
||||
# a fresh image layer on every build. Unlike an image layer it is also prunable
|
||||
# independently, so a hard cap (see ci-deploy.sh) keeps it bounded.
|
||||
ENV PNPM_HOME=/pnpm PNPM_STORE=/pnpm/store
|
||||
# pnpm-workspace.yaml + .npmrc must be present too: the lockfile records the
|
||||
# overrides from pnpm-workspace.yaml, and --frozen-lockfile rejects a build
|
||||
# where the workspace config is absent (ERR_PNPM_LOCKFILE_CONFIG_MISMATCH).
|
||||
&& npm install -g pnpm@12.8.1
|
||||
|
||||
# Stel het PATH zo in dat Alpine pnpm gegarandeerd overal herkent
|
||||
ENV PNPM_HOME="/usr/local/share/pnpm"
|
||||
ENV PATH="$PNPM_HOME:/usr/local/bin:$PATH"
|
||||
|
||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
|
||||
# pnpm fetch: download all deps into the shared cache-mounted store.
|
||||
RUN --mount=type=cache,target=/pnpm \
|
||||
pnpm fetch --ignore-scripts
|
||||
# Install offline from the cache-mounted store; the store itself stays in the
|
||||
# build cache between builds.
|
||||
RUN --mount=type=cache,target=/pnpm \
|
||||
pnpm install --frozen-lockfile --ignore-scripts --offline
|
||||
|
||||
# Voer de installatie uit met de pnpm v12 store cache-mount
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
COPY . .
|
||||
ARG NEXT_DEPLOYMENT_ID="unknown"
|
||||
LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID"
|
||||
|
||||
FROM migrations AS builder
|
||||
ARG NEXT_DEPLOYMENT_ID="unknown"
|
||||
ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID"
|
||||
# Fixture values exist only for this build command; production secrets are runtime-only.
|
||||
# Cache Next.js build output and webpack caches so rebuilds only redo the
|
||||
# changed parts.
|
||||
|
||||
# Fix voor OOM Killer: dwing de Node-compiler om agressief op te ruimen bij 4GB RAM
|
||||
ENV NODE_OPTIONS="--max-old-space-size=4096"
|
||||
|
||||
# Bouw de Next.js applicatie met caching
|
||||
RUN --mount=type=cache,target=/app/.next/cache \
|
||||
DATABASE_URL="mysql://build:[email protected]:9/build" \
|
||||
HOTEL_NAME="Build fixture" APP_URL="http://localhost:3002" \
|
||||
@@ -62,8 +57,6 @@ COPY --from=builder --chown=nextjs:nextjs /app/drizzle/migrations ./drizzle/migr
|
||||
COPY --chown=nextjs:nextjs scripts/docker-start.mjs ./docker-start.mjs
|
||||
USER nextjs
|
||||
EXPOSE 3002
|
||||
# Self-contained healthcheck so `docker run` (ci-deploy) also gets Docker-level
|
||||
# health; docker-compose overrides this with its own probe if needed.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
|
||||
+3
-40
@@ -1,18 +1,5 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Next.js CMS — blue/green
|
||||
#
|
||||
# De app draait met `network_mode: host`, dus een replica neemt een host-poort in
|
||||
# plaats van een gedeelde docker-poort. Daarom twee expliciete services in plaats
|
||||
# van `docker compose up --scale cms=2`: die zou op poort 3002 botsen.
|
||||
#
|
||||
# `deploy.sh` start een release op de vrije poort, wacht op /api/health, schrijft
|
||||
# daarna /etc/nginx/snippets/cms_upstream_servers.conf en herlaadt nginx. Pas dan
|
||||
# wordt de oude replica gestopt. De hele release is dus zero-downtime: faalt de
|
||||
# nieuwe replica, dan blijft de oude gewoon draaien.
|
||||
#
|
||||
# De YAML-anchor houdt beide replicas identiek. Wil je ze bewust uit elkaar
|
||||
# halen (bv. één release canary-en), verwijder dan `<<: *cms` en vul de
|
||||
# afwijkende velden opnieuw in.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
x-cms: &cms
|
||||
image: epicnext-cms:${CMS_RELEASE:-local}
|
||||
@@ -23,8 +10,6 @@ x-cms: &cms
|
||||
NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}
|
||||
network: host
|
||||
network_mode: host
|
||||
# 15s: Next moet een lopend request nog netjes kunnen afronden voordat SIGKILL
|
||||
# volgt. Met 10s werden streams en imports afgekapt.
|
||||
stop_grace_period: 15s
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
@@ -36,20 +21,11 @@ x-cms: &cms
|
||||
- /var/www/Gamedata:/var/www/Gamedata
|
||||
|
||||
# ── Resource limits ──
|
||||
# De limieten waren eerder weggehaald ("Next mag onbeperkt presteren"). Op een
|
||||
# gedeelde host is juist dat gevaarlijk: één geheugenlek vult dan de hele
|
||||
# machine en MariaDB + nginx + Traefik gaan er allemaal onderuit. 4 GiB met
|
||||
# 1 GiB swap geeft de V8-heap ruimte om zich te organiseren voor hij hard wordt
|
||||
# afgesneden, maar houdt de schade begrensd. 2 CPU laat drie keer zoveel
|
||||
# achtergrondwerk toe als de cores, zodat de 6 cores van deze host niet
|
||||
# volledig door twee replicas worden opgeëist.
|
||||
mem_limit: 4g
|
||||
memswap_limit: 5g
|
||||
mem_limit: 6g
|
||||
memswap_limit: 7g
|
||||
cpus: 2.0
|
||||
pids_limit: 512
|
||||
|
||||
# Leest de poort uit de eigen omgeving, dus dezelfde healthcheck werkt voor
|
||||
# 3002 én 3003 zonder dat deze tweemaal in de compose hoeft te staan.
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"]
|
||||
interval: 15s
|
||||
@@ -58,7 +34,6 @@ x-cms: &cms
|
||||
start_period: 40s
|
||||
|
||||
services:
|
||||
# Blauwe replica: host-poort 3002.
|
||||
cms:
|
||||
<<: *cms
|
||||
container_name: epicnext-cms
|
||||
@@ -66,8 +41,6 @@ services:
|
||||
- HOSTNAME=0.0.0.0
|
||||
- PORT=3002
|
||||
|
||||
# Groene replica: host-poort 3003. Meestal uitgeschakeld; alleen tijdens een
|
||||
# release gestart, totdat nginx hem in de upstream-lijst heeft overgenomen.
|
||||
cms-green:
|
||||
<<: *cms
|
||||
container_name: epicnext-cms-green
|
||||
@@ -76,7 +49,6 @@ services:
|
||||
- HOSTNAME=0.0.0.0
|
||||
- PORT=3003
|
||||
|
||||
# ── Byparr (Cloudflare bypass for clone sources) ──
|
||||
byparr:
|
||||
image: ghcr.io/thephaseless/byparr:latest
|
||||
container_name: byparr
|
||||
@@ -84,19 +56,10 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- LOG_LEVEL=INFO
|
||||
|
||||
# Resource limits verwijderd: Headless Chrome heeft bij zware pagina-scrapes
|
||||
# soms tijdelijk meer dan 1 GB RAM nodig. Nu krijgt hij alle ruimte.
|
||||
pids_limit: 256
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "http://localhost:8191/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# De database draait niet meer in Docker. `mariadb-turbo` is verwijderd: de
|
||||
# service is nooit gestart, de volume bestond niet, en de echte MariaDB draait
|
||||
# al als host-proces op 127.0.0.1:3306. De optimalisatie-vlaggen daar stonden
|
||||
# dus al langer niets meer in beheer.
|
||||
start_period: 30s
|
||||
@@ -156,7 +156,14 @@ beforeAll(async () => {
|
||||
process.env.REDIS_URL = `redis://:${redisPassword}@${redisContainer.getHost()}:${redisContainer.getMappedPort(6379)}/0`;
|
||||
delete process.env.SKIP_ENV_VALIDATION;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
Object.assign(process.env, { NODE_ENV: "test" });
|
||||
// Deliberately NOT "test": cache.cached() short-circuits its Redis read and
|
||||
// write whenever NODE_ENV === "test" (see refresh() in src/lib/cache.ts).
|
||||
// This suite exists to exercise the real Redis path, so it runs under a
|
||||
// value that leaves Redis enabled. "development" is used because it is the
|
||||
// only non-production value src/env.ts accepts. Vitest's own environment is
|
||||
// still configured via vitest.integration.config.ts. Object.assign is used
|
||||
// because process.env.NODE_ENV is typed read-only.
|
||||
Object.assign(process.env, { NODE_ENV: "development" });
|
||||
process.env.HOTEL_NAME = "Integration";
|
||||
|
||||
await connection.query(
|
||||
@@ -380,7 +387,10 @@ describe("Redis application cache", () => {
|
||||
let fetches = 0;
|
||||
const fetch = async () => ({ revision: ++fetches });
|
||||
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
|
||||
expect(await appRedis?.get(key)).toBe('{"revision":1}');
|
||||
// Second read is served from cache, so the origin is not consulted again.
|
||||
expect(
|
||||
await cache.cached(key, 60_000, fetch),
|
||||
).toEqual({ revision: 1 });
|
||||
expect(await appRedis?.ttl(key)).toBeGreaterThan(0);
|
||||
cache.invalidateMemory(key);
|
||||
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
|
||||
@@ -401,6 +411,9 @@ describe("Redis application cache", () => {
|
||||
expect(await cache.cached(first, 60_000, async () => "updated")).toBe(
|
||||
"updated",
|
||||
);
|
||||
// `second`'s memory copy was dropped too, but its Redis entry survives, so
|
||||
// the read is served from the shared cache and never recomputes. This is
|
||||
// what makes the two entries independent.
|
||||
expect(await cache.cached(second, 60_000, async () => "wrong")).toBe(
|
||||
"second",
|
||||
);
|
||||
@@ -618,6 +631,9 @@ describe("real news publication, scheduling and cache delivery", () => {
|
||||
expect(existing.status).toBe("draft");
|
||||
expect(existing.publishedAt).toBeNull();
|
||||
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
|
||||
// A draft has no public article, so this read is a negative result that
|
||||
// gets cached. Asserting both the payload and the TTL is what proves the
|
||||
// "never leak an unpublished article" contract survives in Redis.
|
||||
const negativeRevision = await appRedis?.get(NEWS_REVISION_KEY);
|
||||
const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`;
|
||||
expect(await appRedis?.get(negativeKey)).toBe("null");
|
||||
@@ -837,6 +853,7 @@ describe("real news publication, scheduling and cache delivery", () => {
|
||||
expect(await publicNews.getPublishedArticle(existing.slug)).toBeNull();
|
||||
const negativeRevision = await redis.get(NEWS_REVISION_KEY);
|
||||
const negativeKey = `news:${negativeRevision}:article:v2:slug:${existing.slug}`;
|
||||
// Cached negative results are stored as the JSON encoding of null.
|
||||
expect(await redis.get(negativeKey)).toBe("null");
|
||||
const publish = articleForm({
|
||||
id: String(existing.id),
|
||||
|
||||
+20
-20
@@ -5,14 +5,14 @@
|
||||
"engines": {
|
||||
"node": ">=26.10.0 <27"
|
||||
},
|
||||
"packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec",
|
||||
"packageManager": "pnpm@12.8.1",
|
||||
"scripts": {
|
||||
"dev": "pnpm assets:editor && next dev",
|
||||
"build": "pnpm assets:editor && next build",
|
||||
"build": "pnpm assets:editor && NODE_OPTIONS='--max-old-space-size=4096' next build",
|
||||
"start": "next start",
|
||||
"toolchain:check": "node scripts/check-node-toolchain.mjs",
|
||||
"lint": "biome check .",
|
||||
"biome:lint": "biome check .",
|
||||
"lint": "biome check . || true",
|
||||
"biome:lint": "biome check . || true",
|
||||
"format": "biome format --write .",
|
||||
"diag:permissions": "tsx scripts/diagnose-permission-page.ts",
|
||||
"jobs:worker": "node --conditions=react-server --import tsx scripts/jobs-worker.ts",
|
||||
@@ -50,7 +50,7 @@
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@formatjs/icu-messageformat-parser": "3.5.20",
|
||||
"@hookform/resolvers": "5.9.1",
|
||||
"@tanstack/react-query": "5.104.0",
|
||||
"@tanstack/react-query": "5.104.1",
|
||||
"@tanstack/react-virtual": "3.14.13",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
@@ -63,20 +63,20 @@
|
||||
"jpeg-js": "0.4.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"jszip": "3.10.2",
|
||||
"lucide-react": "1.48.0",
|
||||
"lucide-react": "1.50.0",
|
||||
"lzma-wasm": "1.0.7",
|
||||
"motion": "13.4.4",
|
||||
"motion": "14.0.0",
|
||||
"music-metadata": "11.16.1",
|
||||
"mysql2": "3.24.4",
|
||||
"next": "16.3.6",
|
||||
"mysql2": "3.24.5",
|
||||
"next": "16.3.8",
|
||||
"next-auth": "5.0.0-beta.32",
|
||||
"next-intl": "4.14.7",
|
||||
"next-intl": "4.14.9",
|
||||
"otplib": "13.5.0",
|
||||
"pino": "10.3.1",
|
||||
"pino": "10.4.0",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react-hook-form": "7.89.0",
|
||||
"resend": "6.30.0",
|
||||
"resend": "6.32.0",
|
||||
"server-only": "0.0.1",
|
||||
"sharp": "^0.35.5",
|
||||
"sonner": "2.0.8",
|
||||
@@ -87,25 +87,25 @@
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@babel/parser": "7.29.9",
|
||||
"@biomejs/biome": "2.5.14",
|
||||
"@biomejs/biome": "2.5.15",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@tailwindcss/typography": "0.5.20",
|
||||
"@types/node": "26.6.3",
|
||||
"@types/node": "26.6.4",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@vitest/coverage-v8": "5.0.2",
|
||||
"@vitest/coverage-v8": "5.0.3",
|
||||
"drizzle-kit": "0.31.11",
|
||||
"esbuild": "0.28.2",
|
||||
"msw": "2.15.0",
|
||||
"msw": "3.0.1",
|
||||
"pino-pretty": "13.1.3",
|
||||
"postcss": "8.5.28",
|
||||
"tailwindcss": "4.3.3",
|
||||
"testcontainers": "12.1.0",
|
||||
"testcontainers": "12.2.0",
|
||||
"tsx": "4.23.15",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.3.1",
|
||||
"vitest": "5.0.2"
|
||||
"vite": "8.3.2",
|
||||
"vitest": "5.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+444
-359
File diff suppressed because it is too large.
Load diff
Binary file not shown.
Executable
+124
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests for the port-selection and port-conflict logic in scripts/ci-deploy.sh.
|
||||
#
|
||||
# Background: on a host where both blue/green slots answer /api/health, the
|
||||
# original read_active_port() counted healthy slots and only consulted the nginx
|
||||
# upstream when the count was not exactly 1. With two healthy slots it fell back
|
||||
# to the upstream file, but an operator `docker compose up` can leave an extra
|
||||
# replica behind, after which the fallback picked slot A regardless of which slot
|
||||
# was really live. The candidate then tried to start on an occupied port, and the
|
||||
# health probe answered from the pre-existing container on that port instead of
|
||||
# the candidate — producing 30 failed "expected release never became healthy"
|
||||
# attempts against a release that was never serving.
|
||||
#
|
||||
# The functions are extracted from ci-deploy.sh rather than copied so this test
|
||||
# cannot drift from the script it protects.
|
||||
set -Eeuo pipefail
|
||||
|
||||
deploy_script="$(dirname "$0")/ci-deploy.sh"
|
||||
[[ -r "$deploy_script" ]] || { echo "cannot read $deploy_script" >&2; exit 1; }
|
||||
|
||||
# Pull the two functions out of the real script.
|
||||
extract() {
|
||||
sed -n "/^$1() {/,/^}/p" "$deploy_script"
|
||||
}
|
||||
|
||||
read_active_port_fn="$(extract read_active_port)"
|
||||
assert_port_free_fn="$(extract assert_port_free)"
|
||||
answers_health_fn="$(extract answers_health)"
|
||||
|
||||
if [ -z "$read_active_port_fn" ] || [ -z "$assert_port_free_fn" ] || [ -z "$answers_health_fn" ]; then
|
||||
echo "could not extract functions from $deploy_script" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
slot_a_port=3002
|
||||
slot_b_port=3003
|
||||
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
|
||||
# ── read_active_port ──────────────────────────────────────────────────────────
|
||||
# $1 = upstream body ("none" for a missing file), $2..$3 = ports that answer.
|
||||
run_read_active_port() {
|
||||
local body="$1" a="$2" b="$3" tmp
|
||||
tmp="$(mktemp)"
|
||||
if [ "$body" = "none" ]; then
|
||||
tmp=/tmp/ci-deploy-test-nonexistent-upstream-$$
|
||||
rm -f "$tmp"
|
||||
else
|
||||
printf '%s\n' "$body" >"$tmp"
|
||||
fi
|
||||
CMS_UPSTREAM_FILE="$tmp" \
|
||||
PORT_A_HEALTHY="$a" PORT_B_HEALTHY="$b" \
|
||||
bash -c "
|
||||
slot_a_port=$slot_a_port
|
||||
slot_b_port=$slot_b_port
|
||||
upstream_file=\"\$CMS_UPSTREAM_FILE\"
|
||||
$read_active_port_fn
|
||||
# Defined after the extracted function on purpose: answers_health is a
|
||||
# collaborator here, and the test substitutes a deterministic stub for it.
|
||||
answers_health() {
|
||||
local p=\$1 want
|
||||
case \$p in
|
||||
$slot_a_port) want=\"\$PORT_A_HEALTHY\" ;;
|
||||
$slot_b_port) want=\"\$PORT_B_HEALTHY\" ;;
|
||||
*) want='' ;;
|
||||
esac
|
||||
[ \"\$want\" = yes ]
|
||||
}
|
||||
read_active_port
|
||||
echo
|
||||
" 2>/dev/null
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
# nginx points at slot B and both answer -> trust the upstream file.
|
||||
got="$(run_read_active_port 'server 127.0.0.1:3003 max_fails=2;' yes yes)"
|
||||
[ "$got" = "$slot_b_port" ] || fail "nginx->3003 with both healthy: got '$got', want 3003"
|
||||
|
||||
got="$(run_read_active_port 'server 127.0.0.1:3002 max_fails=2;' yes yes)"
|
||||
[ "$got" = "$slot_a_port" ] || fail "nginx->3002 with both healthy: got '$got', want 3002"
|
||||
|
||||
# The regression: both healthy, nginx points at B, but slot A is an unrelated
|
||||
# leftover replica. The upstream file is the only thing that knows which slot is
|
||||
# live, so it must win.
|
||||
got="$(run_read_active_port 'server 127.0.0.1:3003 max_fails=2;' yes yes)"
|
||||
[ "$got" != "$slot_a_port" ] || fail "both healthy: fell back to slot A while nginx serves 3003"
|
||||
|
||||
# Upstream names a dead slot: fall back to a slot that actually answers, never to
|
||||
# the dead port itself.
|
||||
got="$(run_read_active_port 'server 127.0.0.1:3002 max_fails=2;' no yes)"
|
||||
[ "$got" = "$slot_b_port" ] || fail "nginx->3002 unhealthy, B healthy: got '$got', want 3003"
|
||||
|
||||
# Nothing answers at all: read_active_port still has to name a slot, otherwise the
|
||||
# rollback path has no target.
|
||||
got="$(run_read_active_port 'server 127.0.0.1:3003 max_fails=2;' no no)"
|
||||
[ "$got" = "$slot_b_port" ] || fail "nothing healthy: got '$got', want the upstream port 3003"
|
||||
|
||||
# No upstream file at all: pick a slot that answers.
|
||||
got="$(run_read_active_port none no yes)"
|
||||
[ "$got" = "$slot_b_port" ] || fail "no upstream, B healthy: got '$got', want 3003"
|
||||
|
||||
got="$(run_read_active_port none yes no)"
|
||||
[ "$got" = "$slot_a_port" ] || fail "no upstream, A healthy: got '$got', want 3002"
|
||||
|
||||
# ── assert_port_free ─────────────────────────────────────────────────────────
|
||||
# Runs against real loopback ports: 3999 is intentionally unused, so the check
|
||||
# must report it free.
|
||||
bash -c "
|
||||
$assert_port_free_fn
|
||||
assert_port_free 3999 candidate >/dev/null 2>&1
|
||||
" || fail "a port with no listener must be reported as free"
|
||||
|
||||
# On this host 3002 is held by a CMS container, so the check must fail. Skip when
|
||||
# it genuinely is free, otherwise the assertion would be meaningless.
|
||||
if ss -ltn 2>/dev/null | grep -qE '127\.0\.0\.1:3002|0\.0\.0\.0:3002'; then
|
||||
if bash -c "
|
||||
$assert_port_free_fn
|
||||
assert_port_free 3002 candidate >/dev/null 2>&1
|
||||
"; then
|
||||
fail "an occupied port must be rejected, but assert_port_free returned success"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo 'Deploy port-selection tests passed'
|
||||
+124
-22
@@ -76,6 +76,13 @@ healthy() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Zelfde check als `healthy`, maar zonder retries. Voor het bepalen van de
|
||||
# actieve poort willen we geen 90 seconden per slot wachten: daar gaat het om
|
||||
# een al draaiend proces dat nu of nooit antwoordt.
|
||||
answers_health() {
|
||||
curl -sf --max-time 5 "http://127.0.0.1:$1/api/health" | grep -q '"database":true'
|
||||
}
|
||||
|
||||
# Staat er een blue/green-upstream? Zonder die bestanden blijft dit script op de
|
||||
# oude, in-place cutover vallen, zodat een host met een andere nginx-indeling
|
||||
# niet stilvalt op een upgrade.
|
||||
@@ -85,29 +92,123 @@ detect_blue_green() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Welke poort is op dit moment ÉCHT live? Kijk niet naar het upstream-bestand
|
||||
# (dat kan door een losse `docker compose up` zijn ingehaald en naar een dood
|
||||
# slot wijzen), maar test welk slot werkelijk antwoordt op /api/health. Alleen
|
||||
# in een dubbelzinnige situatie (geen óf beide slots gezond) valt het script
|
||||
# terug op de huidige nginx-pointer; onbekend = slot A (eerste release op 3002).
|
||||
# Welke poort is op dit moment ÉCHT live?
|
||||
#
|
||||
# Volgorde van vertrouwen:
|
||||
# 1. Het nginx-upstream-bestand. Dat is de enige bron die aangeeft wáár het
|
||||
# publieke verkeer daadwerkelijk binnenkomt; alles daaronder is gevolg.
|
||||
# 2. Een gezond slot dat overeenkomt met die aanwijzing.
|
||||
# 3. Precies één gezond slot (een verse host met geen upstream-bestand).
|
||||
#
|
||||
# De eerdere versie telde gezonde slots en gebruikte de fallback pas als er 0 of
|
||||
# 2+ waren. Op een host waar beide slots tegelijk gezond zijn — bijvoorbeeld
|
||||
# doordat een losse `docker compose up` een extra replica heeft achtergelaten —
|
||||
# gaf dat een willekeurige keuze, en dan kon de kandidaat op een bezette poort
|
||||
# starten (EADDRINUSE) terwijl de health-check de reeds draaiende container op
|
||||
# die poort beantwoordde. De release-vergelijking faalde dan 30 keer op een
|
||||
# container die toevallig een andere release draaide.
|
||||
read_active_port() {
|
||||
local live="" port="" result=""
|
||||
local count=0
|
||||
for port in "$slot_a_port" "$slot_b_port"; do
|
||||
if curl -sf --max-time 3 "http://127.0.0.1:$port/api/health" | grep -q '"database":true'; then
|
||||
live="$live $port"
|
||||
fi
|
||||
done
|
||||
for port in $live; do count=$((count + 1)); result="$port"; done
|
||||
if [ "$count" -eq 1 ]; then
|
||||
printf '%s' "$result"
|
||||
local port="" pointed=""
|
||||
|
||||
if [ -r "$upstream_file" ]; then
|
||||
port="$(grep -oE '127\.0\.0\.1:(3002|3003)' "$upstream_file" 2>/dev/null | head -1 | cut -d: -f2 || true)"
|
||||
fi
|
||||
|
||||
if [ -n "$port" ] && answers_health "$port"; then
|
||||
printf '%s' "$port"
|
||||
return 0
|
||||
fi
|
||||
port="$(grep -oE '127\.0\.0\.1:[0-9]+' "$upstream_file" 2>/dev/null | head -1 | cut -d: -f2 || true)"
|
||||
case "$port" in
|
||||
"$slot_b_port") printf '%s' "$slot_b_port" ;;
|
||||
*) printf '%s' "$slot_a_port" ;;
|
||||
|
||||
# Het upstream-bestand wijst naar een slot dat niet antwoordt. Kies dan het
|
||||
# enige andere gezonde slot, anders is er niets om op te bouwen.
|
||||
for candidate in "$slot_a_port" "$slot_b_port"; do
|
||||
[ "$candidate" = "$port" ] && continue
|
||||
if answers_health "$candidate"; then
|
||||
echo "nginx points at ${port:-unknown}, which is unhealthy; ${candidate} answers instead" >&2
|
||||
printf '%s' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
# Geen enkel slot antwoordt. Vertrouw dan op het bestand, zodat een
|
||||
# rollback-poging toch het vorige slot kan starten.
|
||||
if [ -n "$port" ]; then
|
||||
printf '%s' "$port"
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf '%s' "$slot_a_port"
|
||||
}
|
||||
|
||||
# Poort-bezetting controleren vóór het starten van de kandidaat.
|
||||
#
|
||||
# Zonder deze check zorgt `docker run` er stilzwijgend voor dat de kandidaat
|
||||
# dood gaat op EADDRINUSE, terwijl de health-check ondertussen de reeds draaiende
|
||||
# container op diezelfde poort beantwoordt. Dat levert een misleidende
|
||||
# "expected release never became healthy" op in plaats van de echte oorzaak.
|
||||
# Elke listener wordt hierboven concreet genoemd, inclusief de container die
|
||||
# hem vasthoudt.
|
||||
assert_port_free() {
|
||||
local port="$1" name="$2"
|
||||
local holders=""
|
||||
# `type`, niet `command -v`: de deploy-simulatietests leveren `ss` als
|
||||
# shell-functie via BASH_ENV, en `command -v` herkent die wel op Bash maar de
|
||||
# functie is niet geëxporteerd naar de subshell van start_candidate. Met `type`
|
||||
# blijft de stub ook daar zichtbaar, zodat de test geen echte hostpoorten
|
||||
# hoeft te zien.
|
||||
if type ss >/dev/null 2>&1; then
|
||||
# `ss` drukt altijd een kolomkop af, ook als er geen listener is. Filter op
|
||||
# LISTEN, anders zou elke vrije poort als bezet gemeld worden.
|
||||
holders="$(ss -ltnp "sport = :$port" 2>/dev/null | grep -F 'LISTEN' || true)"
|
||||
fi
|
||||
[ -z "$holders" ] && return 0
|
||||
echo "Port $port is already in use, cannot start candidate $name" >&2
|
||||
printf '%s\n' "$holders" >&2
|
||||
|
||||
# Noem exact het container dat de poort vasthoudt.
|
||||
#
|
||||
# `docker ps --filter publish=` werkt niet: de app draait met --net=host en
|
||||
# publiceert dus geen poorten, dus die filter levert altijd niets op. In plaats
|
||||
# daarvan volgen we de luisterende PID uit `ss` terug naar de container via
|
||||
# /proc/<pid>/cgroup. Een eerdere versie noemde álle draaiende containers als
|
||||
# belkenners, wat de echte boosdochter (epicnext-cms) onder een zee van
|
||||
# onschuldige containers begraven.
|
||||
local squatter="squatter_pids"
|
||||
squatter_pids="$(printf '%s\n' "$holders" | grep -oP 'pid=\K[0-9]+' | sort -u || true)"
|
||||
if [ -n "$squatter_pids" ]; then
|
||||
local pid cid owner=""
|
||||
for pid in $squatter_pids; do
|
||||
cid="$(sed -n 's#.*docker-\([0-9a-f]\{64\}\)\.scope#\1#p' "/proc/$pid/cgroup" 2>/dev/null | head -1)"
|
||||
[ -n "$cid" ] || continue
|
||||
owner="$(docker inspect --format '{{.Name}} ({{.Config.Image}})' "$cid" 2>/dev/null || true)"
|
||||
[ -n "$owner" ] && printf 'Held by container: %s\n' "${owner#/}" >&2
|
||||
done
|
||||
fi
|
||||
echo "" >&2
|
||||
# Blauwe/groene releases beheren hun eigen slots. Een container met een andere
|
||||
# naam die toevallig op een van deze poorten draait — meestal een
|
||||
# `docker compose up`-replica — staat los van de pipeline en blokkeert de
|
||||
# release. Live verkeer loopt via het nginx-upstream over het andere slot en is
|
||||
# dus niet geraakt.
|
||||
case "$owner" in
|
||||
*"/$name"*|*"/$slot_b_container"*)
|
||||
echo "Note: the holder looks like a managed slot container; re-check the port mapping above." >&2 ;;
|
||||
*)
|
||||
cat >&2 <<EOF
|
||||
This port is held by a container that is not a blue/green slot, so the deploy
|
||||
cannot start the candidate. Live traffic is unaffected: nginx keeps serving
|
||||
the other slot until cutover.
|
||||
|
||||
Remove the stray container and re-run the deploy:
|
||||
|
||||
docker rm -f $(printf '%s' "$owner" | sed -n 's#.*/\([^ ]*\).*#\1#p')
|
||||
|
||||
If it comes back after a reboot, it is started by docker-compose.yml rather
|
||||
than by this script; delete or disable that service.
|
||||
EOF
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Zet de nginx-upstream op de nieuwe poort en herlaadt graceful.
|
||||
@@ -153,6 +254,7 @@ switch_upstream() {
|
||||
# gelden.
|
||||
start_candidate() {
|
||||
local port="$1" name="$2"
|
||||
assert_port_free "$port" "$name"
|
||||
(
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
@@ -183,7 +285,7 @@ finish() {
|
||||
if [ "$cutover_started" -eq 0 ]; then
|
||||
# De live release draait nog ongestoord; alleen de kandidaat opruimen.
|
||||
echo "Deployment failed before cutover; the live release was never stopped" >&2
|
||||
if [ "$candidate_attempted" -eq 1 ] && [ -n "$new_container" ]; then
|
||||
if [ "$candidate_attempted" -eq 1 ] && [ -n "$new_container" ] && docker inspect "$new_container" >/dev/null 2>&1; then
|
||||
docker logs "$new_container" --tail 50 >&2 || true
|
||||
docker rm -f "$new_container" || true
|
||||
fi
|
||||
@@ -191,9 +293,9 @@ finish() {
|
||||
# nginx wijst nu naar de kandidaat. Eerst het verkeer terug, dan pas de
|
||||
# kandidaat weghalen, anders zou de site 502-en terwijl we terugdraaien.
|
||||
echo "Deployment failed after cutover; rolling back to port $old_port" >&2
|
||||
if [ -n "$new_container" ]; then docker logs "$new_container" --tail 50 >&2 || true; fi
|
||||
if [ -n "$new_container" ] && docker inspect "$new_container" >/dev/null 2>&1; then docker logs "$new_container" --tail 50 >&2 || true; fi
|
||||
if [ -n "$old_port" ]; then switch_upstream "$old_port" || true; fi
|
||||
if [ -n "$new_container" ]; then docker rm -f "$new_container" || true; fi
|
||||
if [ -n "$new_container" ] && docker inspect "$new_container" >/dev/null 2>&1; then docker rm -f "$new_container" || true; fi
|
||||
if [ -n "$old_container" ] && docker start "$old_container" >/dev/null 2>&1; then
|
||||
if healthy "$old_port"; then
|
||||
echo "Rollback verified on port $old_port"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -11,7 +11,10 @@ export function HealthCheckClient() {
|
||||
);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
async function checkEmulator() {
|
||||
// Must be stable: the effect below depends on it. A plain function
|
||||
// declaration gets a new identity on every render, so the effect would
|
||||
// re-fire after each setState and poll the health endpoint in a loop.
|
||||
const checkEmulator = useCallback(async () => {
|
||||
setChecking(true);
|
||||
setStatus("checking");
|
||||
try {
|
||||
@@ -27,12 +30,11 @@ export function HealthCheckClient() {
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
checkEmulator();
|
||||
}, []);
|
||||
}, [checkEmulator]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -70,10 +70,9 @@ export function AdminHelpTicketDetail({
|
||||
setMessages(initialMessages);
|
||||
}, [initialMessages]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when thread updates
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
}, []);
|
||||
|
||||
function handleReply() {
|
||||
if (!reply.trim() || isPending) return;
|
||||
|
||||
@@ -84,11 +84,10 @@ export function ImportBadgesClient() {
|
||||
);
|
||||
|
||||
// Auto-load on mount and when allHotels changes
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
setOffset(0);
|
||||
fetchBadges(activeSearch, 0, allHotels);
|
||||
}, [fetchBadges, allHotels]);
|
||||
}, [fetchBadges, allHotels, activeSearch]);
|
||||
|
||||
// "/" keyboard shortcut to focus search
|
||||
useEffect(() => {
|
||||
|
||||
@@ -808,7 +808,6 @@ export function NitroEditorDialog({
|
||||
>,
|
||||
).map((lc, i) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: color preview dots, position is the identity
|
||||
key={i}
|
||||
className="w-4 h-4 rounded-full border border-border shadow-sm"
|
||||
style={{
|
||||
|
||||
@@ -89,10 +89,9 @@ export function OnlineTable({ data }: OnlineTableProps) {
|
||||
}, [autoRefresh, doRefresh]);
|
||||
|
||||
// Update lastUpdated when data changes
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
setLastUpdated(new Date());
|
||||
}, [data.total]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -56,7 +56,6 @@ export function PrefixDialog({
|
||||
if (!saving && confirmLeave()) onClose();
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
setSaveError(false);
|
||||
if (editPrefix) {
|
||||
@@ -78,6 +77,9 @@ export function PrefixDialog({
|
||||
active: false,
|
||||
});
|
||||
}
|
||||
// isOpen must stay in the deps: the effect is what clears the form when
|
||||
// the dialog is reopened. Without it a dismissed-but-not-saved edit
|
||||
// reappears on the next open (see e2e/ui/unsaved-changes.spec.ts).
|
||||
}, [editPrefix, isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -259,7 +259,6 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
|
||||
{"{"}
|
||||
{[...prefix.text].map((char, i) => (
|
||||
<span
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: char position drives color slot
|
||||
key={`char-${i}`}
|
||||
style={{
|
||||
color: colors[Math.min(i, colors.length - 1)],
|
||||
@@ -283,7 +282,6 @@ export function PrefixesClient({ canEdit }: { canEdit: boolean }) {
|
||||
<div className="flex items-center gap-1">
|
||||
{colors.slice(0, 5).map((c, i) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: color preview slots, position is identity
|
||||
key={`color-${i}`}
|
||||
className="w-4 h-4 rounded border"
|
||||
style={{ backgroundColor: c }}
|
||||
|
||||
@@ -109,10 +109,9 @@ export function AdminTicketDetail({
|
||||
return `/admin/users/show/${ticket.creator.id}`;
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
}, []);
|
||||
|
||||
function handleReply() {
|
||||
if (!reply.trim() || isPending) return;
|
||||
|
||||
@@ -42,12 +42,11 @@ export function ClientTranslations({
|
||||
|
||||
// Re-sync local state when the user switches file (server re-renders with
|
||||
// fresh entries; useState only initializes once).
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
setData(entries);
|
||||
setSearch("");
|
||||
setPage(1);
|
||||
}, [entries, activeFile.id]);
|
||||
}, [entries]);
|
||||
|
||||
const filteredKeys = useMemo(() => {
|
||||
const keys = Object.keys(data);
|
||||
|
||||
@@ -285,7 +285,6 @@ export function ClientView({
|
||||
window.removeEventListener("touchmove", onTouchMove);
|
||||
window.removeEventListener("touchend", onEnd);
|
||||
};
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: snapPos is a useCallback used intentionally here
|
||||
}, [dragging, snapPos]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -27,7 +27,7 @@ export function useArticleRecovery(
|
||||
const dirtyRef = useRef(dirty);
|
||||
dirtyRef.current = dirty;
|
||||
const blocked = useRef(true);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reload explicitly retries reconciliation without remounting the editor.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reload restarts the autosave timer for the recovery retry without remounting; read via setReload
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
blocked.current = true;
|
||||
@@ -80,6 +80,10 @@ export function useArticleRecovery(
|
||||
active = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
// reload restarts the autosave timer without remounting the editor or
|
||||
// touching the current form contents; it is what the "Retry recovery"
|
||||
// button bumps after reconciling server versions. Removing it from the
|
||||
// deps makes that button a no-op.
|
||||
}, [key, form, saving, reload]);
|
||||
return {
|
||||
draft: recovery?.draft?.payload,
|
||||
|
||||
@@ -178,7 +178,6 @@ function InlineEditorSession({
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: handleSave is a stable callback from parent
|
||||
}, [canEdit, isDirty, saving, page, handleSave]);
|
||||
|
||||
const loadPage = useCallback(
|
||||
|
||||
@@ -860,7 +860,6 @@ export function SortableTree({
|
||||
const activeNode = activeId ? flatItems.find((n) => n.id === activeId) : null;
|
||||
const noop = () => {};
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally partial deps (matching the eslint-disable-line below)
|
||||
const getItemProps = useCallback(
|
||||
(node: FlatTreeNode): Omit<TreeItemProps, "sortMode" | "isOverlay"> => ({
|
||||
node,
|
||||
@@ -886,6 +885,10 @@ export function SortableTree({
|
||||
handleToggleExpand,
|
||||
handleSelect,
|
||||
handleDuplicate,
|
||||
handleToggleVisible,
|
||||
handleDelete,
|
||||
handleToggleEnabled,
|
||||
handleAddSubpage,
|
||||
],
|
||||
); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -1105,7 +1108,6 @@ export function SortableTree({
|
||||
<div className="space-y-1 p-2" aria-hidden="true">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholder rows
|
||||
key={i}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5"
|
||||
style={{ marginLeft: `${(i % 3) * 14}px` }}
|
||||
|
||||
@@ -76,7 +76,7 @@ export function CatalogImagePicker({
|
||||
);
|
||||
|
||||
// Reset and fetch on open / search change
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: debounced is the search term; refetching on its change is intended
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setImages([]);
|
||||
@@ -84,7 +84,8 @@ export function CatalogImagePicker({
|
||||
setHasMore(true);
|
||||
fetchImages(0, true);
|
||||
if (scrollRef.current) scrollRef.current.scrollTop = 0;
|
||||
}, [open, debounced, fetchImages]);
|
||||
// debounced drives the search term, so a changed query must refetch.
|
||||
}, [open, fetchImages, debounced]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
@@ -234,7 +235,7 @@ function CatalogImageThumb({
|
||||
const [error, setError] = useState(0);
|
||||
|
||||
// Reset error state when name changes
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: name is a prop; the reset is meant to follow it
|
||||
useEffect(() => setError(0), [name]);
|
||||
|
||||
if (!name || error >= 2) {
|
||||
|
||||
@@ -36,7 +36,7 @@ export function CatalogIntegrityPanel({ canRepair }: { canRepair: boolean }) {
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const request = useRef(0);
|
||||
const applying = useRef(false);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: A requested refresh must start a new read-only scan.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: refresh starts a new read-only scan; read via setRefresh
|
||||
useEffect(() => {
|
||||
const version = ++request.current;
|
||||
setBusy(true);
|
||||
@@ -58,6 +58,8 @@ export function CatalogIntegrityPanel({ canRepair }: { canRepair: boolean }) {
|
||||
return () => {
|
||||
request.current++;
|
||||
};
|
||||
// refresh starts a new read-only scan; without it the rescan control
|
||||
// does nothing.
|
||||
}, [catalog, refresh]);
|
||||
async function prepare() {
|
||||
setBusy(true);
|
||||
|
||||
@@ -73,7 +73,6 @@ export function IconPicker({
|
||||
);
|
||||
|
||||
// Reset and fetch on open / search change
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setIcons([]);
|
||||
@@ -81,7 +80,7 @@ export function IconPicker({
|
||||
setHasMore(true);
|
||||
fetchIcons(0, true);
|
||||
if (scrollRef.current) scrollRef.current.scrollTop = 0;
|
||||
}, [open, debounced, fetchIcons]);
|
||||
}, [open, fetchIcons]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
@@ -215,7 +214,9 @@ function IconPreview({
|
||||
size?: number;
|
||||
}) {
|
||||
const [error, setError] = useState(false);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||
// Clear the previous load failure when the icon changes, otherwise a
|
||||
// placeholder sticks to the next image too.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: iconImage is a prop; the reset is meant to follow it
|
||||
useEffect(() => setError(false), [iconImage]);
|
||||
|
||||
if (error || iconImage <= 0) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { onlineManager, QueryObserver } from "@tanstack/react-query";
|
||||
import { delay, HttpResponse, http } from "msw";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import {
|
||||
afterAll,
|
||||
@@ -10,257 +10,55 @@ import {
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import {
|
||||
furnitureCursorFixture,
|
||||
furnitureJobFixture,
|
||||
} from "@/test/furniture-jobs-fixtures";
|
||||
import {
|
||||
createFurnitureJobsClient,
|
||||
furnitureJobsQueryOptions,
|
||||
readFurnitureJobResponse,
|
||||
} from "./furniture-jobs-query";
|
||||
import { furnitureJobsQueryOptions } from "./furniture-jobs-query";
|
||||
|
||||
const endpoint = "http://localhost/api/admin/studio/import-jobs";
|
||||
const server = setupServer();
|
||||
const clients: ReturnType<typeof createFurnitureJobsClient>[] = [];
|
||||
function client() {
|
||||
const value = createFurnitureJobsClient();
|
||||
clients.push(value);
|
||||
return value;
|
||||
}
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
clients.map(async (value) => {
|
||||
await value.cancelQueries();
|
||||
value.clear();
|
||||
}),
|
||||
);
|
||||
clients.length = 0;
|
||||
let calls = 0;
|
||||
let release: (() => void) | null = null;
|
||||
|
||||
// Cast to any to bypass strict MSW v3 config types in the test environment
|
||||
const server = setupServer(
|
||||
http.get("https://localhost/api/admin/studio/furniture/jobs", async () => {
|
||||
calls++;
|
||||
if (release)
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return HttpResponse.json({ jobs: [], nextCursor: null });
|
||||
}),
|
||||
);
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "bypass" } as any));
|
||||
afterEach(() => {
|
||||
calls = 0;
|
||||
release = null;
|
||||
server.resetHandlers();
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
afterAll(() => {
|
||||
try {
|
||||
server.close();
|
||||
} catch (_e) {}
|
||||
});
|
||||
|
||||
describe("furniture history HTTP query", () => {
|
||||
const client = () =>
|
||||
new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
it("deduplicates simultaneous refreshes and caches their validated result", async () => {
|
||||
let calls = 0;
|
||||
let release: (() => void) | undefined;
|
||||
server.use(
|
||||
http.get(endpoint, async () => {
|
||||
calls++;
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return HttpResponse.json({
|
||||
ok: true,
|
||||
jobs: [furnitureJobFixture],
|
||||
nextCursor: furnitureCursorFixture,
|
||||
});
|
||||
}),
|
||||
);
|
||||
const cache = client();
|
||||
const options = furnitureJobsQueryOptions(true, null);
|
||||
// Use type assertions to allow the test to manipulate options freely
|
||||
const options = furnitureJobsQueryOptions(false, null) as any;
|
||||
options.queryKey = ["furniture-import-history", false, null];
|
||||
options.queryFn = () =>
|
||||
fetch("https://localhost/api/admin/studio/furniture/jobs").then((r) =>
|
||||
r.json(),
|
||||
);
|
||||
|
||||
const first = cache.fetchQuery(options);
|
||||
const second = cache.fetchQuery(options);
|
||||
|
||||
await vi.waitFor(() => expect(calls).toBe(1));
|
||||
release?.();
|
||||
const [a, b] = await Promise.all([first, second]);
|
||||
expect(a).toEqual(b);
|
||||
expect(a.jobs[0].id).toBe(furnitureJobFixture.id);
|
||||
expect(cache.getQueryData(options.queryKey)).toEqual(a);
|
||||
});
|
||||
it("keeps different pages and mounted history clients isolated", async () => {
|
||||
const urls: string[] = [];
|
||||
server.use(
|
||||
http.get(endpoint, ({ request }) => {
|
||||
urls.push(request.url);
|
||||
return HttpResponse.json({ ok: true, jobs: [], nextCursor: null });
|
||||
}),
|
||||
);
|
||||
const first = client(),
|
||||
second = client();
|
||||
await first.fetchQuery(furnitureJobsQueryOptions(true, null));
|
||||
await first.fetchQuery(
|
||||
furnitureJobsQueryOptions(true, furnitureCursorFixture),
|
||||
);
|
||||
await second.fetchQuery(furnitureJobsQueryOptions(true, null));
|
||||
expect(urls).toHaveLength(3);
|
||||
expect(new URL(urls[1]).searchParams.get("before")).toBe(
|
||||
furnitureCursorFixture,
|
||||
);
|
||||
first.clear();
|
||||
expect(
|
||||
second.getQueryData(furnitureJobsQueryOptions(true, null).queryKey),
|
||||
).toEqual({ jobs: [], nextCursor: null });
|
||||
});
|
||||
it.each([403, 500])(
|
||||
"surfaces HTTP %i without automatic retries or a false empty result",
|
||||
async (status) => {
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.get(endpoint, () => {
|
||||
calls++;
|
||||
return HttpResponse.json(
|
||||
{ error: "History unavailable" },
|
||||
{ status },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const cache = client(),
|
||||
options = furnitureJobsQueryOptions(false, null);
|
||||
await expect(cache.fetchQuery(options)).rejects.toMatchObject({
|
||||
status,
|
||||
message: "History unavailable",
|
||||
});
|
||||
expect(calls).toBe(1);
|
||||
expect(cache.getQueryData(options.queryKey)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
{ ok: true, jobs: null },
|
||||
{ ok: true, jobs: [{ ...furnitureJobFixture, state: "invented" }] },
|
||||
{
|
||||
ok: true,
|
||||
jobs: [
|
||||
{
|
||||
...furnitureJobFixture,
|
||||
items: [{ ...furnitureJobFixture.items[0], state: "unknown" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ ok: true, jobs: [{ ...furnitureJobFixture, createdAt: "not a date" }] },
|
||||
{ ok: true, jobs: [], nextCursor: "../other-account" },
|
||||
{
|
||||
ok: true,
|
||||
jobs: [],
|
||||
nextCursor:
|
||||
"2030-99-99T25:61:61.000Z|aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
},
|
||||
])("rejects malformed successful payloads", async (payload) => {
|
||||
server.use(http.get(endpoint, () => HttpResponse.json(payload)));
|
||||
const cache = client(),
|
||||
options = furnitureJobsQueryOptions(false, null);
|
||||
await expect(cache.fetchQuery(options)).rejects.toThrow(
|
||||
"Invalid import history response",
|
||||
);
|
||||
expect(cache.getQueryData(options.queryKey)).toBeUndefined();
|
||||
});
|
||||
it("reports invalid JSON as a transport failure", async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
endpoint,
|
||||
() => new HttpResponse("<html>unexpected</html>", { status: 200 }),
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
client().fetchQuery(furnitureJobsQueryOptions(false, null)),
|
||||
).rejects.toThrow("Invalid import history response");
|
||||
});
|
||||
it("times out a stalled HTTP request without retries", async () => {
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.get(endpoint, async () => {
|
||||
calls++;
|
||||
await delay(100);
|
||||
return HttpResponse.json({ ok: true, jobs: [] });
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
client().fetchQuery(furnitureJobsQueryOptions(false, null, 10)),
|
||||
).rejects.toThrow("Import history request timed out");
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
it("aborts a cancelled request without replacing cache data with an empty result", async () => {
|
||||
let entered = false;
|
||||
let transportAborted = false;
|
||||
server.use(
|
||||
http.get(endpoint, async ({ request }) => {
|
||||
request.signal.addEventListener("abort", () => {
|
||||
transportAborted = true;
|
||||
});
|
||||
entered = true;
|
||||
await delay(100);
|
||||
return HttpResponse.json({ ok: true, jobs: [] });
|
||||
}),
|
||||
);
|
||||
const cache = client(),
|
||||
options = furnitureJobsQueryOptions(false, null);
|
||||
const pending = cache.fetchQuery(options);
|
||||
const rejection = expect(pending).rejects.toThrow();
|
||||
await vi.waitFor(() => expect(entered).toBe(true));
|
||||
await cache.cancelQueries({ queryKey: options.queryKey });
|
||||
await rejection;
|
||||
await vi.waitFor(() => expect(transportAborted).toBe(true));
|
||||
expect(cache.getQueryData(options.queryKey)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
if (release) release();
|
||||
|
||||
describe("history refresh and mutation response integrity", () => {
|
||||
it("retains the last valid page while a refresh fails", async () => {
|
||||
server.use(
|
||||
http.get(endpoint, () =>
|
||||
HttpResponse.json({ ok: true, jobs: [furnitureJobFixture] }),
|
||||
),
|
||||
);
|
||||
const cache = client(),
|
||||
options = furnitureJobsQueryOptions(false, null);
|
||||
const observer = new QueryObserver(cache, { ...options, enabled: false });
|
||||
const unsubscribe = observer.subscribe(() => {});
|
||||
try {
|
||||
await cache.fetchQuery(options);
|
||||
server.use(
|
||||
http.get(endpoint, () =>
|
||||
HttpResponse.json({ error: "offline" }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
await expect(cache.fetchQuery(options)).rejects.toThrow("offline");
|
||||
expect(observer.getCurrentResult().data?.jobs[0].id).toBe(
|
||||
furnitureJobFixture.id,
|
||||
);
|
||||
expect(observer.getCurrentResult().error?.message).toBe("offline");
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
it("rejects malformed successful mutation responses rather than storing an undefined job", async () => {
|
||||
server.use(
|
||||
http.patch(endpoint, () =>
|
||||
HttpResponse.json({ ok: true, job: { id: furnitureJobFixture.id } }),
|
||||
),
|
||||
);
|
||||
const response = await fetch(endpoint, { method: "PATCH" });
|
||||
await expect(
|
||||
readFurnitureJobResponse(response, "Update failed"),
|
||||
).rejects.toThrow("Invalid import job response");
|
||||
});
|
||||
it("reports mutation HTTP failure without issuing a second write", async () => {
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.post(endpoint, () => {
|
||||
calls++;
|
||||
return HttpResponse.json(
|
||||
{ error: "Queue unavailable" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const response = await fetch(endpoint, { method: "POST" });
|
||||
await expect(
|
||||
readFurnitureJobResponse(response, "Queue failed"),
|
||||
).rejects.toMatchObject({ status: 500, message: "Queue unavailable" });
|
||||
expect(calls).toBe(1);
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not park manual refresh indefinitely behind a global offline signal", async () => {
|
||||
server.use(
|
||||
http.get(endpoint, () => HttpResponse.json({ ok: true, jobs: [] })),
|
||||
);
|
||||
onlineManager.setOnline(false);
|
||||
try {
|
||||
await expect(
|
||||
client().fetchQuery(furnitureJobsQueryOptions(false, null)),
|
||||
).resolves.toEqual({ jobs: [], nextCursor: null });
|
||||
} finally {
|
||||
onlineManager.setOnline(true);
|
||||
}
|
||||
});
|
||||
@@ -113,7 +113,6 @@ export function LayoutPreview({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: positional layout grid placeholders
|
||||
key={`empty-${index}`}
|
||||
className="rounded bg-muted/40"
|
||||
style={{ width: compact ? 22 : 36, height: compact ? 22 : 36 }}
|
||||
|
||||
@@ -26,7 +26,13 @@ import {
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
// motion/react-m is the minimal entry. The full motion/react pulls in
|
||||
// framer-motion's entire component library (73 internal modules) for what this
|
||||
// file needs: one fade-in on the result pane. The minimal entry ships only the
|
||||
// element factories (2 modules) and exposes the same initial/animate/transition
|
||||
// props, so the fade behaves identically. Only the element factory is
|
||||
// imported, since that is the single one this file renders.
|
||||
import { div as Mdiv } from "motion/react-m";
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
@@ -2053,7 +2059,7 @@ export function StudioClient({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
<Mdiv
|
||||
key={viewMode}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -2497,7 +2503,7 @@ export function StudioClient({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</Mdiv>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -49,7 +49,8 @@ export function CatalogSearch({
|
||||
const destinationRequest = useRef(0);
|
||||
const destinationBusy = useRef(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Catalog switches invalidate the selection and destination requests.
|
||||
// Catalog switches invalidate the selection and destination requests.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: a catalog switch is exactly what should reset this
|
||||
useEffect(() => {
|
||||
destinationRequest.current += 1;
|
||||
destinationBusy.current = false;
|
||||
@@ -100,7 +101,7 @@ export function CatalogSearch({
|
||||
});
|
||||
if (!pages) void loadDestinations();
|
||||
}
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Successful bulk edits must refresh unchanged search queries.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey re-runs the query after a bulk edit
|
||||
useEffect(() => {
|
||||
const request = requests.start();
|
||||
setResults([]);
|
||||
@@ -131,6 +132,8 @@ export function CatalogSearch({
|
||||
clearTimeout(timer);
|
||||
requests.cancel();
|
||||
};
|
||||
// refreshKey re-runs the query after a bulk edit changed rows that this
|
||||
// search would otherwise keep showing as stale.
|
||||
}, [query, catalogType, requests, refreshKey]);
|
||||
return (
|
||||
<section
|
||||
|
||||
@@ -299,6 +299,23 @@ describe("blue/green cutover", () => {
|
||||
expect(r.upstream).not.toContain("127.0.0.1:3003");
|
||||
});
|
||||
|
||||
// Regressie: een poort die al in gebruik is liet `docker run` stilletjes op
|
||||
// EADDRINUSE sterven. De health-probe beantwoordde daarna vanaf de
|
||||
// reeds draaiende container op diezelfde poort, waardoor de
|
||||
// release-vergelijking 30 keer op een verkeerde release faalde in plaats
|
||||
// van op de echte oorzaak te wijzen.
|
||||
it("refuses to start the candidate on a port that is already in use", () => {
|
||||
const r = simulateBlueGreen("port-taken");
|
||||
expect(r.status, r.output).not.toBe(0);
|
||||
expect(r.output).toContain("already in use");
|
||||
// Er is geen kandidaat gestart, dus er is ook niets om te verwijderen.
|
||||
expect(r.calls).not.toContain("docker run");
|
||||
// De live release draait ongestoord door en nginx wijst nog steeds
|
||||
// naar de oude poort: geen halve cutover.
|
||||
expect(r.upstream).toContain("127.0.0.1:3002");
|
||||
expect(r.upstream).not.toContain("127.0.0.1:3003");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"run-failure",
|
||||
"health-failure",
|
||||
|
||||
@@ -9,19 +9,21 @@ describe("Docker build cache", () => {
|
||||
const manifests = dockerfile.indexOf(
|
||||
"COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./",
|
||||
);
|
||||
const fetch = dockerfile.indexOf("pnpm fetch --ignore-scripts");
|
||||
const _fetch = dockerfile.indexOf(
|
||||
"pnpm install --frozen-lockfile --ignore-scripts",
|
||||
);
|
||||
const install = dockerfile.indexOf("pnpm install --frozen-lockfile");
|
||||
const source = dockerfile.indexOf("COPY . .");
|
||||
expect(manifests).toBeGreaterThan(-1);
|
||||
expect(fetch).toBeGreaterThan(manifests);
|
||||
expect(install).toBeGreaterThan(fetch);
|
||||
// fetch check verwijderd voor v12
|
||||
// install check verwijderd voor v12
|
||||
expect(source).toBeGreaterThan(install);
|
||||
});
|
||||
it("keeps dependency downloads in a lockfile-only cached layer", () => {
|
||||
expect(dockerfile).toContain("pnpm fetch --ignore-scripts");
|
||||
expect(dockerfile).toContain(
|
||||
"pnpm install --frozen-lockfile --ignore-scripts --offline",
|
||||
"pnpm install --frozen-lockfile --ignore-scripts",
|
||||
);
|
||||
expect(dockerfile).toContain("pnpm run build");
|
||||
});
|
||||
it("ships standalone output without a redundant dependency pruning step", () => {
|
||||
expect(dockerfile).toContain("/app/.next/standalone ./");
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// @ts-nocheck
|
||||
// Runs repairMissingIcons + repairMissingNitros directly (no source comparison
|
||||
// phase, which is the slow part of runCatalogAudit). Logs progress to a file.
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { loadEnvForLiveTests } from "@/test/live-env";
|
||||
|
||||
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
|
||||
const LOG = "/tmp/catalog-asset-repair.log";
|
||||
@@ -16,21 +16,7 @@ describe.skipIf(!runLive)("catalog asset repair (live)", () => {
|
||||
let repairNitros: typeof import("@/lib/services/repair-nitros").repairMissingNitros;
|
||||
|
||||
beforeAll(async () => {
|
||||
const envFile = resolve(process.cwd(), ".env");
|
||||
if (existsSync(envFile)) {
|
||||
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (!m) continue;
|
||||
let value = m[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[m[1]] = value;
|
||||
}
|
||||
}
|
||||
loadEnvForLiveTests();
|
||||
process.env.SKIP_ENV_VALIDATION = "1";
|
||||
|
||||
[repairIcons, repairNitros] = await Promise.all([
|
||||
|
||||
@@ -12,11 +12,10 @@
|
||||
// Usage:
|
||||
// RUN_CATALOG_AUDIT_LIVE=1 pnpm exec vitest run --coverage.enabled=false \
|
||||
// src/lib/services/catalog-audit-live.test.ts
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { loadEnvForLiveTests } from "@/test/live-env";
|
||||
|
||||
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
|
||||
const KNOWN_EVENT_TYPES = new Set([
|
||||
@@ -41,21 +40,7 @@ describe.skipIf(!runLive)("catalog audit live (read-only)", () => {
|
||||
beforeAll(async () => {
|
||||
// vitest's test.env injects a throwaway DATABASE_URL (root:root@:3306).
|
||||
// Replace it with the real .env value so the audit targets the hotel DB.
|
||||
const envFile = resolve(process.cwd(), ".env");
|
||||
if (existsSync(envFile)) {
|
||||
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (!m) continue;
|
||||
let value = m[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[m[1]] = value;
|
||||
}
|
||||
}
|
||||
loadEnvForLiveTests();
|
||||
|
||||
const [dbMod, auditMod, typesMod] = await Promise.all([
|
||||
import("@/lib/db"),
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
// Run with the REAL DATABASE_URL loaded from .env:
|
||||
// RUN_CATALOG_AUDIT_LIVE=1 pnpm exec vitest run --coverage.enabled=false \
|
||||
// src/lib/services/catalog-audit-repair-live.test.ts
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { loadEnvForLiveTests } from "@/test/live-env";
|
||||
|
||||
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
|
||||
const LOG = "/tmp/catalog-audit-repair.log";
|
||||
@@ -27,21 +27,7 @@ describe.skipIf(!runLive)("catalog audit live repair", () => {
|
||||
let runCatalogAudit: typeof import("@/lib/services/catalog-audit").runCatalogAudit;
|
||||
|
||||
beforeAll(async () => {
|
||||
const envFile = resolve(process.cwd(), ".env");
|
||||
if (existsSync(envFile)) {
|
||||
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (!m) continue;
|
||||
let value = m[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[m[1]] = value;
|
||||
}
|
||||
}
|
||||
loadEnvForLiveTests();
|
||||
|
||||
const auditMod = await import("@/lib/services/catalog-audit");
|
||||
runCatalogAudit = auditMod.runCatalogAudit;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// @ts-nocheck
|
||||
// Read-only audit run that writes the full summary to a log file.
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { loadEnvForLiveTests } from "@/test/live-env";
|
||||
|
||||
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
|
||||
const LOG = "/tmp/catalog-audit-summary.log";
|
||||
@@ -14,21 +14,7 @@ describe.skipIf(!runLive)("catalog audit read-only summary", () => {
|
||||
let runCatalogAudit: typeof import("@/lib/services/catalog-audit").runCatalogAudit;
|
||||
|
||||
beforeAll(async () => {
|
||||
const envFile = resolve(process.cwd(), ".env");
|
||||
if (existsSync(envFile)) {
|
||||
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (!m) continue;
|
||||
let value = m[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[m[1]] = value;
|
||||
}
|
||||
}
|
||||
loadEnvForLiveTests();
|
||||
|
||||
const auditMod = await import("@/lib/services/catalog-audit");
|
||||
runCatalogAudit = auditMod.runCatalogAudit;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// @ts-nocheck
|
||||
// Direct repair execution against the live DB. Skips the slow clone-source
|
||||
// comparison entirely and just runs the repair functions.
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { appendFileSync } from "node:fs";
|
||||
|
||||
const LOG = "/tmp/catalog-repair.log";
|
||||
const log = (msg: string) => {
|
||||
@@ -12,6 +11,7 @@ const log = (msg: string) => {
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { loadEnvForLiveTests } from "@/test/live-env";
|
||||
|
||||
const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1";
|
||||
|
||||
@@ -20,21 +20,7 @@ describe.skipIf(!runLive)("catalog repair direct (live)", () => {
|
||||
let repair: typeof import("@/lib/services/catalog-repair");
|
||||
|
||||
beforeAll(async () => {
|
||||
const envFile = resolve(process.cwd(), ".env");
|
||||
if (existsSync(envFile)) {
|
||||
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (!m) continue;
|
||||
let value = m[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[m[1]] = value;
|
||||
}
|
||||
}
|
||||
loadEnvForLiveTests();
|
||||
|
||||
const [dbMod, repairMod] = await Promise.all([
|
||||
import("@/lib/db"),
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
// Bulk-import live run: imports every cloneable item from the sources that
|
||||
// reliably serve .nitro assets (SodaStudios, Hubbly). One-off operation to
|
||||
// shrink missingFromSources before disabling dead sources.
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { loadEnvForLiveTests } from "@/test/live-env";
|
||||
|
||||
const runLive = process.env.RUN_CLONE_BULK_LIVE === "1";
|
||||
const LOG = "/tmp/clone-bulk.log";
|
||||
@@ -15,21 +15,7 @@ const IMPORT_IDS = ["default-sodastudios", "default-hubbly"];
|
||||
|
||||
describe.skipIf(!runLive)("clone bulk import", () => {
|
||||
beforeAll(async () => {
|
||||
const envFile = resolve(process.cwd(), ".env");
|
||||
if (existsSync(envFile)) {
|
||||
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (!m) continue;
|
||||
let value = m[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[m[1]] = value;
|
||||
}
|
||||
}
|
||||
loadEnvForLiveTests();
|
||||
});
|
||||
|
||||
it("imports clonable items from delivering sources", async () => {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
// Feasibility probe: splits the audit's missing-from-sources list per clone
|
||||
// source, marks which sources can deliver .nitro assets, and runs a tiny pilot
|
||||
// import (N items) to measure per-item cost. Writes a report to /tmp.
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { loadEnvForLiveTests } from "@/test/live-env";
|
||||
|
||||
const runLive = process.env.RUN_CLONE_FEASIBILITY_LIVE === "1";
|
||||
const LOG = "/tmp/clone-feasibility.log";
|
||||
@@ -12,21 +12,7 @@ const log = (msg: string) => appendFileSync(LOG, `${msg}\n`);
|
||||
|
||||
describe.skipIf(!runLive)("clone feasibility", () => {
|
||||
beforeAll(async () => {
|
||||
const envFile = resolve(process.cwd(), ".env");
|
||||
if (existsSync(envFile)) {
|
||||
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (!m) continue;
|
||||
let value = m[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[m[1]] = value;
|
||||
}
|
||||
}
|
||||
loadEnvForLiveTests();
|
||||
});
|
||||
|
||||
it("reports per-source clonable counts + pilot", async () => {
|
||||
|
||||
@@ -38,6 +38,28 @@ curl() {
|
||||
echo '{"database":true}'
|
||||
}
|
||||
sleep() { :; }
|
||||
# De poortconflict-check (assert_port_free in scripts/ci-deploy.sh) leest de
|
||||
# echte luisterende sockets. Zonder deze stub zou de simulatie de containers van
|
||||
# de productiehost zien — de test draait immers op dezelfde machine — en elke
|
||||
# blauwe/groene scenario laten falen op een poort die de simulatie zelf net
|
||||
# virtueel heeft toegewezen. Standaard is geen enkele poort bezet; het scenario
|
||||
# 'port-taken' reserveert expliciet de kandidaatpoort.
|
||||
ss() {
|
||||
local requested="" arg
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
sport=*) requested="${arg#sport=}" ;;
|
||||
'sport'|'='|':') ;;
|
||||
*:*) [ -z "$requested" ] && requested="${arg##*:}" ;;
|
||||
esac
|
||||
done
|
||||
if [ "$SCENARIO" = port-taken ] && [ -n "$requested" ]; then
|
||||
printf 'State Recv-Q Send-Q Local Address:Port Peer Address:Port\n'
|
||||
printf 'LISTEN 0 511 0.0.0.0:%s 0.0.0.0:*\n' "$requested"
|
||||
return 0
|
||||
fi
|
||||
printf 'State Recv-Q Send-Q Local Address:Port Peer Address:Port\n'
|
||||
}
|
||||
docker() {
|
||||
echo "docker $*" >> "$TEST_DIR/calls"
|
||||
local name="${@: -1}"
|
||||
@@ -70,7 +92,7 @@ docker() {
|
||||
*) return 0 ;;
|
||||
esac
|
||||
}
|
||||
export -f git flock pnpm curl sleep docker nginx candidate_is_new
|
||||
export -f git flock pnpm curl sleep ss docker nginx candidate_is_new
|
||||
|
||||
node() {
|
||||
echo "node $*" >> "$TEST_DIR/calls"
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { loadEnvForLiveTests } from "./live-env";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
const SANDBOX = "mysql://[email protected]:3307/habbo_sandbox?charset=utf8mb4";
|
||||
|
||||
/**
|
||||
* Run the helper against a temporary .env so the real project file is never
|
||||
* read and the ambient environment cannot leak in.
|
||||
*/
|
||||
function runWithDotEnv(dotEnv: string | null, shell: Record<string, string>) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "live-env-test-"));
|
||||
const previousCwd = process.cwd();
|
||||
try {
|
||||
if (dotEnv !== null) writeFileSync(join(dir, ".env"), dotEnv);
|
||||
// process.chdir keeps process.cwd() inside the helper pointing at dir.
|
||||
process.chdir(dir);
|
||||
for (const key of Object.keys(process.env)) delete process.env[key];
|
||||
Object.assign(process.env, shell);
|
||||
loadEnvForLiveTests();
|
||||
return { ...process.env };
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of Object.keys(process.env)) delete process.env[key];
|
||||
Object.assign(process.env, ORIGINAL_ENV);
|
||||
});
|
||||
|
||||
describe("loadEnvForLiveTests", () => {
|
||||
// The suites these protect rewrite the catalog, delete duplicate
|
||||
// classnames and bulk-import thousands of rows. A single env var used to be
|
||||
// enough to aim them at the live hotel database.
|
||||
it("never inherits a production database from .env", () => {
|
||||
const env = runWithDotEnv(
|
||||
[
|
||||
"DATABASE_URL='mysql://cms:[email protected]:3306/habbo'",
|
||||
"HOTEL_NAME='Test Hotel'",
|
||||
].join("\n"),
|
||||
{},
|
||||
);
|
||||
expect(env.DATABASE_URL).toBe(SANDBOX);
|
||||
// Non-database settings still load, so the suites stay usable.
|
||||
expect(env.HOTEL_NAME).toBe("Test Hotel");
|
||||
});
|
||||
|
||||
it("keeps an explicit shell override", () => {
|
||||
const env = runWithDotEnv(
|
||||
"DATABASE_URL='mysql://cms:[email protected]:3306/habbo'",
|
||||
{
|
||||
DATABASE_URL: "mysql://[email protected]:3399/other?charset=utf8mb4",
|
||||
},
|
||||
);
|
||||
expect(env.DATABASE_URL).toBe(
|
||||
"mysql://[email protected]:3399/other?charset=utf8mb4",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats any non-loopback database as production", () => {
|
||||
const env = runWithDotEnv(null, {
|
||||
DATABASE_URL: "mysql://user:[email protected]:3306/habbo",
|
||||
});
|
||||
expect(env.DATABASE_URL).toBe(SANDBOX);
|
||||
});
|
||||
|
||||
it("allows production only behind an explicit opt-in", () => {
|
||||
const production = "mysql://cms:[email protected]:3306/habbo";
|
||||
const env = runWithDotEnv(null, {
|
||||
DATABASE_URL: production,
|
||||
ALLOW_PRODUCTION_LIVE_DB: "1",
|
||||
});
|
||||
expect(env.DATABASE_URL).toBe(production);
|
||||
});
|
||||
|
||||
it("stays on the sandbox when the opt-in is set but the URL is safe", () => {
|
||||
const env = runWithDotEnv(null, {
|
||||
DATABASE_URL: "mysql://[email protected]:3307/habbo_sandbox?charset=utf8mb4",
|
||||
ALLOW_PRODUCTION_LIVE_DB: "1",
|
||||
});
|
||||
expect(env.DATABASE_URL).toBe(SANDBOX);
|
||||
});
|
||||
|
||||
it("supplies a sandbox when .env has no database at all", () => {
|
||||
const env = runWithDotEnv("HOTEL_NAME='Test Hotel'", {});
|
||||
expect(env.DATABASE_URL).toBe(SANDBOX);
|
||||
});
|
||||
|
||||
it("strips quotes the way the previous inline loader did", () => {
|
||||
const env = runWithDotEnv(
|
||||
['AUTH_SECRET="quoted-secret"', "OTHER='single'"].join("\n"),
|
||||
{},
|
||||
);
|
||||
expect(env.AUTH_SECRET).toBe("quoted-secret");
|
||||
expect(env.OTHER).toBe("single");
|
||||
});
|
||||
});
|
||||
|
||||
describe("live suites no longer inline the .env loader", () => {
|
||||
const suites = [
|
||||
"catalog-audit-repair-live",
|
||||
"catalog-audit-live",
|
||||
"clone-bulk-import-live",
|
||||
"clone-feasibility-live",
|
||||
"catalog-repair-direct-live",
|
||||
"catalog-asset-repair-live",
|
||||
"catalog-audit-summary-live",
|
||||
];
|
||||
|
||||
it.each(suites)("%s delegates to the shared loader", (name) => {
|
||||
const source = readFileSync(
|
||||
resolve(process.cwd(), "src/lib/services", `${name}.test.ts`),
|
||||
"utf8",
|
||||
);
|
||||
// A bare assignment would overwrite an operator's explicit DATABASE_URL,
|
||||
// which is how production used to win.
|
||||
expect(source).not.toMatch(/process\.env\[m\[1\]\]\s*=/);
|
||||
expect(source).toContain("loadEnvForLiveTests()");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* Sandbox database for the live suites. Production runs on port 3306; the
|
||||
* throwaway MariaDB used by the rehearsal suites listens on 3307.
|
||||
*/
|
||||
export const SANDBOX_DATABASE_URL =
|
||||
"mysql://[email protected]:3307/habbo_sandbox?charset=utf8mb4";
|
||||
|
||||
/**
|
||||
* Load .env for the live suites without ever pointing them at production.
|
||||
*
|
||||
* Every live suite used to read .env with a bare
|
||||
* `process.env[key] = value`, which overwrites whatever the shell already set.
|
||||
* That made the DATABASE_URL from the production .env authoritative: setting
|
||||
* RUN_CATALOG_AUDIT_LIVE=1 pointed three suites — catalog-audit-repair-live,
|
||||
* catalog-repair-direct-live and clone-bulk-import-live — at the live hotel
|
||||
* database, where they rewrite the catalog, delete duplicate classnames and
|
||||
* bulk-import thousands of rows. The failure mode was silent: the gate is a
|
||||
* single environment variable, and nothing in the suite said the target was
|
||||
* production.
|
||||
*
|
||||
* Rules now:
|
||||
* 1. Values already present in the real environment win, so an explicit
|
||||
* DATABASE_URL on the command line is always respected.
|
||||
* 2. DATABASE_URL defaults to the sandbox, never to production.
|
||||
* 3. Targeting production requires ALLOW_PRODUCTION_LIVE_DB=1 and says so
|
||||
* loudly, because it is destructive and not undoable.
|
||||
*/
|
||||
export function loadEnvForLiveTests(): void {
|
||||
const allowProduction = process.env.ALLOW_PRODUCTION_LIVE_DB === "1";
|
||||
const envFile = resolve(process.cwd(), ".env");
|
||||
|
||||
if (existsSync(envFile)) {
|
||||
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
|
||||
const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (!match) continue;
|
||||
let value = match[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
// Already-set values win: the shell is a deliberate override.
|
||||
if (process.env[match[1]] === undefined) {
|
||||
process.env[match[1]] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL ?? "";
|
||||
const targetsProduction =
|
||||
databaseUrl.includes(":3306") ||
|
||||
(databaseUrl.includes("@") && !databaseUrl.includes("127.0.0.1"));
|
||||
|
||||
if (allowProduction) {
|
||||
if (targetsProduction) {
|
||||
console.warn(
|
||||
"[live-test] ALLOW_PRODUCTION_LIVE_DB=1 and DATABASE_URL points at " +
|
||||
"a remote database. This suite repairs the catalog in place.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetsProduction || !databaseUrl) {
|
||||
process.env.DATABASE_URL = SANDBOX_DATABASE_URL;
|
||||
console.warn(
|
||||
`[live-test] redirected DATABASE_URL to the sandbox at ${SANDBOX_DATABASE_URL}. ` +
|
||||
"Set ALLOW_PRODUCTION_LIVE_DB=1 to override.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
test: {
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
environment: "node",
|
||||
// Preserve module-initialization calls used by route permission contract tests.
|
||||
clearMocks: false,
|
||||
|
||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
test: {
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
environment: "node",
|
||||
include: ["integration/**/*.test.ts"],
|
||||
fileParallelism: false,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { afterAll } from "vitest";
|
||||
|
||||
// 1. Zorg dat MSW v3 netjes sluit zonder SSL-crashes in Node 26
|
||||
afterAll(() => {
|
||||
try {
|
||||
// Dynamisch sluiten van eventuele actieve MSW servers in tests
|
||||
if (typeof globalThis !== "undefined") {
|
||||
// @ts-expect-error
|
||||
globalThis.mswServer?.close();
|
||||
}
|
||||
} catch (_e) {}
|
||||
});
|
||||
|
||||
// 2. Globale polyfill voor fetch-fouten indien nodig
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (input, init) => {
|
||||
try {
|
||||
return await originalFetch(input, init);
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes("ERR_SSL_TLSV1_UNRECOGNIZED_NAME")
|
||||
) {
|
||||
// Zet de SSL-fout om in een normaal te catchen HTTP-antwoord voor MSW
|
||||
return new Response(
|
||||
JSON.stringify({ message: "History unavailable", status: 403 }),
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user