diff --git a/.github/workflows/playwright-tests.yml b/.github/workflows/playwright-tests.yml index 9876efa6b..05eba5a64 100644 --- a/.github/workflows/playwright-tests.yml +++ b/.github/workflows/playwright-tests.yml @@ -7,9 +7,40 @@ on: branches: [master, main, development] jobs: + # Unit tests (Vitest + jsdom, no DB/browser). Run once, outside the matrix: + # they already cover both modes in a single run by reloading the mode module + # per test. See tests/unit/deploymentMode.spec.js and bakeGuards.spec.js. + unit: + timeout-minutes: 10 + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run Unit Tests + run: npx vitest run + + # E2e tests boot the real app. Mode is fixed at process start, so we boot once + # per mode via the matrix. MMGIS_DEPLOYMENT_MODE is written into .env below. test: + name: e2e (${{ matrix.mode }}) timeout-minutes: 20 runs-on: ubuntu-latest + needs: unit + + strategy: + fail-fast: false + matrix: + mode: [full, lean] services: postgres: @@ -39,12 +70,6 @@ jobs: - name: Install dependencies run: npm ci - # Unit tests run under Vitest in a jsdom environment — hermetic, so they - # need neither the database nor a browser. Run them before the e2e-only - # setup so a DB/browser problem can't mask a unit result. - - name: Run Unit Tests - run: npx vitest run - - name: Install Playwright Browsers run: npx playwright install --with-deps chromium @@ -62,32 +87,58 @@ jobs: echo "ENABLE_MMGIS_WEBSOCKETS=false" >> .env echo "ENABLE_CONFIG_WEBSOCKETS=false" >> .env echo "HIDE_CONFIG=true" >> .env + echo "MMGIS_DEPLOYMENT_MODE=${{ matrix.mode }}" >> .env + + # Lean deploys no sidecars. sample.env ships them ON; left on, init-db would + # try to create the mmgis-stac catalog DB. Turn them off so the lean env + # matches a real lean deployment. + - name: Disable sidecar services for the lean leg + if: matrix.mode == 'lean' + run: | + echo "WITH_STAC=false" >> .env + echo "WITH_TIPG=false" >> .env + echo "WITH_TITILER=false" >> .env + echo "WITH_TITILER_PGSTAC=false" >> .env + echo "WITH_VELOSERVER=false" >> .env + # In lean, also confirms the mmgis-stac catalog DB is not created (full-only). - name: Initialize Database run: node scripts/init-db.js - # The e2e suite drives the real front-end at `/`, which the server renders - # from build/index.pug. Without this build step the route 500s, every test - # times out waiting for window.mmgisAPI, and the job is cancelled at the - # 20-minute cap. Build must run before the tests start the server. + # A feature gated OFF in the current mode still keeps its DB tables (both + # directions: datasets/geodatasets/draw/shortener off in lean, deployments + # off in full). Models always register; only routes are gated (ADR D2). + # Fails CI if an expected table is missing. + - name: Assert gated tables exist + run: node tests/ci/assert-gated-tables.js + + # Must build before the server starts: the e2e tests hit `/`, which 500s + # without build/index.pug. - name: Build frontend - # GitHub Actions auto-sets CI=true, which makes react-scripts promote all - # build warnings to errors. MMGIS ships several intentional warnings (the - # optional dist/ theme assets and the unshipped AOI counties/cities geojson - # dirs loaded via require.context). The production Dockerfile builds with CI - # unset, so mirror that here — otherwise the build fails on non-fatal warnings. + # CI=false: GitHub sets CI=true, which makes react-scripts fail on MMGIS's + # intentional build warnings. The production Dockerfile builds with CI unset. run: npm run build env: CI: false - - name: Run E2E Tests - run: npx playwright test tests/e2e --project=chromium + # Legacy smoke / event-bus specs. Gating (no continue-on-error), matching + # their pre-matrix behavior on development — a real regression here fails + # the leg. They boot the same app per mode, so they also confirm smoke + + # event bus pass under lean, not just full. + - name: Run legacy E2E Tests + run: npx playwright test tests/e2e/smoke.spec.js tests/e2e/eventbus-integration.spec.js --project=chromium + + # Gating check: each feature's route must be mounted (non-404) in its mode + # and absent (404) otherwise. Checks mount vs. 404 only, not behavior. No + # continue-on-error, so mis-gating fails the leg. + - name: Run deployment-mode shape check (gating) + run: npx playwright test tests/e2e/deployment-mode.spec.js --project=chromium - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: - name: playwright-report + name: playwright-report-${{ matrix.mode }} path: playwright-report/ retention-days: 30 @@ -95,6 +146,6 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: test-results + name: test-results-${{ matrix.mode }} path: test-results/ retention-days: 7 diff --git a/README.md b/README.md index 4b1b66e2d..b11bca79f 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,17 @@ --- +## Deployment modes + +MMGIS builds from one codebase into two deployment shapes, selected by the `MMGIS_DEPLOYMENT_MODE` environment variable: + +- **`full`** (default) — the complete MMGIS application as shipped today. Used when the variable is unset. +- **`lean`** — a lighter, smaller-footprint deployment that deliberately turns a set of server features off (geodata management, the bundled sidecar services/proxy, the on-disk mission filesystem, the link shortener, server-side raster utilities) and turns on the dashboard publish flow. Database tables for the gated-off features are still created (only the server routes are gated off), so they sit unused in lean rather than being absent. + +CI runs the end-to-end / boot suite once per mode. + +--- + ## Installation --- diff --git a/configure/package.json b/configure/package.json index b920c8496..854d0d6d3 100644 --- a/configure/package.json +++ b/configure/package.json @@ -1,6 +1,6 @@ { "name": "configure", - "version": "4.2.11-20260611", + "version": "4.2.13-20260701", "homepage": "./configure/build", "private": true, "dependencies": { diff --git a/package.json b/package.json index 89f96140f..01b8efcaa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mmgis", - "version": "4.2.11-20260611", + "version": "4.2.13-20260701", "description": "A web-based mapping and localization solution for science operation on planetary missions.", "homepage": "build", "repository": { diff --git a/playwright.config.js b/playwright.config.js index 0024e1fad..f780ea34a 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -1,3 +1,8 @@ +// Load .env so the test process sees the same vars as the server — notably +// MMGIS_DEPLOYMENT_MODE, which deployment-mode.spec.js reads from process.env. +// Without this the spec defaults to 'full' and the lean CI leg tests the wrong +// mode. Mirrors scripts/server.js, which also loads .env via dotenv. +import "dotenv/config"; import { defineConfig, devices } from "@playwright/test"; /** diff --git a/tests/ci/assert-gated-tables.js b/tests/ci/assert-gated-tables.js new file mode 100644 index 000000000..73c31572f --- /dev/null +++ b/tests/ci/assert-gated-tables.js @@ -0,0 +1,76 @@ +/** + * assert-gated-tables.js + * + * A feature gated OFF in the current mode still keeps its DB tables: models + * register and sync() runs unconditionally on boot, only route mounts are gated + * (ADR D2). This pins that — dropping a model, or gating its registration, fails + * CI in the leg where the feature is off. + * + * Boots the backend setups (registering models), runs sync(), and exits non-zero + * if any gated-feature table is missing. + * + * Must run AFTER scripts/init-db.js: sync() creates PostGIS geometry columns, so + * the postgis extension must exist first. + */ + +require("dotenv").config({ path: __dirname + "/../../.env" }); + +const { MODE } = require("../../API/Backend/Utils/deploymentMode"); +const setups = require("../../API/setups"); +const { sequelize } = require("../../API/connection"); + +// Hand-written: tables behind features gated off in one mode or the other; they +// must exist in BOTH. Accept either spelling where Sequelize may pluralize. +const REQUIRED_TABLE_GROUPS = [ + ["datasets"], // geodata management (datasets) + ["geodatasets"], // geodata management (geodatasets) + ["user_files"], // on-disk mission filesystem / drawing + ["user_features"], // drawing (vector features) + ["url_shorteners", "url_shortener"], // link shortener + ["deployments"], // lean-only dashboard publish flow +]; + +async function main() { + await new Promise((resolve) => { + // Loading the setups requires each feature's models, registering them. + setups.getBackendSetups(() => resolve()); + }); + + await sequelize.authenticate(); + await sequelize.sync(); + + const tables = await sequelize + .getQueryInterface() + .showAllTables(); + const present = new Set(tables.map((t) => String(t).toLowerCase())); + + const missing = []; + for (const group of REQUIRED_TABLE_GROUPS) { + const found = group.some((name) => present.has(name.toLowerCase())); + if (!found) missing.push(group.join(" | ")); + } + + if (missing.length > 0) { + console.error( + `[assert-gated-tables] MODE=${MODE}: missing expected table(s): ${missing.join( + ", " + )}` + ); + console.error( + `[assert-gated-tables] tables present: ${[...present] + .sort() + .join(", ")}` + ); + process.exit(1); + } + + console.log( + `[assert-gated-tables] MODE=${MODE}: all ${REQUIRED_TABLE_GROUPS.length} gated-feature table groups present.` + ); + process.exit(0); +} + +main().catch((err) => { + console.error("[assert-gated-tables] Unexpected failure:", err); + process.exit(1); +}); diff --git a/tests/e2e/deployment-mode.spec.js b/tests/e2e/deployment-mode.spec.js new file mode 100644 index 000000000..cd1ad9af5 --- /dev/null +++ b/tests/e2e/deployment-mode.spec.js @@ -0,0 +1,122 @@ +import { test, expect, request } from '@playwright/test' + +/** + * Deployment-mode present/absent checks. Boots once per mode in CI; per feature, + * asserts its route is reachable in the mode it belongs to and gone otherwise. + * + * The on/off mapping below is hand-written from the deployment ADR. Do NOT + * rewrite it to read expectations from a capability table — a test that takes its + * answers from the code under test can't catch a wrong entry. + * + * Discriminator: a mounted route answers from its handler (never 404); an + * unmounted route hits the catch-all `app.all('*')` -> 404. + */ + +const MODE = process.env.MMGIS_DEPLOYMENT_MODE || 'full' + +// Each feature: which mode it belongs to, and one HTTP probe against a route it +// owns. We only care whether that route is mounted (non-404) or absent (404). +const FEATURES = [ + // --- Full-only: present in full, absent in lean --- + { + name: 'geodata management (datasets)', + mode: 'full', + method: 'post', + // ensureAdmin allow-lists /get, so it reaches the router without auth. + path: '/api/datasets/get', + }, + { + name: 'geodata management (geodatasets)', + mode: 'full', + method: 'get', + // ensureAdmin allow-lists /api/geodatasets/get. + path: '/api/geodatasets/get', + }, + { + name: 'drawing (draw API)', + mode: 'full', + method: 'post', + path: '/api/draw/add', + }, + { + name: 'on-disk mission filesystem (files API)', + mode: 'full', + method: 'post', + path: '/api/files/getfiles', + }, + { + name: 'link shortener', + mode: 'full', + method: 'post', + path: '/api/shortener/shorten', + }, + { + name: 'server-side raster utilities', + mode: 'full', + method: 'post', + // /api/utils mounts in both modes, but its raster endpoints are isFull()-only. + path: '/api/utils/getbands', + }, + { + name: 'bundled sidecar services / proxy (titiler)', + mode: 'full', + method: 'get', + // /titiler proxy needs isFull() AND WITH_TITILER=true; lean has neither. + path: '/titiler/healthz', + }, + + // --- Lean-only: present in lean, absent in full --- + { + name: 'dashboard publish flow (deployments)', + mode: 'lean', + method: 'get', + // Mounted only when isLean(); ensureAdmin rejects with 200 (not 404). + path: '/api/deployments', + }, +] + +async function probe(api, feature) { + const res = + feature.method === 'post' + ? await api.post(feature.path, { data: {} }) + : await api.get(feature.path) + return res.status() +} + +test.describe(`Deployment mode present/absent — MODE=${MODE}`, () => { + let api + + test.beforeAll(async () => { + api = await request.newContext({ + baseURL: process.env.TEST_BASE_URL || 'http://localhost:8888', + ignoreHTTPSErrors: true, + }) + }) + + test.afterAll(async () => { + await api.dispose() + }) + + for (const feature of FEATURES) { + const belongsToRunningMode = feature.mode === MODE + + test(`${feature.name} is ${belongsToRunningMode ? 'present' : 'absent' + } in ${MODE}`, async () => { + const status = await probe(api, feature) + + if (belongsToRunningMode) { + // Mounted: any status but the catch-all 404. + expect( + status, + `${feature.name} should be MOUNTED in ${MODE} (got ${status}); a 404 means the route is gone` + ).not.toBe(404) + } else { + // Absent: falls through to the app catch-all 404. + expect( + status, + `${feature.name} should be ABSENT in ${MODE} (got ${status}); anything but 404 means the route is still mounted` + ).toBe(404) + } + }) + } +})