Skip to content

Instantly share code, notes, and snippets.

@tomschall
Last active October 1, 2025 09:34
Show Gist options
  • Select an option

  • Save tomschall/7dbf5e2ac39dd2daa264bf7af7680f3a to your computer and use it in GitHub Desktop.

Select an option

Save tomschall/7dbf5e2ac39dd2daa264bf7af7680f3a to your computer and use it in GitHub Desktop.

Dev-Deploy Smoke – ohne E2E

Ziel: Jeden Dev-Deploy leichtgewichtig absichern – ohne Cypress/Playwright‑Suite. Fokus: API ok? Seiten rendern? A11y ok? Links nicht tot?

TL;DR

  • 3–4 kleine Skripte + 1 Job in GitLab.
  • Laufzeit: ~2–3 Minuten pro Deploy.
  • Wartungsarm, Agentur muss keine E2E liefern.

Dependencies

pnpm add -D cheerio linkinator pa11y # Kern-Snippets
# optional für Mini-Browser-Smoke
pnpm add -D playwright

Struktur (Beispiel):

scripts/
  smoke/
    api.smoke.mjs
    html.smoke.mjs
    links.smoke.mjs
    browser.smoke.ts    # optional

Setze die Zielbasis via ENV: BASE=https://dev.example.com.


1) API‑Contract Smoke (Pflicht)

Sichert: Status 200, Shape/Keys, Cache‑Header.

// scripts/smoke/api.smoke.mjs
const BASE = process.env.BASE || 'https://dev.example.com'
const must = (cond, msg) => { if (!cond) { console.error(msg); process.exit(1) } }

const check = async (path, validate) => {
  const res = await fetch(BASE + path)
  must(res.status === 200, `${path}: expected 200, got ${res.status}`)
  await validate(res)
  console.log(`✓ ${path}`)
}

await check('/++api++/@search?portal_type=Document', async (r) => {
  const js = await r.json()
  must(Array.isArray(js.items), '@search: items[] missing')
  const cc = r.headers.get('cache-control') || ''
  must(/max-age|s-maxage/i.test(cc), '@search: Cache-Control missing max-age/s-maxage')
})

await check('/++api++/@navigation?root=/', async (r) => {
  const js = await r.json()
  must(Array.isArray(js.items) && js.items.length > 0, '@navigation: no items')
})

2) HTML‑Smoke (SSR)

Sichert: Seiten rendern (SSR). Nutzt Cheerio (kein Browser).

// scripts/smoke/html.smoke.mjs
import * as cheerio from 'cheerio'
const BASE = process.env.BASE || 'https://dev.example.com'
const must = (c,m) => { if(!c){ console.error(m); process.exit(1) } }

const probe = async (path, sel, why) => {
  const res = await fetch(BASE + path)
  must(res.status === 200, `${path}: HTTP ${res.status}`)
  const html = await res.text()
  const $ = cheerio.load(html)
  must($(sel).length > 0, `${path}: selector ${sel} missing (${why})`)
  console.log(`✓ ${path} has ${sel}`)
}

await probe('/', 'header nav', 'Navigation sichtbar')
await probe('/de/search', 'h1', 'H1 vorhanden')
await probe('/de/story/beispiel', 'main article', 'Story-Container sichtbar')

⚠️ Wenn SSR aus: Diesen Check überspringen oder unten Mini‑Browser‑Smoke verwenden.


3) A11y‑Smoke (pa11y)

Sichert: WCAG2AA – Errors auf Kernseiten (nur Errors zählen).

npx pa11y "$BASE/" --standard WCAG2AA
npx pa11y "$BASE/de/search" --standard WCAG2AA
npx pa11y "$BASE/de/kontakt" --standard WCAG2AA

4) Link‑Smoke (flach, same‑origin)

Sichert: keine 404/500 auf Home + eine Ebene Tiefe.

// scripts/smoke/links.smoke.mjs
import { LinkChecker } from 'linkinator'
const BASE = process.env.BASE || 'https://dev.example.com'
const checker = new LinkChecker()
const result = await checker.check({ path: BASE + '/', recurse: true, maxDepth: 1, retry: true })
const bad = result.links.filter(l => !l.state || l.state.status !== 'OK')
if (bad.length) {
  console.error('Broken links:', bad.map(b => `${b.url} (${b.status})`).join('\n'))
  process.exit(1)
}
console.log('✓ Links OK')

(Optional) 5) Mini‑Browser‑Smoke (Playwright, ~20 Zeilen)

Minimaler „lebt‑die‑App?“-Check ohne E2E‑Suite.

// scripts/smoke/browser.smoke.ts
import { chromium } from 'playwright'
const BASE = process.env.BASE || 'https://dev.example.com'
const must = (c,m) => { if(!c){ console.error(m); process.exit(1)} }

