Compare commits
134
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e4869bb84 | ||
|
|
6c89a7ed46 | ||
|
|
fbf98cd2b5 | ||
|
|
cf448d6ce9 | ||
|
|
4768b2b406 | ||
|
|
af67a28639 | ||
|
|
a4bc25b6d6 | ||
|
|
fb724a35cc | ||
|
|
a878d831aa | ||
|
|
1ad4f9ea28 | ||
|
|
4f6b352691 | ||
|
|
42fa988d9c | ||
|
|
be4dc08c31 | ||
|
|
6ef92f57fc | ||
|
|
a16e9da018 | ||
|
|
3cb5baabfb | ||
|
|
e96c408ac1 | ||
|
|
e4564abc5e | ||
|
|
30583b8e2e | ||
|
|
97454f361b | ||
|
|
ecd8e5852c | ||
|
|
8ca538f99f | ||
|
|
1e81cac504 | ||
|
|
3bc218ba16 | ||
|
|
61f4db2a00 | ||
|
|
597287fce5 | ||
|
|
5c428c4526 | ||
|
|
e7a6b2b602 | ||
|
|
4b25292ecc | ||
|
|
b40293bd06 | ||
|
|
9cf1eb615c | ||
|
|
aef0939ee9 | ||
|
|
c6e5e0b1a9 | ||
|
|
2249303dbd | ||
|
|
07c5904879 | ||
|
|
4776a41c01 | ||
|
|
a32cf6119b | ||
|
|
bb3799f705 | ||
|
|
d28dde459a | ||
|
|
095353012a | ||
|
|
ef0c425598 | ||
|
|
8d3263a8fe | ||
|
|
050fcdf25b | ||
|
|
a40669b130 | ||
|
|
c15177a52e | ||
|
|
fb470123c3 | ||
|
|
1dd584cd82 | ||
|
|
c9b363d60d | ||
|
|
e870beb71c | ||
|
|
728999b2de | ||
|
|
8d8dab5817 | ||
|
|
59a45d2165 | ||
|
|
6895eaab49 | ||
|
|
8557370d91 | ||
|
|
16a902bf14 | ||
|
|
dc4cca653b | ||
|
|
9894240f06 | ||
|
|
43590f1509 | ||
|
|
b58f3215f7 | ||
|
|
3a296551f6 | ||
|
|
e70b96e3a7 | ||
|
|
480343d5a5 | ||
|
|
77a0d1010c | ||
|
|
f56b345598 | ||
|
|
a4bcd40e7b | ||
|
|
4b575d3a1d | ||
|
|
61107848f8 | ||
|
|
1e3406a7ae | ||
|
|
ef6c671903 | ||
|
|
ef2f8c30f0 | ||
|
|
73005a3c05 | ||
|
|
918134df69 | ||
|
|
d64d864175 | ||
|
|
62de3e3a8b | ||
|
|
9f024dc6ac | ||
|
|
05c2706eaf | ||
|
|
ac23d7d1d9 | ||
|
|
3c8abb6fbf | ||
|
|
640e5a5dd0 | ||
|
|
3fa643828d | ||
|
|
888765c47f | ||
|
|
f3407ebfdf | ||
|
|
56676effc3 | ||
|
|
d7d6382b60 | ||
|
|
007818b03f | ||
|
|
b24045812b | ||
|
|
0d943fe3dc | ||
|
|
6e82a02e64 | ||
|
|
08ff14d0ac | ||
|
|
b17da6ab25 | ||
|
|
56ca775405 | ||
|
|
8ee99e8ce8 | ||
|
|
5ea1d50ad1 | ||
|
|
6ef7486ca4 | ||
|
|
7c15cfd26d | ||
|
|
2ca277d858 | ||
|
|
758e25452d | ||
|
|
60d375144d | ||
|
|
1c816019b1 | ||
|
|
ceff6f98d9 | ||
|
|
8395e60f87 | ||
|
|
a92b951540 | ||
|
|
d5938f94ed | ||
|
|
991328ff0e | ||
|
|
1835221096 | ||
|
|
e7a6245ac2 | ||
|
|
da43b6c80c | ||
|
|
eb67424a1c | ||
|
|
3776475db4 | ||
|
|
54b9186e52 | ||
|
|
fe684faf70 | ||
|
|
22f834ef45 | ||
|
|
7b249a7222 | ||
|
|
57529fd1ea | ||
|
|
acbb833eca | ||
|
|
6912669754 | ||
|
|
7388ad3451 | ||
|
|
b13c9f47be | ||
|
|
f14a0e5fe7 | ||
|
|
505ed2bfe2 | ||
|
|
548ae541b2 | ||
|
|
05883b1ee6 | ||
|
|
ad015e1e9c | ||
|
|
62e3e49ad1 | ||
|
|
338591691f | ||
|
|
f56109836e | ||
|
|
b1a45902b4 | ||
|
|
7d8874c8cd | ||
|
|
a06db72c65 | ||
|
|
afc8909d3f | ||
|
|
990ebdb158 | ||
|
|
1a4888df50 | ||
|
|
2978483ea9 | ||
|
|
43742e8d99 |
No files matched your search
@@ -26,6 +26,25 @@ bun.lockb
|
|||||||
public/nitro-assets
|
public/nitro-assets
|
||||||
public/swf
|
public/swf
|
||||||
|
|
||||||
|
# Testbronnen draaien op de host (pnpm test:ui / test:integration / test:e2e),
|
||||||
|
# de image heeft ze niet nodig. In de image zouden ze alleen de typecheck van
|
||||||
|
# `next build` meebepalen en de image van @playwright/test, vitest, msw,
|
||||||
|
# testcontainers en drizzle-kit afhankelijk maken.
|
||||||
|
e2e/
|
||||||
|
integration/
|
||||||
|
|
||||||
|
# Databasebackups (~232MB, dagelijkse rotatie): horen niet in de buildcontext.
|
||||||
|
# Ze maakten elke context groot en invalideerden bij elke rotatie de
|
||||||
|
# `COPY . .`-laag, waardoor de Next-build volledig opnieuw draaide.
|
||||||
|
backups/
|
||||||
|
|
||||||
|
# Wordt tijdens de build opnieuw gegenereerd door `pnpm assets:editor`
|
||||||
|
# (bron: node_modules/tinymce) en staat niet in git.
|
||||||
|
public/vendor/
|
||||||
|
|
||||||
|
# Lokale buildcache voor `docker compose build` (cache_from/cache_to).
|
||||||
|
.docker-cache/
|
||||||
|
|
||||||
.deploy.lock
|
.deploy.lock
|
||||||
logs
|
logs
|
||||||
|
|
||||||
|
|||||||
+45
-2
@@ -3,8 +3,13 @@
|
|||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|
||||||
# --- DATABASE (High Performance Pooling & Strict Timeouts) ---
|
# --- DATABASE (High Performance Pooling & Strict Timeouts) ---
|
||||||
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=150&connect_timeout=5"
|
# The pool size is governed by DATABASE_POOL_SIZE (src/lib/db.ts); the
|
||||||
DATABASE_POOL_SIZE=150
|
# `connection_limit` in the URL is only honoured by tools that read the URL
|
||||||
|
# directly (drizzle-kit, scripts). Keep both in step and keep them well below
|
||||||
|
# MariaDB's `max_connections` (151 by default) or the emulator and any second
|
||||||
|
# CMS instance get "Too many connections". README §performance: pool size 25.
|
||||||
|
DATABASE_URL="mysql://user:password@localhost:3306/dbname?charset=utf8mb4&connection_limit=25&connect_timeout=5"
|
||||||
|
DATABASE_POOL_SIZE=25
|
||||||
DATABASE_IDLE_TIMEOUT_MS=60000
|
DATABASE_IDLE_TIMEOUT_MS=60000
|
||||||
DATABASE_CONNECT_TIMEOUT_MS=5000
|
DATABASE_CONNECT_TIMEOUT_MS=5000
|
||||||
|
|
||||||
@@ -18,6 +23,21 @@ CACHE_MEMORY_MAX_ENTRIES=2000
|
|||||||
# every 5 minutes brings an over-budget directory back to 90% of this (default 20000).
|
# every 5 minutes brings an over-budget directory back to 90% of this (default 20000).
|
||||||
IMAGING_CACHE_MAX_ENTRIES=20000
|
IMAGING_CACHE_MAX_ENTRIES=20000
|
||||||
|
|
||||||
|
# --- CACHE TTL TUNING (seconds; defaults apply when unset) ---
|
||||||
|
# Higher = less DB load but staler data. Publishing news rotates its cache
|
||||||
|
# revision immediately, so news TTLs only delay content that bypasses that path.
|
||||||
|
CMS_HOME_PAYLOAD_TTL_SEC=15
|
||||||
|
CMS_NEWS_LIST_TTL_SEC=300
|
||||||
|
CMS_NEWS_ARCHIVE_TTL_SEC=60
|
||||||
|
CMS_SEARCH_TTL_SEC=30
|
||||||
|
CMS_ONLINE_COUNT_TTL_SEC=10
|
||||||
|
CMS_COUNTERS_TTL_SEC=300
|
||||||
|
CMS_SITE_SETTINGS_TTL_SEC=300
|
||||||
|
CMS_SITE_SETTINGS_MEMORY_TTL_SEC=60
|
||||||
|
# Boot-time cache warm-up (primes hot keys after each deploy). Set false on
|
||||||
|
# tiny hosts where the boot COUNT(*) burst is undesirable.
|
||||||
|
CMS_WARMUP_ENABLED=true
|
||||||
|
|
||||||
# --- CORE RUNTIME & PERFORMANCE FLAGS ---
|
# --- CORE RUNTIME & PERFORMANCE FLAGS ---
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
PORT=3002
|
PORT=3002
|
||||||
@@ -44,6 +64,10 @@ AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
|
|||||||
APP_KEY=base64:your-app-key-here=
|
APP_KEY=base64:your-app-key-here=
|
||||||
# Bcrypt cost factor for new password hashes.
|
# Bcrypt cost factor for new password hashes.
|
||||||
BCRYPT_COST=12
|
BCRYPT_COST=12
|
||||||
|
# Password-length policy for every new password (register, password reset and
|
||||||
|
# staff-created accounts). Minimum: 5-30. Maximum: 30-128.
|
||||||
|
PASSWORD_MIN_LENGTH=12
|
||||||
|
PASSWORD_MAX_LENGTH=128
|
||||||
|
|
||||||
# --- ANTI-DDOS (app-layer gate, production only) ---
|
# --- ANTI-DDOS (app-layer gate, production only) ---
|
||||||
# On by default in production. Set to "false" to disable (not recommended).
|
# On by default in production. Set to "false" to disable (not recommended).
|
||||||
@@ -78,6 +102,18 @@ CLOUDFLARE_AUTO_BLOCK_ENABLED=true
|
|||||||
# Override for tests/staging (production uses the public endpoint by default).
|
# Override for tests/staging (production uses the public endpoint by default).
|
||||||
CLOUDFLARE_API_BASE_URL=https://api.cloudflare.com/client/v4
|
CLOUDFLARE_API_BASE_URL=https://api.cloudflare.com/client/v4
|
||||||
|
|
||||||
|
# --- CAPTCHA (env fallback; admin settings win when set) ---
|
||||||
|
# Enable a captcha on register/login/forgot/resend without touching the DB.
|
||||||
|
# "none" (default), "turnstile", "recaptcha" or "hcaptcha". Example:
|
||||||
|
# CAPTCHA_PROVIDER=turnstile
|
||||||
|
CAPTCHA_PROVIDER=
|
||||||
|
# Provider keys. The SITE key is public (it ships to every browser), the
|
||||||
|
# SECRET is server-only and must never be committed — set it on the host or as
|
||||||
|
# a masked CI/CD variable. Cloudflare Turnstile keys:
|
||||||
|
# https://dash.cloudflare.com/turnstile
|
||||||
|
TURNSTILE_SITE_KEY=
|
||||||
|
TURNSTILE_SECRET_KEY=
|
||||||
|
|
||||||
# --- PATHS ---
|
# --- PATHS ---
|
||||||
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
BADGE_UPLOAD_DIR=./public/assets/images/badges
|
||||||
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
EMULATOR_JAR_PATH=./emulator/Arcturus.jar
|
||||||
@@ -89,6 +125,13 @@ DB_BACKUP_KEEP=7
|
|||||||
# Minutes between repeat health-fail alerts from jobs-worker (default 15).
|
# Minutes between repeat health-fail alerts from jobs-worker (default 15).
|
||||||
HEALTH_ALERT_COOLDOWN_MIN=15
|
HEALTH_ALERT_COOLDOWN_MIN=15
|
||||||
|
|
||||||
|
# --- FURNITURE ASSETS ---
|
||||||
|
# Extension the local Nitro client requests for furniture bundles. The CMS
|
||||||
|
# writes new/imported/repaired bundles with this extension and prefers it when
|
||||||
|
# resolving existing ones (the other stays readable as a legacy fallback).
|
||||||
|
# ".hab" (default) or ".nitro".
|
||||||
|
FURNI_BUNDLE_EXT=.hab
|
||||||
|
|
||||||
# --- RCON (Low Latency Loop) ---
|
# --- RCON (Low Latency Loop) ---
|
||||||
RCON_HOST=127.0.0.1
|
RCON_HOST=127.0.0.1
|
||||||
RCON_PORT=3003
|
RCON_PORT=3003
|
||||||
|
|||||||
+84
-36
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,44 @@
|
|||||||
|
name: Renovate
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "17 4 * * 1-5"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
debug:
|
||||||
|
description: "Debug-loglevel"
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
# Niet overlappen met zichzelf; een tweede start wacht tot de eerste klaar is.
|
||||||
|
concurrency:
|
||||||
|
group: renovate
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
RENOVATE_VERSION: 44.164.10 # renovate: datasource=npm depName=renovate
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
renovate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- name: Run Renovate
|
||||||
|
env:
|
||||||
|
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
|
LOG_LEVEL: ${{ github.event.inputs.debug == 'true' && 'debug' || 'info' }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
--memory=6g \
|
||||||
|
--memory-swap=6g \
|
||||||
|
-e RENOVATE_PLATFORM=gitea \
|
||||||
|
-e RENOVATE_ENDPOINT=https://gitlab.epicnabbo.nl/ \
|
||||||
|
-e RENOVATE_TOKEN \
|
||||||
|
-e RENOVATE_REPOSITORIES='["remco/EpicNext-Cms"]' \
|
||||||
|
-e RENOVATE_AUTODISCOVER=false \
|
||||||
|
-e RENOVATE_BINARY_SOURCE=install \
|
||||||
|
-e LOG_LEVEL \
|
||||||
|
-e RENOVATE_ONBOARDING=true \
|
||||||
|
-e RENOVATE_REQUIRE_CONFIG=required \
|
||||||
|
"renovate/renovate:${RENOVATE_VERSION}"
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
name: Gitea Actions Runner Test
|
|
||||||
on: [push]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test-job:
|
|
||||||
runs-on: self-hosted
|
|
||||||
steps:
|
|
||||||
- name: Check Host Environment
|
|
||||||
run: |
|
|
||||||
echo "The v3.5.0 runner works!"
|
|
||||||
echo "Current date/time on VPS:"
|
|
||||||
date
|
|
||||||
echo "Running kernel version:"
|
|
||||||
uname -a
|
|
||||||
|
|
||||||
- name: Test Bash Command
|
|
||||||
run: echo "Greetings from the host runner!"
|
|
||||||
@@ -15,6 +15,8 @@ public/cache/
|
|||||||
|
|
||||||
prod.log
|
prod.log
|
||||||
*.log
|
*.log
|
||||||
|
# Imager health-alert state (last-known up/down + timestamp)
|
||||||
|
logs/imager-health.state
|
||||||
|
|
||||||
# Database backups
|
# Database backups
|
||||||
db_backup_*.sql
|
db_backup_*.sql
|
||||||
@@ -55,6 +57,9 @@ blob-report/
|
|||||||
build-reports/
|
build-reports/
|
||||||
!/docs/performance-budgets.md
|
!/docs/performance-budgets.md
|
||||||
|
|
||||||
|
# Local buildcache (docker compose cache_from/cache_to)
|
||||||
|
.docker-cache/
|
||||||
|
|
||||||
# One-off local snapshots (o.a. catalog-integrity/pre-fix.json): runtime-werk,
|
# One-off local snapshots (o.a. catalog-integrity/pre-fix.json): runtime-werk,
|
||||||
# geen bron. Per map opgeslagen om een incident terug te kunnen lezen.
|
# geen bron. Per map opgeslagen om een incident terug te kunnen lezen.
|
||||||
backups/
|
backups/
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
image: node:26
|
|
||||||
|
|
||||||
# Runner containers have no systemd, so scripts/with-memory-cap.sh cannot
|
|
||||||
# enforce an RSS cap there and correctly refuses to run unbounded. These
|
|
||||||
# builds are already isolated inside their own runner container (not the
|
|
||||||
# host) and use the webpack builder; opt out explicitly on purpose.
|
|
||||||
variables:
|
|
||||||
CMS_MEMORY_CAP_BACKEND: "none"
|
|
||||||
|
|
||||||
stages:
|
|
||||||
- test
|
|
||||||
- build
|
|
||||||
|
|
||||||
cache:
|
|
||||||
paths:
|
|
||||||
- node_modules/
|
|
||||||
|
|
||||||
before_script:
|
|
||||||
- corepack enable
|
|
||||||
- pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
lint:
|
|
||||||
stage: test
|
|
||||||
script:
|
|
||||||
- pnpm run lint
|
|
||||||
|
|
||||||
typecheck:
|
|
||||||
stage: test
|
|
||||||
script:
|
|
||||||
- pnpm run typecheck
|
|
||||||
|
|
||||||
test:
|
|
||||||
stage: test
|
|
||||||
script:
|
|
||||||
- pnpm run test
|
|
||||||
|
|
||||||
build:
|
|
||||||
stage: build
|
|
||||||
script:
|
|
||||||
- pnpm run build
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"WARNING": "This file is automatically generated by Gitea Runner. Do not edit it manually unless you know what you are doing. Removing this file will cause Gitea Runner to re-register as a new runner.",
|
||||||
|
"id": 22,
|
||||||
|
"uuid": "ffb52201-e18e-4a0f-96e9-cf8a0b849365",
|
||||||
|
"name": "v5-runner",
|
||||||
|
"token": "0484b5a82d3bda9a62972a6d2646ca7cb90bc4e2",
|
||||||
|
"address": "https://gitlab.epicnabbo.nl/",
|
||||||
|
"labels": [
|
||||||
|
"self-hosted:host",
|
||||||
|
"ubuntu-latest:docker://node:18-bullseye",
|
||||||
|
"debian-latest:docker://debian:bullseye-slim"
|
||||||
|
],
|
||||||
|
"ephemeral": false
|
||||||
|
}
|
||||||
Whitespace-only changes.
+1
-1
@@ -27,7 +27,7 @@ This source change alone does not configure or deploy the production service.
|
|||||||
## Exported content
|
## Exported content
|
||||||
|
|
||||||
| Source | Repository destination |
|
| Source | Repository destination |
|
||||||
| --- | --- |
|
| ------------------------------------------------------------------------ | --------------------------------------------------- |
|
||||||
| Configured Nitro bundles, including furniture, figures, effects and pets | `Gamedata/bundled` |
|
| Configured Nitro bundles, including furniture, figures, effects and pets | `Gamedata/bundled` |
|
||||||
| Configured furni icons | `Gamedata/icons` |
|
| Configured furni icons | `Gamedata/icons` |
|
||||||
| Existing badge/catalog images | `Gamedata/c_images` |
|
| Existing badge/catalog images | `Gamedata/c_images` |
|
||||||
|
|||||||
+28
-6
@@ -1,23 +1,45 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
FROM node:26.10.0-alpine AS migrations
|
FROM node:26.12.1-alpine AS migrations
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
# Installeer git, bash en pnpm v12. bash is nodig voor
|
# Installeer git, bash en corepack. bash is nodig voor
|
||||||
# scripts/with-memory-cap.sh (gebruikt bashisme zoals arrays en BASH_REMATCH);
|
# scripts/with-memory-cap.sh (gebruikt bashisme zoals arrays en BASH_REMATCH);
|
||||||
# Alpine levert geen bash mee.
|
# Alpine levert geen bash mee.
|
||||||
|
#
|
||||||
|
# pnpm komt niet meer uit een losgepind npm-pakket, maar uit corepack: die
|
||||||
|
# leest de versie én de sha512-hash uit de `packageManager`-pin van
|
||||||
|
# package.json. Daarmee kan de image nooit uit sync raken met het lockfile
|
||||||
|
# (vroeger stond hier [email protected] terwijl package.json 12.11.2 voorschreef)
|
||||||
|
# en wordt de gedownloade pnpm-binary ook in de runtime-image bewaard.
|
||||||
RUN --mount=type=cache,target=/var/cache/apk \
|
RUN --mount=type=cache,target=/var/cache/apk \
|
||||||
|
--mount=type=cache,target=/root/.npm \
|
||||||
apk add --no-cache git bash \
|
apk add --no-cache git bash \
|
||||||
&& npm install -g [email protected]
|
&& npm install -g [email protected] \
|
||||||
|
&& corepack enable pnpm
|
||||||
|
|
||||||
|
# corepack bewaart de gedownloade pnpm buiten de cache-mount, zodat de runtime
|
||||||
|
# (`docker run --entrypoint pnpm <migrations-image> db:migrate`) pnpm ook
|
||||||
|
# zonder netwerk kan gebruiken.
|
||||||
|
ENV COREPACK_HOME="/usr/local/share/corepack"
|
||||||
|
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||||
|
|
||||||
# Stel het PATH zo in dat Alpine pnpm gegarandeerd overal herkent
|
# Stel het PATH zo in dat Alpine pnpm gegarandeerd overal herkent
|
||||||
ENV PNPM_HOME="/usr/local/share/pnpm"
|
ENV PNPM_HOME="/usr/local/share/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:/usr/local/bin:$PATH"
|
ENV PATH="$PNPM_HOME:/usr/local/bin:$PATH"
|
||||||
|
|
||||||
|
# Alleen package.json: de `packageManager`-pin bepaalt welke pnpm corepack
|
||||||
|
# installeert. Deze laag blijft gecachet wanneer alleen de lockfile verandert.
|
||||||
|
COPY package.json ./
|
||||||
|
RUN corepack install
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
|
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
|
||||||
|
|
||||||
# Voer de installatie uit met de pnpm v12 store cache-mount
|
# Voer de installatie uit met de pnpm v12 store cache-mount. De mount moet op
|
||||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
# de store staan die pnpm ook echt gebruikt ($PNPM_HOME/store/v11); met het
|
||||||
|
# oude pad (/root/.local/share/pnpm/store) cache'te de mount niets en
|
||||||
|
# downloadde elke build alle packages opnieuw.
|
||||||
|
RUN --mount=type=cache,target=/usr/local/share/pnpm/store \
|
||||||
pnpm install --frozen-lockfile --ignore-scripts
|
pnpm install --frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -52,7 +74,7 @@ RUN --mount=type=cache,target=/app/.next/cache \
|
|||||||
pnpm run build \
|
pnpm run build \
|
||||||
&& PERFORMANCE_COMMIT_SHA="$NEXT_DEPLOYMENT_ID" node scripts/performance-report.mjs --output-dir build-reports
|
&& PERFORMANCE_COMMIT_SHA="$NEXT_DEPLOYMENT_ID" node scripts/performance-report.mjs --output-dir build-reports
|
||||||
|
|
||||||
FROM node:26.10.0-alpine AS runner
|
FROM node:26.12.1-alpine AS runner
|
||||||
ARG NEXT_DEPLOYMENT_ID="unknown"
|
ARG NEXT_DEPLOYMENT_ID="unknown"
|
||||||
LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID"
|
LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID"
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
},
|
},
|
||||||
"style": {
|
"style": {
|
||||||
"noDescendingSpecificity": "off"
|
"noDescendingSpecificity": "off"
|
||||||
|
},
|
||||||
|
"correctness": {
|
||||||
|
"noComponentHookFactories": "off",
|
||||||
|
"usePlaywrightValidDescribeCallback": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,4 +6,10 @@ services:
|
|||||||
HOSTNAME: 127.0.0.1
|
HOSTNAME: 127.0.0.1
|
||||||
PORT: "3002"
|
PORT: "3002"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"fetch('http://127.0.0.1:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
|
||||||
|
]
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -39,6 +39,13 @@ http {
|
|||||||
server_tokens off;
|
server_tokens off;
|
||||||
keepalive_timeout 30s;
|
keepalive_timeout 30s;
|
||||||
|
|
||||||
|
# Gamedata/icons are served from disk by alias locations. Caching the
|
||||||
|
# stat/open results avoids a syscall storm on every room load.
|
||||||
|
open_file_cache max=10000 inactive=30s;
|
||||||
|
open_file_cache_valid 60s;
|
||||||
|
open_file_cache_min_uses 2;
|
||||||
|
open_file_cache_errors off;
|
||||||
|
|
||||||
client_max_body_size 64m;
|
client_max_body_size 64m;
|
||||||
client_body_buffer_size 16k;
|
client_body_buffer_size 16k;
|
||||||
client_header_buffer_size 1k;
|
client_header_buffer_size 1k;
|
||||||
|
|||||||
+46
-17
@@ -1,5 +1,20 @@
|
|||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Next.js CMS — blue/green
|
# Next.js CMS — single slot on port 3002
|
||||||
|
#
|
||||||
|
# ── Eigenaar van poort 3002 ─────────────────────────────────────────────────
|
||||||
|
# Deze repo kent twee deploy-paden die allebei op poort 3002 willen:
|
||||||
|
# * `scripts/ci-deploy.sh` (blue/green, docker run) — de eigenaar op een
|
||||||
|
# CI-host. Elke push naar main bouwt daar een nieuwe image, draait de
|
||||||
|
# migrations en zet nginx pas om na health + e2e + imaging.
|
||||||
|
# * deze compose-`cms`-service — bedoeld voor een LOSSE host zonder CI.
|
||||||
|
#
|
||||||
|
# Ze kunnen niet naast elkaar draaien: wie op 3002 zit, blokkeert de ander met
|
||||||
|
# `EADDRINUSE`. Daarom staat `cms` achter een profile. Een bloot
|
||||||
|
# `docker compose up -d` start dus alleen byparr en kan nooit de live CI-release
|
||||||
|
# van de site drukken. De service start alleen als je hem expliciet noemt
|
||||||
|
# (`docker compose up -d cms`, `docker compose build cms`, ...): Compose schakelt
|
||||||
|
# het profile van een expliciet genoemde service vanzelf in. Op een CI-host wil
|
||||||
|
# je dat dus juist NOOIT doen.
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
x-cms: &cms
|
x-cms: &cms
|
||||||
image: epicnext-cms:${CMS_RELEASE:-local}
|
image: epicnext-cms:${CMS_RELEASE:-local}
|
||||||
@@ -14,8 +29,20 @@ x-cms: &cms
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}
|
NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}
|
||||||
# Runtime host networking IS required: blue/green needs per-release host ports
|
# Lokale buildcache op schijf. `scripts/docker-prune.sh` brengt de
|
||||||
# (3002/3003) and nginx reaches the slot over 127.0.0.1.
|
# daemon-cache na elke deploy terug tot 4GB, waardoor de lagen van de
|
||||||
|
# vorige release verdwijnen. Deze export overleeft die prune: een rebuild
|
||||||
|
# na een lockfile-wijziging hergebruikt dan de al gedownloade packages
|
||||||
|
# (pnpm store) in plaats van ze opnieuw te downloaden.
|
||||||
|
# De map ligt binnen de buildcontext, zodat buildx een ontbrekende
|
||||||
|
# cachemap stilzwijgend negeert i.p.v. de build te laten falen; zowel
|
||||||
|
# .dockerignore als .gitignore sluiten de map uit.
|
||||||
|
cache_from:
|
||||||
|
- type=local,src=.docker-cache/cms
|
||||||
|
cache_to:
|
||||||
|
- type=local,dest=.docker-cache/cms,mode=max
|
||||||
|
# Runtime host networking IS required: nginx reaches the CMS over 127.0.0.1
|
||||||
|
# on the single port that is actually served (3002).
|
||||||
network_mode: host
|
network_mode: host
|
||||||
stop_grace_period: 15s
|
stop_grace_period: 15s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -28,13 +55,17 @@ x-cms: &cms
|
|||||||
- /var/www/Gamedata:/var/www/Gamedata
|
- /var/www/Gamedata:/var/www/Gamedata
|
||||||
|
|
||||||
# ── Resource limits ──
|
# ── Resource limits ──
|
||||||
mem_limit: 6g
|
mem_limit: 4g
|
||||||
memswap_limit: 7g
|
memswap_limit: 5g
|
||||||
cpus: 2.0
|
|
||||||
pids_limit: 512
|
|
||||||
|
|
||||||
healthcheck:
|
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))"]
|
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
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -43,21 +74,19 @@ x-cms: &cms
|
|||||||
services:
|
services:
|
||||||
cms:
|
cms:
|
||||||
<<: *cms
|
<<: *cms
|
||||||
|
# Alleen starten als je deze host bewust compose-only beheert.
|
||||||
|
profiles: ["cms"]
|
||||||
container_name: epicnext-cms
|
container_name: epicnext-cms
|
||||||
environment:
|
environment:
|
||||||
- HOSTNAME=0.0.0.0
|
- HOSTNAME=0.0.0.0
|
||||||
- PORT=3002
|
- PORT=3002
|
||||||
|
|
||||||
cms-green:
|
|
||||||
<<: *cms
|
|
||||||
container_name: epicnext-cms-green
|
|
||||||
profiles: ["green"]
|
|
||||||
environment:
|
|
||||||
- HOSTNAME=0.0.0.0
|
|
||||||
- PORT=3003
|
|
||||||
|
|
||||||
byparr:
|
byparr:
|
||||||
image: ghcr.io/thephaseless/byparr:latest
|
image: ghcr.io/thephaseless/byparr:v3.0.4
|
||||||
|
# `latest` is een drijvende tag. Zonder pull_policy haalt compose de
|
||||||
|
# bestaande image nooit opnieuw op en blijft byparr hangen op de eerste
|
||||||
|
# versie die ooit werd binnengehaald.
|
||||||
|
pull_policy: always
|
||||||
container_name: byparr
|
container_name: byparr
|
||||||
network_mode: host
|
network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## Operator changes
|
## Operator changes
|
||||||
|
|
||||||
| Area | Behavior and location |
|
| Area | Behavior and location |
|
||||||
| --- | --- |
|
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| Release | Deployment checks HTTP health, release identity and Chromium pages before marking the running image verified. Registry publication reuses that verified digest on the shared runner; independent hosts build and verify their own image. |
|
| Release | Deployment checks HTTP health, release identity and Chromium pages before marking the running image verified. Registry publication reuses that verified digest on the shared runner; independent hosts build and verify their own image. |
|
||||||
| Shared HK | Dialogs scroll within the available viewport. Table column preferences persist per page in the current browser session; filters already remain in the URL. No favorites added. |
|
| Shared HK | Dialogs scroll within the available viewport. Table column preferences persist per page in the current browser session; filters already remain in the URL. No favorites added. |
|
||||||
| Catalog Studio | Dedicated detail drawer and five separate completeness states: SQL, offers, furnidata, icon and Nitro. Sprite/type conflicts are consistently excluded from import and linked to audit. Missing source files are never represented as available. |
|
| Catalog Studio | Dedicated detail drawer and five separate completeness states: SQL, offers, furnidata, icon and Nitro. Sprite/type conflicts are consistently excluded from import and linked to audit. Missing source files are never represented as available. |
|
||||||
@@ -37,7 +37,6 @@
|
|||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
Local verification on 2026-09-09:
|
Local verification on 2026-09-09:
|
||||||
|
|
||||||
- Production build succeeded with fixture configuration and an intentionally unavailable database. Build-time fallback logs are expected in this check.
|
- Production build succeeded with fixture configuration and an intentionally unavailable database. Build-time fallback logs are expected in this check.
|
||||||
- Full Vitest run: 254 files passed, 4 skipped; 1,466 tests passed, 6 skipped. Coverage thresholds passed (19.89% lines); this does not imply exhaustive coverage.
|
- Full Vitest run: 254 files passed, 4 skipped; 1,466 tests passed, 6 skipped. Coverage thresholds passed (19.89% lines); this does not imply exhaustive coverage.
|
||||||
- Global Biome rules/import checks passed across 1,328 files; modified source/locales were formatted separately to avoid unrelated Windows line-ending changes.
|
- Global Biome rules/import checks passed across 1,328 files; modified source/locales were formatted separately to avoid unrelated Windows line-ending changes.
|
||||||
|
|||||||
@@ -56,9 +56,18 @@ Lenis has been removed; the public site now uses native scrolling. Other used
|
|||||||
runtime libraries remain. Vitest and its coverage provider are upgraded together;
|
runtime libraries remain. Vitest and its coverage provider are upgraded together;
|
||||||
`clearMocks: false` preserves initialization-time permission contract assertions.
|
`clearMocks: false` preserves initialization-time permission contract assertions.
|
||||||
|
|
||||||
Dependency updates are manual: Renovate has been removed, so there is no bot
|
Dependency updates run through self-hosted Renovate: `renovate.json` plus
|
||||||
opening upgrade pull requests. Node/pnpm upgrades remain coordinated with
|
`.gitea/workflows/renovate.yaml` run the pinned `renovate/renovate` image on the
|
||||||
Docker and the runner toolchain.
|
host runner every weekday at 04:17 (and via `workflow_dispatch`). Minor and patch
|
||||||
|
updates are grouped into one PR and automerged only after CI is green
|
||||||
|
(`platformAutomerge: false`, so Renovate waits on the checks itself); major
|
||||||
|
updates require approval on the dependency dashboard. The job authenticates with
|
||||||
|
the `renovate-bot` account token stored as the repo Actions secret
|
||||||
|
`RENOVATE_TOKEN`; rotation only requires overwriting that secret (the bot
|
||||||
|
password lives in `/root/.gitea-renovate-bot.env` on the host). `minimumReleaseAge`
|
||||||
|
is 3 days in `renovate.json` and 60 hours in `pnpm-workspace.yaml`, so neither the
|
||||||
|
installer nor the bot picks up a fresh release on day zero. Node/pnpm upgrades
|
||||||
|
remain coordinated with Docker and the runner toolchain.
|
||||||
Obsolete global overrides were removed; a scoped esbuild override remains because
|
Obsolete global overrides were removed; a scoped esbuild override remains because
|
||||||
Drizzle Kit's loader still resolves a vulnerable legacy development-server build.
|
Drizzle Kit's loader still resolves a vulnerable legacy development-server build.
|
||||||
The two deprecated esbuild-kit packages remain upstream dependencies of Drizzle
|
The two deprecated esbuild-kit packages remain upstream dependencies of Drizzle
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ The direct template intentionally records the CDN/edge socket address when place
|
|||||||
|
|
||||||
### Local verification and deployment boundary
|
### Local verification and deployment boundary
|
||||||
|
|
||||||
`pnpm exec vitest run --coverage.enabled=false scripts/proxy-config.test.mjs` runs the installed Docker Compose CLI against a disposable clone configuration, without contacting Docker Engine, building images or reading the real `.env`. It verifies selection through `.env`, loopback/port override precedence, the IPv4 healthcheck and preservation of mounts, release selection and host networking. It explicitly skips when Compose is unavailable. This is not a container-start or network-isolation test.
|
`pnpm exec vitest run --coverage.enabled=false scripts/proxy-config.test.mjs` runs the installed Docker Compose CLI against a disposable clone configuration, without contacting Docker Engine, building images or reading the real `.env`. It verifies selection through `.env`, loopback/port override precedence, the IPv4 healthcheck and preservation of mounts, release selection and host networking. The `cms` service is behind the `cms` profile (so a bare `docker compose up -d` cannot claim port 3002 on a CI-managed host), so the test renders the configuration with `--profile cms`. It explicitly skips when Compose is unavailable. This is not a container-start or network-isolation test.
|
||||||
|
|
||||||
`pnpm test:integration` additionally starts disposable Nginx containers from the actual templates, supplies a temporary test certificate, and sends real HTTPS requests with forged identity headers. It checks direct-mode replacement even with an inherited real-IP rule, rejection of untrusted peers, and acceptance through an explicitly trusted peer. This requires Docker Engine and the OpenSSL CLI and does not read deployment credentials. The templates must still pass `nginx -t` on the intended host after its hostname/certificate substitution, then the listener and trusted-header checks above; the disposable fixture cannot certify that host or its firewall.
|
`pnpm test:integration` additionally starts disposable Nginx containers from the actual templates, supplies a temporary test certificate, and sends real HTTPS requests with forged identity headers. It checks direct-mode replacement even with an inherited real-IP rule, rejection of untrusted peers, and acceptance through an explicitly trusted peer. This requires Docker Engine and the OpenSSL CLI and does not read deployment credentials. The templates must still pass `nginx -t` on the intended host after its hostname/certificate substitution, then the listener and trusted-header checks above; the disposable fixture cannot certify that host or its firewall.
|
||||||
|
|
||||||
|
|||||||
+15
-15
@@ -1,4 +1,4 @@
|
|||||||
# Informational route JavaScript budgets
|
# Route JavaScript budgets (enforced)
|
||||||
|
|
||||||
Run after the existing production build; no second build or server is needed:
|
Run after the existing production build; no second build or server is needed:
|
||||||
|
|
||||||
@@ -14,12 +14,11 @@ For each configured App Router route, resolve its exact app path using `app-path
|
|||||||
|
|
||||||
The **initial entry envelope** is the union of route bootstrap `rootMainFilesTree[appPath]` (or `rootMainFiles`) and every client chunk that route's client-reference manifest lists. This includes layout, page and boundary/loading entries.
|
The **initial entry envelope** is the union of route bootstrap `rootMainFilesTree[appPath]` (or `rootMainFiles`) and every client chunk that route's client-reference manifest lists. This includes layout, page and boundary/loading entries.
|
||||||
|
|
||||||
The manifest exposes those chunks differently per bundler. Turbopack emits an explicit per-segment `entryJSFiles` map; webpack emits no such field and records chunks only per client module, as `clientModules[*].chunks`, in `[chunkId, fileName, chunkId, fileName, …]` order. The report reads `entryJSFiles` when present and otherwise derives the same envelope from `clientModules`, which is the source Next's own `static-routes-info` uses. Numeric chunk ids are skipped; a malformed chunk *path* still fails rather than being dropped, so a broken manifest cannot quietly under-report a route.
|
The manifest exposes those chunks differently per bundler. Turbopack emits an explicit per-segment `entryJSFiles` map; webpack emits no such field and records chunks only per client module, as `clientModules[*].chunks`, in `[chunkId, fileName, chunkId, fileName, …]` order. The report reads `entryJSFiles` when present and otherwise derives the same envelope from `clientModules`, which is the source Next's own `static-routes-info` uses. Numeric chunk ids are skipped; a malformed chunk _path_ still fails rather than being dropped, so a broken manifest cannot quietly under-report a route.
|
||||||
|
|
||||||
> The build runs webpack (`next build --webpack`), so the `clientModules` path is the live one. An earlier revision only read `entryJSFiles`, and after the switch to webpack every route reported `unavailable` while the command still exited 0 — the budgets were silently not being measured. When a bundler switch changes the manifest layout again, re-check this section rather than trusting a clean exit.
|
> The build runs webpack (`next build --webpack`), so the `clientModules` path is the live one. An earlier revision only read `entryJSFiles`, and after the switch to webpack every route reported `unavailable` while the command still exited 0 — the budgets were silently not being measured. When a bundler switch changes the manifest layout again, re-check this section rather than trusting a clean exit.
|
||||||
|
|
||||||
The definition follows the data exposed by the installed Next 16.3.8 build and the `getLinkAndScriptTags` / `getRequiredScripts` renderer helpers; it is deliberately a build-artifact envelope, not a browser network trace. Conditional rendering, redirects, streaming and browser caches can change actual requests.
|
The definition follows the data exposed by the installed Next 16.3.8 build and the `getLinkAndScriptTags` / `getRequiredScripts` renderer helpers; it is deliberately a build-artifact envelope, not a browser network trace. Conditional rendering, redirects, streaming and browser caches can change actual requests.
|
||||||
|
|
||||||
- Raw bytes are filesystem byte lengths of unique JavaScript assets in that envelope.
|
- Raw bytes are filesystem byte lengths of unique JavaScript assets in that envelope.
|
||||||
- Gzip bytes are the **sum of independent gzip level 9 compressions** of those files using the recorded Node/zlib runtime. They are not gzip of concatenated source, nor observed CDN transfer sizes.
|
- Gzip bytes are the **sum of independent gzip level 9 compressions** of those files using the recorded Node/zlib runtime. They are not gzip of concatenated source, nor observed CDN transfer sizes.
|
||||||
- Deployment query strings and `/_next/` prefixes are normalized before deduplication. Shared files count once per route; each route is measured independently, with no misleading cross-route total.
|
- Deployment query strings and `/_next/` prefixes are normalized before deduplication. Shared files count once per route; each route is measured independently, with no misleading cross-route total.
|
||||||
@@ -28,20 +27,21 @@ The definition follows the data exposed by the installed Next 16.3.8 build and t
|
|||||||
|
|
||||||
## Initial limits
|
## Initial limits
|
||||||
|
|
||||||
The first limits are **baseline bytes × 1.15, rounded upward to the next 10 KiB (10,240 bytes)** independently for raw and gzip. They are provisional size alerts, not validated speed targets. Baseline: local production build `build-TfctsWXpff2fKS`, Next 16.3.4 **Turbopack**; its source commit was not inferred.
|
The limits are **baseline bytes × 1.15, rounded upward to the next 10 KiB (10,240 bytes)** independently for raw and gzip. They are size guardrails, not validated speed targets.
|
||||||
|
|
||||||
The production build now runs webpack, so the numbers it reports are not directly comparable to the baseline below. Re-measured on the current webpack build the routes land at `/me` 786138/247738, `/news` 781601/245672, `/events` 782011/245923, `/search` 783262/246578, `/admin/catalog` 1172089/370843, `/admin/studio/furni` 1374128/440546 (raw/gzip). All remain inside the limits below, but `/admin/studio/furni` sits at ~98% of its gzip limit, so the next dependency added to that route will trip it. Recalibrate the table and `scripts/performance-budgets.json` together if the intent is to reset the baseline on webpack.
|
Baselines were last recalibrated against a local webpack production build (Next 16.4.1, `next build --webpack`) and cross-checked against the report the Docker image build emits: `/me` 621460/195411, `/news` 616712/193199, `/events` 617121/193450, `/search` 618536/194216, `/admin/catalog` 1049094/330369, `/admin/studio/furni` 1397566/447765 (raw/gzip). The earlier Turbopack numbers (previous revision) were **not** comparable and were dropped.
|
||||||
|
|
||||||
|
The image build compiles the app, then runs `scripts/performance-report.mjs` in the same `RUN` layer (`Dockerfile:48-53`). In `mode: enforce` a failing route therefore fails the image build, which blocks both the PR `preflight` job and the `deploy` job. Recalibrate the table and `scripts/performance-budgets.json` **together** in the same commit whenever the envelope legitimately grows (new route dependency, Next upgrade).
|
||||||
|
|
||||||
| Route | Baseline raw bytes | Baseline gzip bytes | Raw limit | Gzip limit |
|
| Route | Baseline raw bytes | Baseline gzip bytes | Raw limit | Gzip limit |
|
||||||
| --- | ---: | ---: | ---: | ---: |
|
| --------------------- | -----------------: | ------------------: | --------: | ---------: |
|
||||||
| `/me` | 767156 | 238571 | 890880 | 276480 |
|
| `/me` | 621460 | 195411 | 716800 | 225280 |
|
||||||
| `/news` | 765367 | 237599 | 880640 | 276480 |
|
| `/news` | 616712 | 193199 | 716800 | 225280 |
|
||||||
| `/events` | 765851 | 237964 | 890880 | 276480 |
|
| `/events` | 617121 | 193450 | 716800 | 225280 |
|
||||||
| `/search` | 765851 | 237964 | 890880 | 276480 |
|
| `/search` | 618536 | 194216 | 716800 | 225280 |
|
||||||
| `/admin/catalog` | 1654898 | 492619 | 1904640 | 573440 |
|
| `/admin/catalog` | 1049094 | 330369 | 1208320 | 389120 |
|
||||||
| `/admin/studio/furni` | 1241312 | 391541 | 1433600 | 450560 |
|
| `/admin/studio/furni` | 1397566 | 447765 | 1607680 | 522240 |
|
||||||
|
|
||||||
Configured limits are positive integer bytes; `null` explicitly means observe-only. `scripts/performance-budgets.json` remains `mode: informational`. Exceeding a limit produces `over-budget` and a warning, with exit code 0. Missing production `BUILD_ID`, unsupported manifests, missing routes or missing referenced assets produce `unavailable` with a reason and **no partial/zero total**, also exit code 0. Malformed budget configuration or an unwritable output directory fails the command. This keeps initial CI reporting non-blocking while preventing invalid configuration from quietly disabling limits.
|
Configured limits are positive integer bytes; `null` explicitly means observe-only. `scripts/performance-budgets.json` runs `mode: enforce`: an `over-budget` route prints `Performance budgets enforced: <route> (over-budget)` and exits non-zero. Missing production `BUILD_ID`, unsupported manifests, missing routes or missing referenced assets produce `unavailable` with a reason and **no partial/zero total**; under `enforce` an `unavailable` route also fails the command, so a bundler change that breaks measurement cannot pass quietly. Malformed budget configuration or an unwritable output directory always fails the command. Set `mode` back to `informational` only while recalibrating, never as a permanent state.
|
||||||
|
|
||||||
Synthetic tests cover shared-chunk deduplication, exact byte/gzip calculations, route bootstrap selection, missing data, safe parsing and CLI exit behavior. Run `pnpm exec vitest run --coverage.enabled=false scripts/performance-report.test.mjs`.
|
|
||||||
|
|
||||||
|
Synthetic tests cover shared-chunk deduplication, exact byte/gzip calculations, route bootstrap selection, missing data, safe parsing and CLI exit behavior in both modes. Run `pnpm exec vitest run --coverage.enabled=false scripts/performance-report.test.mjs`.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
CREATE TABLE `website_daily_reward_claims` (
|
||||||
|
`id` bigint unsigned AUTO_INCREMENT NOT NULL,
|
||||||
|
`user_id` int NOT NULL,
|
||||||
|
`claim_date` date NOT NULL,
|
||||||
|
`streak` int unsigned NOT NULL DEFAULT 1,
|
||||||
|
`reward_day` int unsigned NOT NULL DEFAULT 1,
|
||||||
|
`currency` varchar(20) NOT NULL DEFAULT 'credits',
|
||||||
|
`amount` int NOT NULL DEFAULT 0,
|
||||||
|
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
CONSTRAINT `website_daily_reward_claims_id` PRIMARY KEY(`id`),
|
||||||
|
CONSTRAINT `daily_claim_user_date_uk` UNIQUE(`user_id`,`claim_date`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `website_daily_rewards` (
|
||||||
|
`id` int unsigned AUTO_INCREMENT NOT NULL,
|
||||||
|
`day` int unsigned NOT NULL DEFAULT 1,
|
||||||
|
`currency` varchar(20) NOT NULL DEFAULT 'credits',
|
||||||
|
`amount` int NOT NULL DEFAULT 0,
|
||||||
|
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
CONSTRAINT `website_daily_rewards_id` PRIMARY KEY(`id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `admin_audit_log` MODIFY COLUMN `before` mediumtext;--> statement-breakpoint
|
||||||
|
ALTER TABLE `admin_audit_log` MODIFY COLUMN `after` mediumtext;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -8,6 +8,13 @@
|
|||||||
"when": 1788791201804,
|
"when": 1788791201804,
|
||||||
"tag": "0000_premium_spirit",
|
"tag": "0000_premium_spirit",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1791828901011,
|
||||||
|
"tag": "0001_breezy_maestro",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { expectNoApplicationError } from "./helpers";
|
||||||
|
|
||||||
|
// Unauthenticated requests to private routes must bounce to the login form —
|
||||||
|
// never render private content and never 500. Two guard paths exist: the edge
|
||||||
|
// proxy rewrites `/admin/*` to `/login?from=…` (`src/proxy.ts`), while the
|
||||||
|
// member pages and the mod console redirect from their layouts with a bare
|
||||||
|
// `/login`.
|
||||||
|
|
||||||
|
// Routes where the proxy preserves the requested destination.
|
||||||
|
const PROXY_ROUTES = ["/admin", "/admin/settings"] as const;
|
||||||
|
// Routes guarded by a page/layout `redirect("/login")` with no destination.
|
||||||
|
const LAYOUT_ROUTES = [
|
||||||
|
"/settings",
|
||||||
|
"/me",
|
||||||
|
"/messages",
|
||||||
|
"/notifications",
|
||||||
|
"/friends",
|
||||||
|
"/redeem",
|
||||||
|
"/mod",
|
||||||
|
"/client",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const route of PROXY_ROUTES) {
|
||||||
|
test(`${route} redirects to login and keeps the return path`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const response = await page.goto(route);
|
||||||
|
expect(response?.ok(), `GET ${route} -> ${response?.status()}`).toBe(true);
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await expect(page.locator('input[type="password"]').first()).toBeVisible();
|
||||||
|
await expectNoApplicationError(page);
|
||||||
|
|
||||||
|
const from = new URL(page.url()).searchParams.get("from");
|
||||||
|
expect(from, "login lost the return destination").toBeTruthy();
|
||||||
|
expect(new URL(from as string, "http://localhost").pathname).toBe(route);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const route of LAYOUT_ROUTES) {
|
||||||
|
test(`${route} redirects an anonymous visitor to login`, async ({ page }) => {
|
||||||
|
const response = await page.goto(route);
|
||||||
|
expect(response?.ok(), `GET ${route} -> ${response?.status()}`).toBe(true);
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await expect(page.locator('input[type="password"]').first()).toBeVisible();
|
||||||
|
await expectNoApplicationError(page);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared assertions for the read-only public e2e suite. These specs run against
|
||||||
|
* a live CMS (see `playwright.config.ts`) and must never mutate data — they
|
||||||
|
* guard the "everything loads, nothing is broken" contract that no unit test
|
||||||
|
* can see.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Fails if Next rendered a runtime/server error boundary into the page. */
|
||||||
|
export async function expectNoApplicationError(page: Page) {
|
||||||
|
await expect(page.locator("body")).not.toContainText("Application error");
|
||||||
|
await expect(page.locator("body")).not.toContainText("Internal Server Error");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every <img> that finished loading must have decoded pixels. A failed request
|
||||||
|
* (404, broken CMS media, bad avatar figure) reports `complete === true` with
|
||||||
|
* `naturalWidth === 0`, which is what this catches. Intentionally lazy images
|
||||||
|
* that are still pending (`complete === false`) are ignored.
|
||||||
|
*/
|
||||||
|
export async function expectNoBrokenImages(page: Page) {
|
||||||
|
const broken = await page.evaluate(() => {
|
||||||
|
return Array.from(document.images)
|
||||||
|
.filter((img) => {
|
||||||
|
const src = img.currentSrc || img.src;
|
||||||
|
if (!src || src.startsWith("data:")) return false;
|
||||||
|
return img.complete && img.naturalWidth === 0;
|
||||||
|
})
|
||||||
|
.map((img) => img.currentSrc || img.src);
|
||||||
|
});
|
||||||
|
expect(broken, `broken images:\n${broken.join("\n")}`).toEqual([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No horizontal scrollbar — the classic mobile layout regression. */
|
||||||
|
export async function expectNoHorizontalOverflow(page: Page) {
|
||||||
|
const overflowing = await page.evaluate(() => {
|
||||||
|
const doc = document.documentElement;
|
||||||
|
return doc.scrollWidth > window.innerWidth + 1;
|
||||||
|
});
|
||||||
|
expect(overflowing, "page scrolls horizontally").toBe(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks the page top-to-bottom so `loading="lazy"` images below the fold get a
|
||||||
|
* chance to start before the broken-image check runs.
|
||||||
|
*/
|
||||||
|
export async function scrollThroughPage(page: Page) {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const step = window.innerHeight;
|
||||||
|
for (let y = 0; y < document.body.scrollHeight; y += step) {
|
||||||
|
window.scrollTo(0, y);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||||
|
}
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Skips a spec when the CMS answered with a maintenance redirect. */
|
||||||
|
export function isMaintenanceRedirect(url: string) {
|
||||||
|
return /\/maintenance(?:$|[/?])/.test(url);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,57 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { avatarPath } from "./imaging-target";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-browser check that a real browser (chromium/firefox/webkit) can decode
|
||||||
|
* the avatar the imager produces and that it actually animates. The endpoint
|
||||||
|
* spec covers the HTTP contract; this one covers the client-side decode, where
|
||||||
|
* an unsupported image format would surface as a blank/broken avatar.
|
||||||
|
*
|
||||||
|
* Navigates straight to the image URL (a top-level navigation, which CSP
|
||||||
|
* `upgrade-insecure-requests` does not rewrite) rather than embedding it in a
|
||||||
|
* page: over a plain-HTTP host the upgrade directive would otherwise send the
|
||||||
|
* subresource request to https and break the load, without exercising anything
|
||||||
|
* about the image itself.
|
||||||
|
*
|
||||||
|
* Run across engines via `pnpm test:imaging` (playwright.imaging.config.ts).
|
||||||
|
*/
|
||||||
|
const ANIMATED = avatarPath({ img_format: "apng", effect: "14" });
|
||||||
|
const STATIC = avatarPath({ img_format: "png" });
|
||||||
|
|
||||||
|
test("the avatar decodes and animates in the browser", async ({ page }) => {
|
||||||
|
const response = await page.goto(ANIMATED);
|
||||||
|
expect(response?.status()).toBe(200);
|
||||||
|
|
||||||
|
const image = page.locator("img");
|
||||||
|
await expect(image).toBeVisible();
|
||||||
|
const decoded = await image.evaluate((element: HTMLImageElement) => ({
|
||||||
|
width: element.naturalWidth,
|
||||||
|
height: element.naturalHeight,
|
||||||
|
}));
|
||||||
|
expect(decoded.width).toBeGreaterThan(0);
|
||||||
|
expect(decoded.height).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// An animated APNG keeps producing new frames; a static/undecodable one
|
||||||
|
// yields the same pixels every time. Sampling screenshots over a second is
|
||||||
|
// enough to tell them apart without depending on exact frame timing.
|
||||||
|
const frames = new Set<string>();
|
||||||
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||||
|
frames.add((await image.screenshot()).toString("base64"));
|
||||||
|
await page.waitForTimeout(180);
|
||||||
|
}
|
||||||
|
expect(frames.size).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the static render matches its committed visual baseline", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Pins the exact pixels of a non-animated render so a renderer/asset drift
|
||||||
|
// (wrong palette, missing part, changed sizing) is caught even when the
|
||||||
|
// image still decodes. Baselines are per engine and committed under
|
||||||
|
// e2e/imaging/__screenshots__/.
|
||||||
|
const response = await page.goto(STATIC);
|
||||||
|
expect(response?.status()).toBe(200);
|
||||||
|
await expect(page.locator("img")).toHaveScreenshot("static-avatar.png", {
|
||||||
|
animations: "disabled",
|
||||||
|
});
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,78 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { countBytes, FIGURE, IS_EDGE, looksLikeImage } from "./imaging-target";
|
||||||
|
|
||||||
|
// These assertions only hold when the CMS itself answers `/imaging`. In
|
||||||
|
// production the edge proxy fronts bare `/imaging` (and `/imaging/*`) to the
|
||||||
|
// imager container, so the CMS route is shadowed; set `IMAGING_EDGE=1` to skip.
|
||||||
|
test.skip(
|
||||||
|
IS_EDGE,
|
||||||
|
"The CMS /imaging route is shadowed by the edge proxy in production",
|
||||||
|
);
|
||||||
|
|
||||||
|
const imagerPath = (params: Record<string, string> = {}) =>
|
||||||
|
`/imaging?${new URLSearchParams({ figure: FIGURE, img_format: "png", ...params })}`;
|
||||||
|
|
||||||
|
const PNG_MAGIC = Buffer.from("89504e470d0a1a0a", "hex");
|
||||||
|
|
||||||
|
test("serves the default render as a PNG", async ({ request }) => {
|
||||||
|
const response = await request.get(imagerPath());
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/png");
|
||||||
|
const body = await response.body();
|
||||||
|
expect(body.subarray(0, 8).equals(PNG_MAGIC)).toBe(true);
|
||||||
|
expect(looksLikeImage(body)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("transcodes to WebP on request", async ({ request }) => {
|
||||||
|
const response = await request.get(imagerPath({ format: "webp" }));
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/webp");
|
||||||
|
const body = await response.body();
|
||||||
|
expect(body.subarray(0, 4).toString("ascii")).toBe("RIFF");
|
||||||
|
expect(body.subarray(8, 12).toString("ascii")).toBe("WEBP");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("transcodes to AVIF on request", async ({ request }) => {
|
||||||
|
const response = await request.get(imagerPath({ format: "avif" }));
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/avif");
|
||||||
|
const body = await response.body();
|
||||||
|
expect(body.subarray(4, 12).toString("ascii")).toBe("ftypavif");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves animated APNG renders untranscoded", async ({ request }) => {
|
||||||
|
// The renderer emits effected avatars as APNG; libvips here reads only the
|
||||||
|
// first frame, so the animated original must be served as-is even when a
|
||||||
|
// transcode is requested — otherwise the avatar would stop animating.
|
||||||
|
const response = await request.get(
|
||||||
|
imagerPath({ img_format: "apng", effect: "14", format: "webp" }),
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/png");
|
||||||
|
|
||||||
|
const body = await response.body();
|
||||||
|
expect(body.includes(Buffer.from("acTL"))).toBe(true);
|
||||||
|
expect(countBytes(body, Buffer.from("fcTL"))).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("advertises long-lived edge caching", async ({ request }) => {
|
||||||
|
const response = await request.get(imagerPath());
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
const cacheControl = response.headers()["cache-control"] ?? "";
|
||||||
|
expect(cacheControl).toContain("public");
|
||||||
|
expect(cacheControl).toContain("s-maxage");
|
||||||
|
expect(cacheControl).toContain("stale-while-revalidate");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a malformed figure with 400", async ({ request }) => {
|
||||||
|
const response = await request.get(
|
||||||
|
`/imaging?figure=${encodeURIComponent("../bad")}`,
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the imager preflight advertises GET", async ({ request }) => {
|
||||||
|
const response = await request.fetch("/imaging", { method: "OPTIONS" });
|
||||||
|
expect(response.status()).toBe(204);
|
||||||
|
expect(response.headers()["access-control-allow-methods"]).toContain("GET");
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,39 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { avatarPath } from "./imaging-target";
|
||||||
|
|
||||||
|
// Lightweight size/latency budget for the avatar path. The endpoint spec checks
|
||||||
|
// correctness; this one catches a regression that keeps the image correct but
|
||||||
|
// makes it oversized or slow — e.g. losing the disk/edge cache and re-encoding
|
||||||
|
// (or re-rendering) on every hit. Budgets are deliberately loose: they should
|
||||||
|
// flag a real regression, not a cold cache or a slow CI network.
|
||||||
|
const STATIC_MAX_BYTES = 64 * 1024;
|
||||||
|
const ANIMATED_MAX_BYTES = 512 * 1024;
|
||||||
|
const WARM_MAX_MS = 3000;
|
||||||
|
|
||||||
|
test("the static avatar stays inside its byte budget", async ({ request }) => {
|
||||||
|
const response = await request.get(avatarPath({ img_format: "png" }));
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect((await response.body()).length).toBeLessThanOrEqual(STATIC_MAX_BYTES);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the animated avatar stays inside its byte budget", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const response = await request.get(
|
||||||
|
avatarPath({ img_format: "apng", effect: "14" }),
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect((await response.body()).length).toBeLessThanOrEqual(
|
||||||
|
ANIMATED_MAX_BYTES,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a warm avatar request is served quickly", async ({ request }) => {
|
||||||
|
const path = avatarPath({ img_format: "png" });
|
||||||
|
await request.get(path); // warm the disk and edge caches
|
||||||
|
const started = Date.now();
|
||||||
|
const response = await request.get(path);
|
||||||
|
const elapsed = Date.now() - started;
|
||||||
|
expect([200, 304]).toContain(response.status());
|
||||||
|
expect(elapsed).toBeLessThan(WARM_MAX_MS);
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
avatarPath,
|
||||||
|
getThroughEdge,
|
||||||
|
IMAGER_PATH,
|
||||||
|
IS_EDGE,
|
||||||
|
looksLikeImage,
|
||||||
|
} from "./imaging-target";
|
||||||
|
|
||||||
|
// Regression guard for the public topology: the edge proxy must front the
|
||||||
|
// imager container at `/imaging/*` while `/api/imaging/*` reaches the CMS.
|
||||||
|
// Mis-routing here blanks avatars for every visitor, and no unit test sees it.
|
||||||
|
// Only meaningful against the public origin, so it is skipped when the CMS is
|
||||||
|
// reached directly (set `IMAGING_EDGE=1` for the public origin).
|
||||||
|
test.skip(
|
||||||
|
!IS_EDGE,
|
||||||
|
"Public routing is only asserted against the edge-fronted origin",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("the edge serves the imager endpoint as an image", async ({ request }) => {
|
||||||
|
const response = await getThroughEdge(
|
||||||
|
request,
|
||||||
|
IMAGER_PATH({ img_format: "png" }),
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/");
|
||||||
|
expect(looksLikeImage(await response.body())).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the CMS avatar endpoint is reachable through the edge", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const response = await getThroughEdge(
|
||||||
|
request,
|
||||||
|
avatarPath({ img_format: "png" }),
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/");
|
||||||
|
expect(looksLikeImage(await response.body())).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the edge does not shadow the CMS badge endpoint", async ({ request }) => {
|
||||||
|
const response = await getThroughEdge(request, "/api/imaging/badge?code=ADM");
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()["content-type"]).toContain("image/");
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
// Caching contract for the anonymous pages. This is deterministic and stays in
|
||||||
|
// the blocking deploy gate: the perf work relies on the edge/shared cache
|
||||||
|
// (`src/proxy.ts`) — public content pages advertise a short `s-maxage`, while
|
||||||
|
// session-dependent pages (login, register, settings) must stay
|
||||||
|
// `private, no-store`. Timing budgets are advisory and live in
|
||||||
|
// `e2e/quality/performance.spec.ts`.
|
||||||
|
test("anonymous content pages advertise a shared cache", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
for (const route of ["/news", "/staff", "/shop"]) {
|
||||||
|
const response = await request.get(route);
|
||||||
|
const cacheControl = response.headers()["cache-control"] ?? "";
|
||||||
|
expect(cacheControl, `GET ${route} cache-control`).toContain("s-maxage");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("session-dependent pages stay private and uncached", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
for (const route of ["/login", "/register"]) {
|
||||||
|
const response = await request.get(route);
|
||||||
|
const cacheControl = response.headers()["cache-control"] ?? "";
|
||||||
|
expect(cacheControl, `GET ${route} cache-control`).toContain("no-store");
|
||||||
|
expect(cacheControl, `GET ${route} must not be shared`).not.toContain(
|
||||||
|
"s-maxage",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
expectNoApplicationError,
|
||||||
|
expectNoBrokenImages,
|
||||||
|
expectNoHorizontalOverflow,
|
||||||
|
isMaintenanceRedirect,
|
||||||
|
scrollThroughPage,
|
||||||
|
} from "../helpers";
|
||||||
|
|
||||||
|
// ADVISORY suite — run with `pnpm test:e2e:quality`, not part of the deploy
|
||||||
|
// gate. These checks depend on live content/upload state and user data (a
|
||||||
|
// missing upload, a legacy malformed avatar figure, a bad theme asset), so
|
||||||
|
// they are informational: they tell you the site has a real content problem,
|
||||||
|
// but they must never block a code deploy. Every assertion the CMS can
|
||||||
|
// guarantee on its own lives in the blocking specs instead.
|
||||||
|
const PUBLIC_ROUTES = [
|
||||||
|
"/",
|
||||||
|
"/news",
|
||||||
|
"/staff",
|
||||||
|
"/shop",
|
||||||
|
"/search",
|
||||||
|
"/leaderboard",
|
||||||
|
"/help",
|
||||||
|
"/radio",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const route of PUBLIC_ROUTES) {
|
||||||
|
test(`${route} loads every image and does not overflow`, async ({ page }) => {
|
||||||
|
const response = await page.goto(route);
|
||||||
|
expect(response?.ok(), `GET ${route} -> ${response?.status()}`).toBe(true);
|
||||||
|
|
||||||
|
await expect(page.locator("main").first()).toBeVisible();
|
||||||
|
await expectNoApplicationError(page);
|
||||||
|
|
||||||
|
await scrollThroughPage(page);
|
||||||
|
await expectNoBrokenImages(page);
|
||||||
|
await expectNoHorizontalOverflow(page);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the register page loads its theme/avatar images", async ({ page }) => {
|
||||||
|
const response = await page.goto("/register");
|
||||||
|
test.skip(
|
||||||
|
isMaintenanceRedirect(page.url()) || response?.status() === 404,
|
||||||
|
"register is not reachable on this deploy",
|
||||||
|
);
|
||||||
|
await scrollThroughPage(page);
|
||||||
|
await expectNoBrokenImages(page);
|
||||||
|
await expectNoHorizontalOverflow(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the homepage stays usable on a narrow viewport", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
const response = await page.goto("/");
|
||||||
|
expect(response?.ok()).toBe(true);
|
||||||
|
await expectNoApplicationError(page);
|
||||||
|
await expectNoHorizontalOverflow(page);
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
// ADVISORY suite — run with `pnpm test:e2e:quality`. Warm-page latency depends
|
||||||
|
// on the host, the edge and the shared data caches, so a slow CI hop can trip
|
||||||
|
// these budgets without anything being wrong. The cache-control *contract*
|
||||||
|
// (deterministic) lives in the blocking `e2e/performance.spec.ts`; only the
|
||||||
|
// timing budgets are advisory.
|
||||||
|
const BUDGET_MS = Number(process.env.E2E_LOAD_BUDGET_MS || 4000);
|
||||||
|
|
||||||
|
const WARM_ROUTES = ["/", "/news", "/staff", "/shop", "/login", "/register"];
|
||||||
|
|
||||||
|
for (const route of WARM_ROUTES) {
|
||||||
|
test(`${route} responds within the warm budget`, async ({ request }) => {
|
||||||
|
await request.get(route); // warm the render + data caches
|
||||||
|
const started = Date.now();
|
||||||
|
const response = await request.get(route);
|
||||||
|
const elapsed = Date.now() - started;
|
||||||
|
expect([200, 304]).toContain(response.status());
|
||||||
|
expect(
|
||||||
|
elapsed,
|
||||||
|
`GET ${route} took ${elapsed}ms (budget ${BUDGET_MS}ms)`,
|
||||||
|
).toBeLessThan(BUDGET_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { expectNoApplicationError, isMaintenanceRedirect } from "./helpers";
|
||||||
|
|
||||||
|
// The register form is the gate for every new player, so it gets a deeper
|
||||||
|
// read-only pass than the generic public-page spec. It also pins the captcha
|
||||||
|
// contract that is configured through `CAPTCHA_PROVIDER` / `TURNSTILE_*` env
|
||||||
|
// vars (see `src/lib/services/captcha.ts`): whatever provider the running CMS
|
||||||
|
// resolves, the DOM must expose exactly one widget whose sitekey is non-empty.
|
||||||
|
const CAPTCHA_SELECTOR = ".cf-turnstile, .g-recaptcha, .h-captcha";
|
||||||
|
|
||||||
|
// Optional explicit expectation for a deploy, e.g. `E2E_EXPECT_CAPTCHA=turnstile`
|
||||||
|
// or `E2E_EXPECT_CAPTCHA=none`. Left unset, the spec only checks consistency.
|
||||||
|
const EXPECT = process.env.E2E_EXPECT_CAPTCHA?.trim();
|
||||||
|
|
||||||
|
test.describe("register", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
const response = await page.goto("/register");
|
||||||
|
test.skip(
|
||||||
|
isMaintenanceRedirect(page.url()) || response?.status() === 404,
|
||||||
|
"register is not reachable on this deploy",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders every field a new player has to fill in", async ({ page }) => {
|
||||||
|
await expect(page.locator('input[name="username"]')).toBeVisible();
|
||||||
|
await expect(page.locator('input[name="mail"]')).toBeVisible();
|
||||||
|
await expect(page.locator('input[name="password"]').first()).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.locator('input[name="password_confirmation"]'),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.locator('input[name="terms"]')).toBeVisible();
|
||||||
|
await expect(page.locator('button[type="submit"]').first()).toBeVisible();
|
||||||
|
await expectNoApplicationError(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exposes exactly one captcha widget with a site key", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const widgets = page.locator(CAPTCHA_SELECTOR);
|
||||||
|
|
||||||
|
if (EXPECT === "none") {
|
||||||
|
await expect(widgets).toHaveCount(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EXPECT === "turnstile") {
|
||||||
|
await expect(page.locator(".cf-turnstile")).toHaveCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await widgets.count();
|
||||||
|
expect(
|
||||||
|
count,
|
||||||
|
`expected at most one captcha provider, found ${count}`,
|
||||||
|
).toBeLessThanOrEqual(1);
|
||||||
|
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const siteKey = await widgets.nth(index).getAttribute("data-sitekey");
|
||||||
|
expect(siteKey, "captcha widget without data-sitekey").toBeTruthy();
|
||||||
|
expect(siteKey?.trim().length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > 0 && (await page.locator(".cf-turnstile").count()) > 0) {
|
||||||
|
const script = page.locator(
|
||||||
|
'script[src*="challenges.cloudflare.com/turnstile"]',
|
||||||
|
);
|
||||||
|
await expect(script).toHaveCount(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -116,9 +116,14 @@ test("saving while recovery is pending enables recovery for subsequent edits", a
|
|||||||
test("unmount flush retains an edit before the autosave interval", async ({
|
test("unmount flush retains an edit before the autosave interval", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
// Install the fake clock before navigation so every page timer is faked,
|
||||||
|
// then jump forward to a fixed pause point. Using wall-clock `new Date()`
|
||||||
|
// for both calls raced under load: the fake clock ticks in real time
|
||||||
|
// between the calls and can pass the target ("Cannot fast-forward to the
|
||||||
|
// past"). The 60s jump only fires no-op persists on a clean form.
|
||||||
|
await page.clock.install({ time: new Date("2030-01-01T12:00:00Z") });
|
||||||
await page.goto("/admin/articles/recovery");
|
await page.goto("/admin/articles/recovery");
|
||||||
await page.clock.install();
|
await page.clock.pauseAt(new Date("2030-01-01T12:01:00Z"));
|
||||||
await page.clock.pauseAt(new Date());
|
|
||||||
await page.locator('input[name="title"]').fill("Immediate final edit");
|
await page.locator('input[name="title"]').fill("Immediate final edit");
|
||||||
await page
|
await page
|
||||||
.getByRole("button", { name: "Close editor fixture", exact: true })
|
.getByRole("button", { name: "Close editor fixture", exact: true })
|
||||||
|
|||||||
+112
-14
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,25 @@
|
|||||||
|
// Minimal `process` shim injected into the UI fixture bundle. Server-only
|
||||||
|
// modules that leak into the bundle reference the `process` global at module
|
||||||
|
// scope (release ids, paths, etc). esbuild's `inject` rewrites those free
|
||||||
|
// identifier references to import from this file so evaluation cannot crash
|
||||||
|
// in the browser.
|
||||||
|
|
||||||
|
export const process = {
|
||||||
|
env: { NODE_ENV: "production" },
|
||||||
|
platform: "linux",
|
||||||
|
arch: "x64",
|
||||||
|
version: "v20.0.0",
|
||||||
|
versions: { node: "20.0.0" },
|
||||||
|
argv: [],
|
||||||
|
cwd: () => "/",
|
||||||
|
nextTick: (fn, ...args) => setTimeout(() => fn(...args), 0),
|
||||||
|
on: () => {},
|
||||||
|
once: () => {},
|
||||||
|
emit: () => {},
|
||||||
|
exit: () => {},
|
||||||
|
hrtime: Object.assign(() => [0, 0], { bigint: () => 0n }),
|
||||||
|
memoryUsage: () => ({ heapUsed: 0, rss: 0 }),
|
||||||
|
pid: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default process;
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+70
-2
@@ -1,7 +1,47 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
import { PHASE_PRODUCTION_BUILD } from "next/constants";
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This host runs with `vm.overcommit_memory=0` and no swap, so a process that
|
||||||
|
* asks for more memory than is free gets OOM-killed by the kernel immediately.
|
||||||
|
* The killer picks its victim across the WHOLE machine — an unbounded build can
|
||||||
|
* take down the database, nginx and the live release with it.
|
||||||
|
*
|
||||||
|
* `scripts/with-memory-cap.sh` runs a heavy command in its own cgroup with a
|
||||||
|
* hard `MemoryMax`, so only that build dies and the site keeps serving. Every
|
||||||
|
* script in package.json goes through it.
|
||||||
|
*
|
||||||
|
* The one hole that leaves is running the builder by hand: `npx next build`,
|
||||||
|
* `pnpm exec next build`, or an IDE/agent task invoking it directly skips the
|
||||||
|
* wrapper entirely and is unbounded. This guard closes that. `next build`
|
||||||
|
* loads the config, so refusing here stops the build before it allocates
|
||||||
|
* anything. See the header of scripts/with-memory-cap.sh.
|
||||||
|
*/
|
||||||
|
function assertMemoryCapped(phase: string): void {
|
||||||
|
if (phase !== PHASE_PRODUCTION_BUILD) return;
|
||||||
|
if (process.env.CMS_MEMORY_CAPPED === "1") return;
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
"Refusing to run an uncapped production build.",
|
||||||
|
"",
|
||||||
|
"On this host an unbounded `next build` gets OOM-killed by the kernel,",
|
||||||
|
"and the killer may take the database, nginx or the live release with it.",
|
||||||
|
"",
|
||||||
|
"Use the capped build instead:",
|
||||||
|
" pnpm build",
|
||||||
|
"",
|
||||||
|
"It runs the builder through scripts/with-memory-cap.sh, which puts it in",
|
||||||
|
"its own cgroup with a MemoryMax, so a runaway build fails alone.",
|
||||||
|
"",
|
||||||
|
"Already inside an isolated environment (Docker, a CI runner) where the",
|
||||||
|
"container itself is the boundary? Set CMS_MEMORY_CAPPED=1 explicitly.",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const getGitCommit = () => {
|
const getGitCommit = () => {
|
||||||
try {
|
try {
|
||||||
return execSync("git rev-parse HEAD", { encoding: "utf8" }).trim();
|
return execSync("git rev-parse HEAD", { encoding: "utf8" }).trim();
|
||||||
@@ -118,7 +158,15 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: ["lucide-react", "date-fns"],
|
// Barrel-heavy packages: without this every import pulls the whole
|
||||||
|
// module graph into the chunk that first touches it.
|
||||||
|
optimizePackageImports: [
|
||||||
|
"lucide-react",
|
||||||
|
"motion",
|
||||||
|
"@dnd-kit/core",
|
||||||
|
"@dnd-kit/sortable",
|
||||||
|
"@tanstack/react-virtual",
|
||||||
|
],
|
||||||
useTypeScriptCli: true,
|
useTypeScriptCli: true,
|
||||||
hideLogsAfterAbort: true,
|
hideLogsAfterAbort: true,
|
||||||
},
|
},
|
||||||
@@ -127,6 +175,21 @@ const nextConfig: NextConfig = {
|
|||||||
formats: ["image/avif", "image/webp"],
|
formats: ["image/avif", "image/webp"],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// The SWF/Nitro image pipeline (src/lib/services/swf, src/lib/services/image-*)
|
||||||
|
// pulls shared helpers into several server chunks that webpack reports as a
|
||||||
|
// circular dependency between chunks with runtime. The cycle only affects
|
||||||
|
// server-side module caching of already-loaded chunks and has no runtime
|
||||||
|
// impact, so suppress the noisy warning instead of refactoring the shared
|
||||||
|
// helpers (which would risk the hot image path). Revisit if a real deadlock
|
||||||
|
// appears.
|
||||||
|
webpack(config) {
|
||||||
|
config.ignoreWarnings = [
|
||||||
|
...(config.ignoreWarnings ?? []),
|
||||||
|
/Circular dependency between chunks with runtime/,
|
||||||
|
];
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -174,4 +237,9 @@ const nextConfig: NextConfig = {
|
|||||||
|
|
||||||
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||||
|
|
||||||
export default withNextIntl(nextConfig);
|
// Exported as a function so the build phase is known before the config is
|
||||||
|
// used. next-intl only accepts a plain object, so it is applied here.
|
||||||
|
export default function config(phase: string) {
|
||||||
|
assertMemoryCapped(phase);
|
||||||
|
return withNextIntl(nextConfig);
|
||||||
|
}
|
||||||
+40
-33
File diff suppressed because it is too large.
Load diff
+26
-2
@@ -1,15 +1,39 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
// Deploy gate: keep this suite deterministic and light. One worker, one browser
|
||||||
|
// engine, no live dev server and no parallelism — that is what keeps the host
|
||||||
|
// from pinning at 100% CPU while the specs run.
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
testIgnore: ["**/ui/**", "**/news-real/**"],
|
testIgnore: ["**/ui/**", "**/imaging/**", "**/news-real/**", "**/quality/**"],
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
retries: 1,
|
retries: process.env.CI ? 1 : 0,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
|
expect: { timeout: 10000 },
|
||||||
|
globalTimeout: 10 * 60 * 1000,
|
||||||
|
maxFailures: process.env.CI ? 10 : undefined,
|
||||||
reporter: [["list"], ["html", { open: "never" }]],
|
reporter: [["list"], ["html", { open: "never" }]],
|
||||||
use: {
|
use: {
|
||||||
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002",
|
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002",
|
||||||
trace: "retain-on-failure",
|
trace: "retain-on-failure",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
video: "off",
|
||||||
|
launchOptions: {
|
||||||
|
args: [
|
||||||
|
"--disable-gpu",
|
||||||
|
"--disable-software-rasterizer",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
"--disable-background-networking",
|
||||||
|
"--disable-default-apps",
|
||||||
|
"--disable-extensions",
|
||||||
|
"--disable-sync",
|
||||||
|
"--metrics-recording-only",
|
||||||
|
"--mute-audio",
|
||||||
|
"--no-first-run",
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
// Cross-browser imaging checks. Unlike the main e2e config (chromium only,
|
||||||
|
// used as the deploy gate), this runs the same specs across every bundled
|
||||||
|
// engine so an avatar format that only one browser can decode is caught.
|
||||||
|
//
|
||||||
|
// Point it at a running CMS with PLAYWRIGHT_BASE_URL (defaults to the local
|
||||||
|
// host port). Requires all three engines: `pnpm exec playwright install
|
||||||
|
// chromium firefox webkit`.
|
||||||
|
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e/imaging",
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
retries: 1,
|
||||||
|
timeout: 60000,
|
||||||
|
expect: {
|
||||||
|
timeout: 15000,
|
||||||
|
toHaveScreenshot: {
|
||||||
|
animations: "disabled",
|
||||||
|
caret: "hide",
|
||||||
|
maxDiffPixelRatio: 0.02,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reporter: [
|
||||||
|
["list"],
|
||||||
|
["html", { outputFolder: "playwright-report/imaging", open: "never" }],
|
||||||
|
],
|
||||||
|
outputDir: "test-results/imaging",
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||||
|
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
||||||
|
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
// Advisory quality suite. Unlike the main e2e config (the blocking deploy
|
||||||
|
// gate), this checks things that depend on live content and data — broken
|
||||||
|
// uploads, legacy malformed avatar figures, warm-page latency. Those are real
|
||||||
|
// signals but they are not something a code change can guarantee, so they must
|
||||||
|
// never block a deploy. Point it at a running CMS with PLAYWRIGHT_BASE_URL
|
||||||
|
// (defaults to the local host port).
|
||||||
|
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e/quality",
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
retries: 1,
|
||||||
|
timeout: 60000,
|
||||||
|
expect: { timeout: 15000 },
|
||||||
|
reporter: [
|
||||||
|
["list"],
|
||||||
|
["html", { outputFolder: "playwright-report/quality", open: "never" }],
|
||||||
|
],
|
||||||
|
outputDir: "test-results/quality",
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
},
|
||||||
|
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||||
|
});
|
||||||
+31
-7
@@ -1,8 +1,24 @@
|
|||||||
|
import { randomInt } from "node:crypto";
|
||||||
import { defineConfig } from "@playwright/test";
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
const port = Number(process.env.UI_TEST_PORT || 3107);
|
// The fixture server binds a loopback port. The CI runner packs several jobs
|
||||||
if (!Number.isInteger(port) || port < 1024 || port > 65535)
|
// onto the shared host network (capacity > 1), so a fixed port makes concurrent
|
||||||
throw Error("Invalid UI_TEST_PORT");
|
// runs fight over the bind: one wins, the other's browser then gets
|
||||||
|
// "connection refused". Pick one random high port for the whole run and pass it
|
||||||
|
// to every worker through the environment, unless a port is pinned explicitly
|
||||||
|
// (e.g. a developer reusing a manually started fixture server).
|
||||||
|
function fixturePort() {
|
||||||
|
const requested = Number(process.env.UI_TEST_PORT);
|
||||||
|
if (Number.isInteger(requested) && requested >= 1024 && requested <= 65535)
|
||||||
|
return requested;
|
||||||
|
const shared = Number(process.env.PW_UI_TEST_PORT);
|
||||||
|
if (Number.isInteger(shared) && shared >= 1024 && shared <= 65535)
|
||||||
|
return shared;
|
||||||
|
const port = randomInt(30000, 50000);
|
||||||
|
process.env.PW_UI_TEST_PORT = String(port);
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
const port = fixturePort();
|
||||||
const baseURL = `http://127.0.0.1:${port}`;
|
const baseURL = `http://127.0.0.1:${port}`;
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e/ui",
|
testDir: "./e2e/ui",
|
||||||
@@ -39,11 +55,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: "desktop",
|
name: "desktop-chromium",
|
||||||
use: { browserName: "chromium", viewport: { width: 1280, height: 900 } },
|
use: { browserName: "chromium", viewport: { width: 1280, height: 900 } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "mobile",
|
name: "mobile-chromium",
|
||||||
use: {
|
use: {
|
||||||
browserName: "chromium",
|
browserName: "chromium",
|
||||||
viewport: { width: 390, height: 844 },
|
viewport: { width: 390, height: 844 },
|
||||||
@@ -52,12 +68,20 @@ export default defineConfig({
|
|||||||
hasTouch: true,
|
hasTouch: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "desktop-firefox",
|
||||||
|
use: { browserName: "firefox", viewport: { width: 1280, height: 900 } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "desktop-webkit",
|
||||||
|
use: { browserName: "webkit", viewport: { width: 1280, height: 900 } },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
webServer: {
|
webServer: {
|
||||||
command: "node e2e/ui/fixture-server.mjs",
|
command: "node e2e/ui/fixture-server.mjs",
|
||||||
url: `${baseURL}/health`,
|
url: `${baseURL}/health`,
|
||||||
timeout: 60000,
|
timeout: 120000,
|
||||||
reuseExistingServer: false,
|
reuseExistingServer: !process.env.CI,
|
||||||
env: { UI_TEST_PORT: String(port) },
|
env: { UI_TEST_PORT: String(port) },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Generated
+1016
-964
File diff suppressed because it is too large.
Load diff
+12
-7
@@ -1,9 +1,9 @@
|
|||||||
packages:
|
packages:
|
||||||
- '.'
|
- "."
|
||||||
|
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
'@parcel/watcher': true
|
"@parcel/watcher": true
|
||||||
'@swc/core': true
|
"@swc/core": true
|
||||||
cpu-features: true
|
cpu-features: true
|
||||||
esbuild: true
|
esbuild: true
|
||||||
msw: true
|
msw: true
|
||||||
@@ -13,7 +13,12 @@ allowBuilds:
|
|||||||
minimumReleaseAge: 60
|
minimumReleaseAge: 60
|
||||||
|
|
||||||
overrides:
|
overrides:
|
||||||
glob: '^11.0.0'
|
glob: "^11.0.0"
|
||||||
'@esbuild-kit/core-utils': 'npm:tsx@^4.23.15'
|
"@esbuild-kit/core-utils": "npm:tsx@^4.23.15"
|
||||||
'@esbuild-kit/esm-loader': 'npm:tsx@^4.23.15'
|
"@esbuild-kit/esm-loader": "npm:tsx@^4.23.15"
|
||||||
source-map-js: '1.2.2'
|
source-map-js: "1.2.2"
|
||||||
|
# GHSA advisory: postcss-selector-parser <7.1.6. Pulled in only by
|
||||||
|
# @tailwindcss/typography (build-time prose selectors), which pins 6.0.10
|
||||||
|
# exactly and has no upstream bump. The API subset it uses is unchanged in
|
||||||
|
# v7, so force the patched major.
|
||||||
|
postcss-selector-parser: "^7.1.6"
|
||||||
+29
-56
@@ -1,5 +1,26 @@
|
|||||||
const CACHE = "atom-v3";
|
// Retired service worker — this file no longer serves anything.
|
||||||
const API_CACHE = "atom-api-v3";
|
//
|
||||||
|
// The app used to register a worker that cached /_next/static/ and /assets/
|
||||||
|
// cache-first under a cache name with no build id. A release could not
|
||||||
|
// invalidate it, so browsers replayed the previous release's chunks against
|
||||||
|
// the new HTML and React never hydrated, which is what left the Catalog
|
||||||
|
// Studio on a blank white page. It also bought nothing: nginx already sends
|
||||||
|
// those two prefixes as `max-age=31536000, immutable` and their filenames are
|
||||||
|
// content-hashed, so the HTTP cache already handles them correctly.
|
||||||
|
//
|
||||||
|
// This file must stay at /sw.js and keep its install/activate handlers.
|
||||||
|
// Simply deleting it would leave every existing registration alive and in
|
||||||
|
// control of the page, with the old caching still running. Instead this
|
||||||
|
// worker does the opposite of what it used to: it deletes every cache and
|
||||||
|
// unregisters itself, then reloads open clients so they stop being
|
||||||
|
// controlled by it.
|
||||||
|
//
|
||||||
|
// Nothing registers it any more (see src/app/layout.tsx), so this only ever
|
||||||
|
// runs for browsers that already have a worker installed. Once a visitor
|
||||||
|
// loads the site again it uninstalls itself and never comes back.
|
||||||
|
//
|
||||||
|
// The manifest is unrelated and untouched — it is generated by
|
||||||
|
// src/app/manifest.ts and keeps the site installable without a worker.
|
||||||
|
|
||||||
self.addEventListener("install", () => self.skipWaiting());
|
self.addEventListener("install", () => self.skipWaiting());
|
||||||
|
|
||||||
@@ -7,61 +28,13 @@ self.addEventListener("activate", (event) => {
|
|||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches
|
caches
|
||||||
.keys()
|
.keys()
|
||||||
.then((keys) =>
|
.then((keys) => Promise.all(keys.map((key) => caches.delete(key))))
|
||||||
Promise.all(
|
.then(() => self.registration.unregister())
|
||||||
keys
|
.then(() =>
|
||||||
.filter((k) => k !== CACHE && k !== API_CACHE)
|
self.clients.matchAll({ type: "window", includeUncontrolled: true }),
|
||||||
.map((k) => caches.delete(k)),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.then(() => self.clients.claim()),
|
.then((clients) => {
|
||||||
);
|
for (const client of clients) client.navigate(client.url);
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener("fetch", (event) => {
|
|
||||||
const req = event.request;
|
|
||||||
if (req.method !== "GET") return;
|
|
||||||
const url = new URL(req.url);
|
|
||||||
if (url.origin !== self.location.origin) return;
|
|
||||||
|
|
||||||
if (
|
|
||||||
url.pathname.startsWith("/assets/") ||
|
|
||||||
url.pathname.startsWith("/_next/static/")
|
|
||||||
) {
|
|
||||||
event.respondWith(
|
|
||||||
caches.open(CACHE).then((cache) =>
|
|
||||||
cache.match(req).then(
|
|
||||||
(hit) =>
|
|
||||||
hit ||
|
|
||||||
fetch(req).then((res) => {
|
|
||||||
if (res.ok) cache.put(req, res.clone());
|
|
||||||
return res;
|
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
url.pathname.startsWith("/api/home") ||
|
|
||||||
url.pathname.startsWith("/api/online") ||
|
|
||||||
url.pathname.startsWith("/api/radio/config")
|
|
||||||
) {
|
|
||||||
event.respondWith(
|
|
||||||
caches.open(API_CACHE).then((cache) =>
|
|
||||||
fetch(req)
|
|
||||||
.then((res) => {
|
|
||||||
if (res.ok) cache.put(req, res.clone());
|
|
||||||
return res;
|
|
||||||
})
|
|
||||||
.catch(() => cache.match(req)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.mode === "navigate") {
|
|
||||||
event.respondWith(fetch(req));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:recommended",
|
||||||
|
":timezone(Europe/Amsterdam)",
|
||||||
|
":semanticCommits",
|
||||||
|
"workarounds:all"
|
||||||
|
],
|
||||||
|
"labels": ["dependencies"],
|
||||||
|
"dependencyDashboard": true,
|
||||||
|
"dependencyDashboardOSVVulnerabilitySummary": "all",
|
||||||
|
"osvVulnerabilityAlerts": true,
|
||||||
|
"configMigration": true,
|
||||||
|
"automergeStrategy": "squash",
|
||||||
|
"schedule": [
|
||||||
|
"after 4:00 am and before 5:00 am on monday through friday",
|
||||||
|
"at 4:17 am on monday through friday"
|
||||||
|
],
|
||||||
|
"timezone": "Europe/Amsterdam",
|
||||||
|
"prConcurrentLimit": 5,
|
||||||
|
"prHourlyLimit": 3,
|
||||||
|
"minimumReleaseAge": "3 days",
|
||||||
|
"lockFileMaintenance": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"description": "Minor en patch: één groeps-PR, automerge pas na groene CI",
|
||||||
|
"matchUpdateTypes": ["minor", "patch", "pin", "digest"],
|
||||||
|
"groupName": "all non-major dependencies",
|
||||||
|
"automerge": true,
|
||||||
|
"platformAutomerge": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Major updates vragen goedkeuring via het dependency-dashboard",
|
||||||
|
"matchUpdateTypes": ["major"],
|
||||||
|
"labels": ["major"],
|
||||||
|
"dependencyDashboardApproval": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Beveiligingsfixes (OSV) niet tegenhouden door de wachttijd en in een eigen PR",
|
||||||
|
"matchCategories": ["security"],
|
||||||
|
"minimumReleaseAge": null,
|
||||||
|
"groupName": "security fixes",
|
||||||
|
"addLabels": ["security"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Docker-compose draait op :latest; geen digest-pin-ruis",
|
||||||
|
"matchManagers": ["docker-compose"],
|
||||||
|
"pinDigests": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Node-upgrades worden handmatig gecoördineerd over .nvmrc, package.json engines, @types/node, de Docker-basisimage en de runner; Renovate mag .nvmrc niet los bumpen (check-node-toolchain faalt anders)",
|
||||||
|
"matchManagers": ["nvm"],
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "De Node-basisimage in de Dockerfile hoort bij dezelfde gecoördineerde Node-upgrade als .nvmrc",
|
||||||
|
"matchManagers": ["dockerfile"],
|
||||||
|
"matchPackageNames": ["node"],
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "De pnpm packageManager-pin blijft handmatig: Docker (corepack) en de runner volgen de pin automatisch, maar een bump raakt ook de lockfile en de README-versietabel (scripts/sync-readme-versions.mjs)",
|
||||||
|
"matchPackageNames": ["pnpm"],
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+83
-1
File diff suppressed because it is too large.
Load diff
Executable
+210
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,9 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Create/update the Cloudflare Cache Rule that stores the CMS public API
|
# Create/update the Cloudflare Cache Rule that stores the CMS public API
|
||||||
# allowlist plus the client-facing gamedata tree at the edge (the routes nginx
|
# allowlist, the client-facing gamedata tree and the avatar/badge imaging
|
||||||
# tags with `Cache-Tag: cms-public` respectievelijk `cms-gamedata`).
|
# endpoints at the edge (the routes nginx tags with `Cache-Tag: cms-public`
|
||||||
|
# respectievelijk `cms-gamedata`, en `/api/imaging/` dat zijn eigen s-maxage
|
||||||
|
# stuurt).
|
||||||
#
|
#
|
||||||
# Why a rule is required: Cloudflare only caches a handful of file extensions
|
# Why a rule is required: Cloudflare only caches a handful of file extensions
|
||||||
# by default; `/api/*` responses are served `cf-cache-status: DYNAMIC` even
|
# by default; `/api/*` responses are served `cf-cache-status: DYNAMIC` even
|
||||||
@@ -35,12 +37,18 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
ENV_FILE="$SCRIPT_DIR/../.env"
|
ENV_FILE="$SCRIPT_DIR/../.env"
|
||||||
BASE="https://api.cloudflare.com/client/v4"
|
BASE="https://api.cloudflare.com/client/v4"
|
||||||
PHASE="http_request_cache_settings"
|
PHASE="http_request_cache_settings"
|
||||||
DESCRIPTION="EpicNabbo CMS public API + gamedata edge cache (cms-public, cms-gamedata)"
|
DESCRIPTION="EpicNabbo CMS public API + gamedata + imaging edge cache (cms-public, cms-gamedata, cms-imaging)"
|
||||||
|
|
||||||
# Cache de allowlist exact zoals nginx hem tagt (deployment/proxy/nginx-cms.conf).
|
# Cache de allowlist exact zoals nginx hem tagt (deployment/proxy/nginx-cms.conf).
|
||||||
# Geen regex: `matches` vereist Business; vrije operators zijn `in` en
|
# Geen regex: `matches` vereist Business; vrije operators zijn `in` en
|
||||||
# `starts_with()`.
|
# `starts_with()`.
|
||||||
EXPRESSION='(http.request.method eq "GET") and (http.request.uri.path in { "/api/staff" "/api/teams" "/api/guilds" "/api/photos" "/api/leaderboard" "/api/online" "/api/online/count" "/api/shop" "/api/shop/categories" "/api/values" "/api/values/categories" "/api/radio/current-dj" "/api/radio/points/leaderboard" } or starts_with(http.request.uri.path, "/api/values/") or starts_with(http.request.uri.path, "/gamedata/"))'
|
#
|
||||||
|
# `/api/imaging/` (avatars, badges) staat erbij omdat de CMS-route zelf al
|
||||||
|
# `s-maxage=86400` + `stale-while-revalidate` stuurt: de edge kan een avatar een
|
||||||
|
# dag vasthouden, zodat herhaalde requests de lokale renderer nooit raken. De
|
||||||
|
# volledige URL (inclusief figure/code) is de cache-key, dus geen vervuiling
|
||||||
|
# tussen gebruikers.
|
||||||
|
EXPRESSION='(http.request.method eq "GET") and (http.request.uri.path in { "/api/staff" "/api/teams" "/api/guilds" "/api/photos" "/api/leaderboard" "/api/online" "/api/online/count" "/api/shop" "/api/shop/categories" "/api/values" "/api/values/categories" "/api/radio/current-dj" "/api/radio/points/leaderboard" } or starts_with(http.request.uri.path, "/api/values/") or starts_with(http.request.uri.path, "/api/imaging/") or starts_with(http.request.uri.path, "/gamedata/"))'
|
||||||
|
|
||||||
load_env() {
|
load_env() {
|
||||||
local name="$1"
|
local name="$1"
|
||||||
|
|||||||
+40
-7
@@ -42,13 +42,10 @@ old_container=""
|
|||||||
new_container=""
|
new_container=""
|
||||||
blue_green=0
|
blue_green=0
|
||||||
|
|
||||||
# Resource limits voor de container. Zonder deze limieten kan één geheugenlek de
|
# CPU en pids zijn bewust verwijderd; memory blijft gelimiteerd op 4g/5g swap
|
||||||
# hele host vullen, met MariaDB, nginx en Traefik als slachtoffer. 2 CPU laat
|
# om een geheugenlek niet de host te laten vullen (MariaDB, nginx, Traefik).
|
||||||
# achtergrondwerk toe zonder de hele kernel aan één release te geven.
|
|
||||||
mem_limit="4g"
|
mem_limit="4g"
|
||||||
mem_swap_limit="5g"
|
mem_swap_limit="5g"
|
||||||
cpu_limit="2"
|
|
||||||
pids_limit="512"
|
|
||||||
|
|
||||||
is_current() {
|
is_current() {
|
||||||
local head
|
local head
|
||||||
@@ -300,7 +297,6 @@ start_candidate() {
|
|||||||
done < "$deploy_dir/.env"
|
done < "$deploy_dir/.env"
|
||||||
docker run -d --name "$name" --restart always --net=host \
|
docker run -d --name "$name" --restart always --net=host \
|
||||||
--memory="$mem_limit" --memory-swap="$mem_swap_limit" \
|
--memory="$mem_limit" --memory-swap="$mem_swap_limit" \
|
||||||
--cpus="$cpu_limit" --pids-limit="$pids_limit" \
|
|
||||||
"${ENV_ARGS[@]}" -e "PORT=$port" -e HOSTNAME=0.0.0.0 \
|
"${ENV_ARGS[@]}" -e "PORT=$port" -e HOSTNAME=0.0.0.0 \
|
||||||
-v "$deploy_dir/public/nitro-assets:/app/public/nitro-assets" \
|
-v "$deploy_dir/public/nitro-assets:/app/public/nitro-assets" \
|
||||||
-v "$deploy_dir/public/swf:/app/public/swf" \
|
-v "$deploy_dir/public/swf:/app/public/swf" \
|
||||||
@@ -410,12 +406,32 @@ if [ -n "$(git status --porcelain --untracked-files=normal)" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm exec playwright install chromium
|
# All three engines are needed: the chromium gate below plus the cross-browser
|
||||||
|
# imaging gate just before cutover. Idempotent — already-installed engines in
|
||||||
|
# PLAYWRIGHT_BROWSERS_PATH are detected and skipped.
|
||||||
|
pnpm exec playwright install chromium firefox webkit
|
||||||
|
|
||||||
echo "Building $image"
|
echo "Building $image"
|
||||||
|
# Duurzame buildcache buiten de werkboom. De CI-werkboom wordt na elke job
|
||||||
|
# gewist en `docker builder prune` gooit de buildcache weg, waarna de hele
|
||||||
|
# pnpm-laag opnieuw gedownload moet worden. Een lokale cache-export overleeft
|
||||||
|
# allebei. Een lege CMS_BUILD_CACHE_DIR schakelt de cache uit.
|
||||||
|
build_cache_dir="${CMS_BUILD_CACHE_DIR-/var/cache/epicnext-cms-buildcache}"
|
||||||
|
build_cache_flags=()
|
||||||
|
if [ -n "$build_cache_dir" ]; then
|
||||||
|
if mkdir -p "$build_cache_dir" 2>/dev/null && [ -w "$build_cache_dir" ]; then
|
||||||
|
build_cache_flags=(
|
||||||
|
--cache-from "type=local,src=$build_cache_dir"
|
||||||
|
--cache-to "type=local,dest=$build_cache_dir,mode=max"
|
||||||
|
)
|
||||||
|
else
|
||||||
|
echo "Warning: buildcache $build_cache_dir is niet beschrijfbaar; build zonder hergebruikte cache." >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
# No --network=host: BuildKit only grants it via --allow=network.host, and the
|
# No --network=host: BuildKit only grants it via --allow=network.host, and the
|
||||||
# build needs nothing but outbound internet (apk, pnpm, next/font/google).
|
# build needs nothing but outbound internet (apk, pnpm, next/font/google).
|
||||||
DOCKER_BUILDKIT=1 docker build --progress=plain --cache-from epicnext-cms:latest \
|
DOCKER_BUILDKIT=1 docker build --progress=plain --cache-from epicnext-cms:latest \
|
||||||
|
${build_cache_flags[@]+"${build_cache_flags[@]}"} \
|
||||||
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
||||||
check_current
|
check_current
|
||||||
# Read reports from the already-built image; do not start an extra application.
|
# Read reports from the already-built image; do not start an extra application.
|
||||||
@@ -536,6 +552,18 @@ if [ "$blue_green" -eq 1 ]; then
|
|||||||
node scripts/verify-deployed-release.mjs "http://127.0.0.1:$new_port/api/health" "$sha"
|
node scripts/verify-deployed-release.mjs "http://127.0.0.1:$new_port/api/health" "$sha"
|
||||||
PLAYWRIGHT_BASE_URL="http://127.0.0.1:$new_port" pnpm test:e2e
|
PLAYWRIGHT_BASE_URL="http://127.0.0.1:$new_port" pnpm test:e2e
|
||||||
|
|
||||||
|
# 4b. Cross-browser imaging gate. The chromium suite above proves the
|
||||||
|
# candidate serves the right bytes; this proves every bundled engine can
|
||||||
|
# actually decode the render. The webkit path is the one that historically
|
||||||
|
# broke (CSP upgrade-insecure-requests + animated PNG), and a regression
|
||||||
|
# there must never reach live. Set DEPLOY_SKIP_CROSS_BROWSER=1 only to get
|
||||||
|
# a release out in an emergency.
|
||||||
|
if [ "${DEPLOY_SKIP_CROSS_BROWSER:-0}" = "1" ]; then
|
||||||
|
echo "WARNING: cross-browser imaging gate skipped (DEPLOY_SKIP_CROSS_BROWSER=1)" >&2
|
||||||
|
else
|
||||||
|
PLAYWRIGHT_BASE_URL="http://127.0.0.1:$new_port" pnpm test:imaging
|
||||||
|
fi
|
||||||
|
|
||||||
# 5. Het enige onomkeerbare moment: vanaf hier wijst nginx naar de kandidaat.
|
# 5. Het enige onomkeerbare moment: vanaf hier wijst nginx naar de kandidaat.
|
||||||
cutover_started=1
|
cutover_started=1
|
||||||
switch_upstream "$new_port"
|
switch_upstream "$new_port"
|
||||||
@@ -569,6 +597,11 @@ else
|
|||||||
healthy 3002
|
healthy 3002
|
||||||
node scripts/verify-deployed-release.mjs http://127.0.0.1:3002/api/health "$sha"
|
node scripts/verify-deployed-release.mjs http://127.0.0.1:3002/api/health "$sha"
|
||||||
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e
|
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e
|
||||||
|
if [ "${DEPLOY_SKIP_CROSS_BROWSER:-0}" = "1" ]; then
|
||||||
|
echo "WARNING: cross-browser imaging gate skipped (DEPLOY_SKIP_CROSS_BROWSER=1)" >&2
|
||||||
|
else
|
||||||
|
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:imaging
|
||||||
|
fi
|
||||||
verified_image="$(docker inspect --format '{{.Image}}' epicnext-cms-app)"
|
verified_image="$(docker inspect --format '{{.Image}}' epicnext-cms-app)"
|
||||||
fi
|
fi
|
||||||
docker tag "$verified_image" "epicnext-cms:verified-$sha"
|
docker tag "$verified_image" "epicnext-cms:verified-$sha"
|
||||||
|
|||||||
@@ -39,6 +39,23 @@ pnpm exec playwright install chromium
|
|||||||
export NEWS_E2E_IMAGE="$image"
|
export NEWS_E2E_IMAGE="$image"
|
||||||
export NEWS_E2E_RELEASE="$sha"
|
export NEWS_E2E_RELEASE="$sha"
|
||||||
build_attempted=1
|
build_attempted=1
|
||||||
|
# Duurzame buildcache buiten de tijdelijke werkmap, zodat de lagen van een
|
||||||
|
# vorige preflight hergebruikt worden in plaats van opnieuw gedownload. Eigen
|
||||||
|
# map, gescheiden van de release-cache: een branch-preflight mag nooit de cache
|
||||||
|
# van de release-build voeden. Een lege CMS_BUILD_CACHE_DIR schakelt het uit.
|
||||||
|
build_cache_dir="${CMS_BUILD_CACHE_DIR-/var/cache/epicnext-cms-preflight-cache}"
|
||||||
|
build_cache_flags=()
|
||||||
|
if [ -n "$build_cache_dir" ]; then
|
||||||
|
if mkdir -p "$build_cache_dir" 2>/dev/null && [ -w "$build_cache_dir" ]; then
|
||||||
|
build_cache_flags=(
|
||||||
|
--cache-from "type=local,src=$build_cache_dir"
|
||||||
|
--cache-to "type=local,dest=$build_cache_dir,mode=max"
|
||||||
|
)
|
||||||
|
else
|
||||||
|
echo "Warning: buildcache $build_cache_dir is niet beschrijfbaar; build zonder hergebruikte cache." >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
DOCKER_BUILDKIT=1 docker build --progress=plain \
|
DOCKER_BUILDKIT=1 docker build --progress=plain \
|
||||||
|
${build_cache_flags[@]+"${build_cache_flags[@]}"} \
|
||||||
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
||||||
NEWS_E2E_IMAGE="$image" NEWS_E2E_RELEASE="$sha" node --import tsx e2e/news-real/run.ts
|
NEWS_E2E_IMAGE="$image" NEWS_E2E_RELEASE="$sha" node --import tsx e2e/news-real/run.ts
|
||||||
@@ -11,6 +11,9 @@ LOG_FILE="${LOG_FILE:-$DIR/logs/docker-update.log}"
|
|||||||
mkdir -p "$(dirname "$LOG_FILE")"
|
mkdir -p "$(dirname "$LOG_FILE")"
|
||||||
log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"; }
|
log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"; }
|
||||||
die() { log "ERROR: $*"; exit 1; }
|
die() { log "ERROR: $*"; exit 1; }
|
||||||
|
# A run that legitimately has nothing to do (the host is owned by CI) must not
|
||||||
|
# look like a failure to cron. `die` stays reserved for real errors.
|
||||||
|
skip() { log "SKIP: $*"; exit 0; }
|
||||||
migration_image=""
|
migration_image=""
|
||||||
remote_migration_image=""
|
remote_migration_image=""
|
||||||
previous_image=""
|
previous_image=""
|
||||||
@@ -72,11 +75,11 @@ trap 'log "Update failed; inspect $LOG_FILE. No volumes or local files were dele
|
|||||||
# the only thing that still marks the host as blue/green when a slot is idle.
|
# the only thing that still marks the host as blue/green when a slot is idle.
|
||||||
ci_upstream_file="${CMS_UPSTREAM_FILE:-/etc/nginx/snippets/cms_upstream_servers.conf}"
|
ci_upstream_file="${CMS_UPSTREAM_FILE:-/etc/nginx/snippets/cms_upstream_servers.conf}"
|
||||||
if [ -r "$ci_upstream_file" ] && grep -qsE '127\.0\.0\.1:(3002|3003)' "$ci_upstream_file"; then
|
if [ -r "$ci_upstream_file" ] && grep -qsE '127\.0\.0\.1:(3002|3003)' "$ci_upstream_file"; then
|
||||||
die "This host is managed by CI ($ci_upstream_file points at a blue/green slot). Update through CI, not a second Compose deployment."
|
skip "This host is managed by CI ($ci_upstream_file points at a blue/green slot). Update through CI, not a second Compose deployment."
|
||||||
fi
|
fi
|
||||||
for slot_container in epicnext-cms-app epicnext-cms-green; do
|
for slot_container in epicnext-cms-app epicnext-cms-green; do
|
||||||
if [ "$(docker inspect --format '{{.State.Running}}' "$slot_container" 2>/dev/null || true)" = true ]; then
|
if [ "$(docker inspect --format '{{.State.Running}}' "$slot_container" 2>/dev/null || true)" = true ]; then
|
||||||
die "This host is managed by CI ($slot_container). Update through CI, not a second Compose deployment."
|
skip "This host is managed by CI ($slot_container). Update through CI, not a second Compose deployment."
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
[[ -z "$(git status --porcelain --untracked-files=normal)" ]] || die "Working tree is not clean. Commit or stash local work first."
|
[[ -z "$(git status --porcelain --untracked-files=normal)" ]] || die "Working tree is not clean. Commit or stash local work first."
|
||||||
@@ -123,7 +126,9 @@ if [[ -n "${CMS_IMAGE_REPOSITORY:-}" ]]; then
|
|||||||
docker tag "$app_reference" "epicnext-cms:$CMS_RELEASE"
|
docker tag "$app_reference" "epicnext-cms:$CMS_RELEASE"
|
||||||
docker tag "$remote_migration_image" "$migration_image"
|
docker tag "$remote_migration_image" "$migration_image"
|
||||||
else
|
else
|
||||||
docker build --target migrations --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" -t "$migration_image" . >>"$LOG_FILE" 2>&1
|
docker build --target migrations --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" \
|
||||||
|
--cache-from type=local,src=.docker-cache/cms \
|
||||||
|
-t "$migration_image" . >>"$LOG_FILE" 2>&1
|
||||||
docker compose build --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" cms >>"$LOG_FILE" 2>&1
|
docker compose build --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" cms >>"$LOG_FILE" 2>&1
|
||||||
fi
|
fi
|
||||||
expected_image="$(docker image inspect --format '{{.Id}}' "epicnext-cms:$CMS_RELEASE")"
|
expected_image="$(docker image inspect --format '{{.Id}}' "epicnext-cms:$CMS_RELEASE")"
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
Executable
+72
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Alert when the local avatar imager stops rendering, and again when it recovers.
|
||||||
|
#
|
||||||
|
# Intended to run from cron every few minutes (see `crontab -l`). Steady-state is
|
||||||
|
# silent; a state change posts to DISCORD_WEBHOOK_URL (from the environment or
|
||||||
|
# .env). While the imager stays down it re-alerts at most once per
|
||||||
|
# HEALTH_ALERT_COOLDOWN_MIN. The script always exits 0 so an outage never turns
|
||||||
|
# into a noisy cron failure; the alert itself (and this log) is the signal.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
LOG_FILE="${IMAGER_HEALTH_LOG:-$DIR/logs/imager-health.log}"
|
||||||
|
STATE_FILE="${IMAGER_HEALTH_STATE:-$DIR/logs/imager-health.state}"
|
||||||
|
HEALTH_URL="${IMAGER_HEALTH_URL:-http://127.0.0.1:8082/health}"
|
||||||
|
mkdir -p "$(dirname "$LOG_FILE")" "$(dirname "$STATE_FILE")"
|
||||||
|
log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" | tee -a "$LOG_FILE"; }
|
||||||
|
|
||||||
|
load_env() {
|
||||||
|
local name="$1"
|
||||||
|
if [ -n "${!name:-}" ]; then return 0; fi
|
||||||
|
if [ -f "$DIR/.env" ]; then
|
||||||
|
local line
|
||||||
|
line="$(grep -m1 "^$name=" "$DIR/.env" | cut -d= -f2- | tr -d "'\"")" || true
|
||||||
|
if [ -n "$line" ]; then printf -v "$name" '%s' "$line"; fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
load_env DISCORD_WEBHOOK_URL || true
|
||||||
|
COOLDOWN_MIN="${HEALTH_ALERT_COOLDOWN_MIN:-30}"
|
||||||
|
|
||||||
|
notify() {
|
||||||
|
local msg="$1"
|
||||||
|
log "ALERT: $msg"
|
||||||
|
if [ -z "${DISCORD_WEBHOOK_URL:-}" ]; then
|
||||||
|
log "no DISCORD_WEBHOOK_URL configured; alert written to log only"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local payload
|
||||||
|
payload="$(python3 -c 'import json,sys;print(json.dumps({"content":sys.argv[1]}))' "$msg")"
|
||||||
|
curl -sS -m 10 -H "Content-Type: application/json" -d "$payload" \
|
||||||
|
"$DISCORD_WEBHOOK_URL" >/dev/null 2>&1 || log "failed to post alert to Discord"
|
||||||
|
}
|
||||||
|
|
||||||
|
body="$(curl -sS -m 5 "$HEALTH_URL" 2>/dev/null || true)"
|
||||||
|
ready=0
|
||||||
|
if printf '%s' "$body" | grep -q '"ready":true'; then ready=1; fi
|
||||||
|
|
||||||
|
now="$(date +%s)"
|
||||||
|
prev_state="up"
|
||||||
|
prev_at=0
|
||||||
|
if [ -f "$STATE_FILE" ]; then read -r prev_state prev_at < "$STATE_FILE" || true; fi
|
||||||
|
[ -n "${prev_state:-}" ] || prev_state=up
|
||||||
|
[ -n "${prev_at:-}" ] || prev_at=0
|
||||||
|
|
||||||
|
if [ "$ready" = 1 ]; then
|
||||||
|
if [ "$prev_state" != "up" ]; then
|
||||||
|
notify "Avatar imager RECOVERED ($HEALTH_URL reports ready)"
|
||||||
|
fi
|
||||||
|
printf 'up %s\n' "$now" > "$STATE_FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$prev_state" != "down" ]; then
|
||||||
|
notify "Avatar imager DOWN ($HEALTH_URL not ready: ${body:-unreachable})"
|
||||||
|
printf 'down %s\n' "$now" > "$STATE_FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$(( now - prev_at ))" -ge "$(( COOLDOWN_MIN * 60 ))" ]; then
|
||||||
|
notify "Avatar imager STILL DOWN after ${COOLDOWN_MIN}m ($HEALTH_URL)"
|
||||||
|
printf 'down %s\n' "$now" > "$STATE_FILE"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
+15
-4
@@ -78,13 +78,24 @@ else
|
|||||||
if install_file "$PROXY_DIR/cms_upstream_servers.conf" "$NGINX_DIR/snippets/cms_upstream_servers.conf"; then changed=1; fi
|
if install_file "$PROXY_DIR/cms_upstream_servers.conf" "$NGINX_DIR/snippets/cms_upstream_servers.conf"; then changed=1; fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -f "$NGINX_DIR/sites-enabled/cms.conf" ]]; then
|
# sites-enabled/cms.conf must be a symlink, not a copy. A regular file here
|
||||||
|
# silently shadows sites-available/cms.conf, so nginx keeps serving stale
|
||||||
|
# config even though the sync updated sites-available.
|
||||||
|
enabled="$NGINX_DIR/sites-enabled/cms.conf"
|
||||||
|
if [[ -L "$enabled" && "$(readlink "$enabled")" == "../sites-available/cms.conf" ]]; then
|
||||||
|
echo "= $enabled symlink up to date"
|
||||||
|
else
|
||||||
if [[ "$MODE" == "check" ]]; then
|
if [[ "$MODE" == "check" ]]; then
|
||||||
echo "- sites-enabled/cms.conf missing"
|
echo "- $enabled is not the expected symlink"
|
||||||
changed=1
|
changed=1
|
||||||
else
|
else
|
||||||
ln -sf ../sites-available/cms.conf "$NGINX_DIR/sites-enabled/cms.conf"
|
mkdir -p "$NGINX_DIR/sites-enabled"
|
||||||
echo "+ linked sites-enabled/cms.conf"
|
if [[ -e "$enabled" || -L "$enabled" ]]; then
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
cp -a "$enabled" "$BACKUP_DIR/cms-enabled.conf"
|
||||||
|
fi
|
||||||
|
ln -sfn ../sites-available/cms.conf "$enabled"
|
||||||
|
echo "+ linked $enabled"
|
||||||
changed=1
|
changed=1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,36 +1,41 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"mode": "informational",
|
"mode": "enforce",
|
||||||
"routes": [
|
"routes": [
|
||||||
|
{
|
||||||
|
"route": "/",
|
||||||
|
"maxInitialRawBytes": 716800,
|
||||||
|
"maxInitialGzipBytes": 225280
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"route": "/me",
|
"route": "/me",
|
||||||
"maxInitialRawBytes": 890880,
|
"maxInitialRawBytes": 716800,
|
||||||
"maxInitialGzipBytes": 276480
|
"maxInitialGzipBytes": 225280
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"route": "/news",
|
"route": "/news",
|
||||||
"maxInitialRawBytes": 880640,
|
"maxInitialRawBytes": 716800,
|
||||||
"maxInitialGzipBytes": 276480
|
"maxInitialGzipBytes": 225280
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"route": "/events",
|
"route": "/events",
|
||||||
"maxInitialRawBytes": 890880,
|
"maxInitialRawBytes": 716800,
|
||||||
"maxInitialGzipBytes": 276480
|
"maxInitialGzipBytes": 225280
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"route": "/search",
|
"route": "/search",
|
||||||
"maxInitialRawBytes": 890880,
|
"maxInitialRawBytes": 716800,
|
||||||
"maxInitialGzipBytes": 276480
|
"maxInitialGzipBytes": 225280
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"route": "/admin/catalog",
|
"route": "/admin/catalog",
|
||||||
"maxInitialRawBytes": 1904640,
|
"maxInitialRawBytes": 1208320,
|
||||||
"maxInitialGzipBytes": 573440
|
"maxInitialGzipBytes": 389120
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"route": "/admin/studio/furni",
|
"route": "/admin/studio/furni",
|
||||||
"maxInitialRawBytes": 1433600,
|
"maxInitialRawBytes": 1607680,
|
||||||
"maxInitialGzipBytes": 450560
|
"maxInitialGzipBytes": 522240
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -7,12 +7,12 @@ const LIMIT_KEYS = ["route", "maxInitialRawBytes", "maxInitialGzipBytes"];
|
|||||||
export function validateBudgets(config) {
|
export function validateBudgets(config) {
|
||||||
if (
|
if (
|
||||||
config?.schemaVersion !== 1 ||
|
config?.schemaVersion !== 1 ||
|
||||||
config.mode !== "informational" ||
|
(config.mode !== "informational" && config.mode !== "enforce") ||
|
||||||
!Array.isArray(config.routes) ||
|
!Array.isArray(config.routes) ||
|
||||||
config.routes.length === 0
|
config.routes.length === 0
|
||||||
)
|
)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Budget config requires schemaVersion 1, mode informational and nonempty routes.",
|
"Budget config requires schemaVersion 1, mode informational or enforce, and nonempty routes.",
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
Object.keys(config).some(
|
Object.keys(config).some(
|
||||||
@@ -288,7 +288,7 @@ export function collectReport(nextDir, config, metadata = {}) {
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
mode: "informational",
|
mode: config.mode,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
buildId,
|
buildId,
|
||||||
commitSha: metadata.commitSha ?? null,
|
commitSha: metadata.commitSha ?? null,
|
||||||
@@ -308,6 +308,10 @@ export function collectReport(nextDir, config, metadata = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function markdownReport(report) {
|
export function markdownReport(report) {
|
||||||
|
const modeLine =
|
||||||
|
report.mode === "enforce"
|
||||||
|
? `Build: ${report.buildId ?? "unavailable"}. Declared commit: ${report.commitSha ?? "not supplied"}. Mode: enforce; an over-budget or unavailable route fails the command and the image build.`
|
||||||
|
: `Build: ${report.buildId ?? "unavailable"}. Declared commit: ${report.commitSha ?? "not supplied"}. Mode: informational; exceeded budgets and unavailable metrics do not fail CI.`;
|
||||||
const lines = [
|
const lines = [
|
||||||
"# Route JavaScript size report",
|
"# Route JavaScript size report",
|
||||||
"",
|
"",
|
||||||
@@ -315,7 +319,7 @@ export function markdownReport(report) {
|
|||||||
"",
|
"",
|
||||||
report.gzip,
|
report.gzip,
|
||||||
"",
|
"",
|
||||||
`Build: ${report.buildId ?? "unavailable"}. Declared commit: ${report.commitSha ?? "not supplied"}. Mode: informational; exceeded budgets and unavailable metrics do not fail CI.`,
|
modeLine,
|
||||||
"",
|
"",
|
||||||
"| Route | State | Unique JS | Raw bytes | Gzip bytes | Raw budget | Gzip budget |",
|
"| Route | State | Unique JS | Raw bytes | Gzip bytes | Raw budget | Gzip budget |",
|
||||||
"| --- | --- | ---: | ---: | ---: | ---: | ---: |",
|
"| --- | --- | ---: | ---: | ---: | ---: | ---: |",
|
||||||
@@ -370,6 +374,17 @@ export function main(argv = process.argv.slice(2)) {
|
|||||||
console.warn(
|
console.warn(
|
||||||
`Performance warning: ${row.route} ${row.status}${row.reason ? `: ${row.reason}` : ""}`,
|
`Performance warning: ${row.route} ${row.status}${row.reason ? `: ${row.reason}` : ""}`,
|
||||||
);
|
);
|
||||||
|
if (report.mode === "enforce") {
|
||||||
|
const failed = report.routes.filter((row) => row.status !== "measured");
|
||||||
|
if (failed.length > 0) {
|
||||||
|
console.error(
|
||||||
|
`Performance budgets enforced: ${failed
|
||||||
|
.map((row) => `${row.route} (${row.status})`)
|
||||||
|
.join(", ")}`,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
return report;
|
return report;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -373,7 +373,10 @@ describe("route JS measurement", () => {
|
|||||||
expect(() =>
|
expect(() =>
|
||||||
validateBudgets({ ...config, routes: [budget, budget] }),
|
validateBudgets({ ...config, routes: [budget, budget] }),
|
||||||
).toThrow();
|
).toThrow();
|
||||||
expect(() => validateBudgets({ ...config, mode: "enforce" })).toThrow();
|
expect(validateBudgets({ ...config, mode: "enforce" }).mode).toBe(
|
||||||
|
"enforce",
|
||||||
|
);
|
||||||
|
expect(() => validateBudgets({ ...config, mode: "strict" })).toThrow();
|
||||||
expect(() => validateBudgets({ ...config, typo: true })).toThrow();
|
expect(() => validateBudgets({ ...config, typo: true })).toThrow();
|
||||||
});
|
});
|
||||||
it("emits warnings and artifacts with exit zero when a build is unavailable", () => {
|
it("emits warnings and artifacts with exit zero when a build is unavailable", () => {
|
||||||
@@ -423,4 +426,51 @@ describe("route JS measurement", () => {
|
|||||||
expect(markdown).toContain("not measured browser traffic");
|
expect(markdown).toContain("not measured browser traffic");
|
||||||
expect(markdown).toContain("Raw bytes");
|
expect(markdown).toContain("Raw bytes");
|
||||||
});
|
});
|
||||||
|
describe("enforce mode", () => {
|
||||||
|
const run = (dir, budgets) => {
|
||||||
|
const write = (relative, value) => {
|
||||||
|
const file = path.join(dir, relative);
|
||||||
|
mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
writeFileSync(file, JSON.stringify(value));
|
||||||
|
};
|
||||||
|
write("budgets.json", budgets);
|
||||||
|
return spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
"scripts/performance-report.mjs",
|
||||||
|
"--next-dir",
|
||||||
|
dir,
|
||||||
|
"--config",
|
||||||
|
path.join(dir, "budgets.json"),
|
||||||
|
"--output-dir",
|
||||||
|
path.join(dir, "reports"),
|
||||||
|
],
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
it("passes when every route is measured within budget", () => {
|
||||||
|
const enforce = { ...config, mode: "enforce" };
|
||||||
|
const runResult = run(fixture().dir, enforce);
|
||||||
|
expect(runResult.status).toBe(0);
|
||||||
|
expect(runResult.stdout).toContain("Mode: enforce");
|
||||||
|
expect(runResult.stderr).not.toContain("Performance budgets enforced");
|
||||||
|
});
|
||||||
|
it("fails when a route is over budget", () => {
|
||||||
|
const enforce = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
mode: "enforce",
|
||||||
|
routes: [{ ...budget, maxInitialRawBytes: 1, maxInitialGzipBytes: 1 }],
|
||||||
|
};
|
||||||
|
const runResult = run(fixture().dir, enforce);
|
||||||
|
expect(runResult.status).toBe(1);
|
||||||
|
expect(runResult.stderr).toContain("Performance budgets enforced");
|
||||||
|
expect(runResult.stderr).toContain("/news (over-budget)");
|
||||||
|
});
|
||||||
|
it("fails when a route cannot be measured, so broken manifests do not pass quietly", () => {
|
||||||
|
const enforce = { ...config, mode: "enforce" };
|
||||||
|
const runResult = run(path.join(fixture().dir, "missing"), enforce);
|
||||||
|
expect(runResult.status).toBe(1);
|
||||||
|
expect(runResult.stderr).toContain("/news (unavailable)");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -18,7 +18,7 @@ const hasCompose =
|
|||||||
}).status === 0;
|
}).status === 0;
|
||||||
|
|
||||||
it.skipIf(!hasCompose)(
|
it.skipIf(!hasCompose)(
|
||||||
"loads the loopback profile from .env for bare Compose commands without changing mounts or host networking",
|
"loads the loopback override from .env for the cms service without changing mounts or host networking",
|
||||||
() => {
|
() => {
|
||||||
const directory = mkdtempSync(path.join(tmpdir(), "cms-proxy-config-"));
|
const directory = mkdtempSync(path.join(tmpdir(), "cms-proxy-config-"));
|
||||||
try {
|
try {
|
||||||
@@ -50,9 +50,12 @@ it.skipIf(!hasCompose)(
|
|||||||
["CMS_RELEASE", "HOSTNAME", "PORT"].includes(key)
|
["CMS_RELEASE", "HOSTNAME", "PORT"].includes(key)
|
||||||
)
|
)
|
||||||
delete environment[key];
|
delete environment[key];
|
||||||
|
// `cms` is behind the `cms` profile so a bare `docker compose up -d`
|
||||||
|
// can never squat port 3002 on a CI-managed host. Selecting the
|
||||||
|
// profile here keeps the loopback/port-override contract under test.
|
||||||
const result = spawnSync(
|
const result = spawnSync(
|
||||||
"docker",
|
"docker",
|
||||||
["compose", "config", "--format", "json"],
|
["compose", "--profile", "cms", "config", "--format", "json"],
|
||||||
{ cwd: directory, env: environment, encoding: "utf8", timeout: 15_000 },
|
{ cwd: directory, env: environment, encoding: "utf8", timeout: 15_000 },
|
||||||
);
|
);
|
||||||
expect(result.status, result.stderr).toBe(0);
|
expect(result.status, result.stderr).toBe(0);
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -34,7 +34,7 @@ export async function sendHotelAlert(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
/** Mark every unread ops alert as read. */
|
/** Mark every unread ops alert as read. */
|
||||||
export async function markAllAlertsRead(): Promise<void> {
|
export async function markAllAlertsRead(): Promise<void> {
|
||||||
await requirePermission(PERMS.NOTIFICATIONS_VIEW);
|
await requirePermission(PERMS.NOTIFICATIONS_EDIT);
|
||||||
try {
|
try {
|
||||||
await db
|
await db
|
||||||
.update(AlertLogs)
|
.update(AlertLogs)
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ async function persistSettings(config: AntiddosConfig): Promise<void> {
|
|||||||
* override the proxy reads, so the change is live within the proxy cache TTL.
|
* override the proxy reads, so the change is live within the proxy cache TTL.
|
||||||
*/
|
*/
|
||||||
export async function saveAntiddosSettings(formData: FormData): Promise<void> {
|
export async function saveAntiddosSettings(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
const config = configFromForm(formData);
|
const config = configFromForm(formData);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -165,7 +165,7 @@ export async function saveAntiddosSettings(formData: FormData): Promise<void> {
|
|||||||
* DB-persisted settings. The gate immediately falls back to env ANTI_DDOS_*.
|
* DB-persisted settings. The gate immediately falls back to env ANTI_DDOS_*.
|
||||||
*/
|
*/
|
||||||
export async function resetAntiddosSettings(): Promise<void> {
|
export async function resetAntiddosSettings(): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (redis) await redis.del(OVERRIDE_KEY);
|
if (redis) await redis.del(OVERRIDE_KEY);
|
||||||
@@ -186,7 +186,7 @@ export async function resetAntiddosSettings(): Promise<void> {
|
|||||||
|
|
||||||
/** Remove a single IP from the temporary DDoS block list. */
|
/** Remove a single IP from the temporary DDoS block list. */
|
||||||
export async function unbanAntiddosIp(formData: FormData): Promise<void> {
|
export async function unbanAntiddosIp(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
const ip = str(formData.get("ip")).trim();
|
const ip = str(formData.get("ip")).trim();
|
||||||
if (!ip) return;
|
if (!ip) return;
|
||||||
|
|
||||||
@@ -213,7 +213,7 @@ export async function unbanAntiddosIp(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
/** Remove an automatic Cloudflare edge block for a tracked IP. */
|
/** Remove an automatic Cloudflare edge block for a tracked IP. */
|
||||||
export async function removeCloudflareRule(formData: FormData): Promise<void> {
|
export async function removeCloudflareRule(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
const ip = str(formData.get("ip")).trim();
|
const ip = str(formData.get("ip")).trim();
|
||||||
if (!ip) return;
|
if (!ip) return;
|
||||||
|
|
||||||
@@ -233,7 +233,7 @@ export async function removeCloudflareRule(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
/** Test the configured Cloudflare API credentials against the zone. */
|
/** Test the configured Cloudflare API credentials against the zone. */
|
||||||
export async function verifyCloudflareConfiguration(): Promise<void> {
|
export async function verifyCloudflareConfiguration(): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
const status = await verifyCloudflareConnection();
|
const status = await verifyCloudflareConnection();
|
||||||
await setLastCloudflareVerify(status);
|
await setLastCloudflareVerify(status);
|
||||||
logger.info("Cloudflare API configuration verified", {
|
logger.info("Cloudflare API configuration verified", {
|
||||||
|
|||||||
Loaded 100 of 387 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user