const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(BASE + '/')
must(await page.getByRole('navigation').count() > 0, 'Nav fehlt')
await page.getByRole('link', { name: /story|geschichten|news/i }).first().click().catch(()=>{})
await page.waitForLoadState('networkidle')
must(await page.getByRole('heading', { level: 1 }).count() > 0, 'H1 auf Detail fehlt')
await browser.close()
console.log('✓ Mini-Browser-Smoke OK')

GitLab CI – Job nach Dev‑Deploy

# .gitlab-ci.yml (Ausschnitt)
stages: [build, deploy, postdeploy]

dev-smoke:
  stage: postdeploy
  needs: ["deploy-dev"]                 # ↩︎ euren Deploy-Job eintragen
  image: cr.gitlab.fhnw.ch/webteam/fhnw-webauftritt-2025/node:22.15.0
  rules:
    - if: '$CI_COMMIT_BRANCH =~ /^(develop|redesign|main)$/'
  variables:
    BASE: "https://dev.example.com"     # ↩︎ eure Dev-URL
  script:
    - pnpm i --frozen-lockfile
    - node scripts/smoke/api.smoke.mjs
    - node scripts/smoke/html.smoke.mjs
    - npx pa11y "$BASE/" --standard WCAG2AA
    - node scripts/smoke/links.smoke.mjs
    # optional:
    # - pnpm tsx scripts/smoke/browser.smoke.ts
  allow_failure: true                    # Woche 1–2 soft, dann entfernen
  artifacts:
    when: always
    expire_in: 1 day

Post‑Deploy Timing – wie starte ich „nachdem es live ist“?

A) Einfach: verzögert starten

rules:
  - if: '$CI_COMMIT_BRANCH == "main"'
    when: delayed
    start_in: 7 minutes   # Puffer passend zu eurer Deploy-Dauer

B) Robust: auf Revision warten (SHA Check)

Beim Build eine kleine Metadatei ausliefern und danach darauf warten.

Build‑Snippet (FE):

node -e "require('fs').writeFileSync('build/_build.json', JSON.stringify({sha: process.env.CI_COMMIT_SHORT_SHA, built: new Date().toISOString()}))"

Wait‑Script:

# scripts/wait-for-deploy.sh
#!/usr/bin/env bash
set -euo pipefail
BASE="${1:?BASE missing}"; SHA="${2:?SHA missing}"; TIMEOUT="${3:-600}"
deadline=$(( $(date +%s) + TIMEOUT ))
echo "Waiting for $SHA on $BASE/_build.json (timeout ${TIMEOUT}s)…"
while true; do
  if data=$(curl -sf "$BASE/_build.json"); then
    live=$(echo "$data" | sed -n 's/.*"sha" *: *"\([^"]*\)".*/\1/p')
    if [[ "$live" == "$SHA" ]]; then
      echo "OK: live SHA = $live"; exit 0
    else
      echo "still $live"
    fi
  else
    echo "endpoint not ready…"
  fi
  if (( $(date +%s) > deadline )); then echo "Timeout waiting for $SHA"; exit 1; fi
  sleep 10
done

YAML:

wait-for-dev:
  stage: postdeploy
  image: curlimages/curl:8.8.0
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
      when: delayed
      start_in: 5 minutes
  variables:
    BASE: "https://dev.example.com"
  script:
    - apk add --no-cache bash
    - ./scripts/wait-for-deploy.sh "$BASE" "$CI_COMMIT_SHORT_SHA" 900

dev-smoke:
  stage: postdeploy
  needs: ["wait-for-dev"]
  # ... (wie oben)

C) Webhook: CD triggert Smoke-Pipeline

  • GitLab Trigger-Token anlegen, CD ruft nach Deploy:
curl -X POST \
  -F token=$DEV_SMOKE_TRIGGER_TOKEN \
  -F ref=main \
  -F "variables[PIPELINE_KIND]=DEV_SMOKE" \
  https://gitlab.example.com/api/v4/projects/<PROJECT_ID>/trigger/pipeline
  • In .gitlab-ci.yml:
workflow:
  rules:
    - if: '$PIPELINE_KIND == "DEV_SMOKE"'
    - when: never

Nutzen

  • API: echte Endpoints, Header & Shape ok → Backend/Proxy/CDN korrekt.
  • HTML/SSR: Seiten rendern → Templating/Routing in Ordnung.
  • A11y: grobe WCAG‑Fehler früh sichtbar.
  • Links: keine offensichtlichen 404/500 nach Content‑Änderungen.

Startet mit allow_failure: true. Wenn stabil → hart schalten. Drei Skripte, großer Effekt.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment