Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .github/workflows/loadtest.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# SSE streaming load test: k6 against the /chat streaming endpoint.
#
# ON-DEMAND ONLY, and this workflow boots NOTHING itself: you point it at a
# stack that is already running — use the staging stack from PR #210, or
# any dev deployment you own. It exists because the SSE chat stream is the
# product's hot path and has bitten us before (stream timeouts on long tool
# calls); loadtest/sse-stream.js measures time-to-first-byte and whether
# streams actually deliver events through to [DONE] under concurrency.
#
# NEVER point this at production: every iteration creates a real chat row
# and burns real LLM tokens on the target. Also raise RATE_LIMIT_CHAT_MAX
# on the target stack first (default is 30 req / 15 min / IP — the runner
# is one IP, so the default rate limit fails the test immediately).
#
# Setup (once): add a repository secret LOADTEST_AUTH_TOKEN containing a
# Supabase access token for a throwaway test user on the target stack. The
# token is a secret (not a workflow input) so it never appears in run logs.
name: SSE load test

on:
workflow_dispatch:
inputs:
target_url:
description: "Backend base URL of the (staging) stack to hit, e.g. https://staging-api.example.com — see PR #210"
required: true
type: string
vus:
description: "Peak concurrent SSE streams"
required: false
default: "5"
type: string
hold_duration:
description: "How long to hold peak concurrency (k6 duration, e.g. 2m)"
required: false
default: "2m"
type: string

jobs:
loadtest:
name: k6 SSE stream load test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

# Official k6 image — no toolchain to install, runner's docker only.
# Thresholds live in loadtest/sse-stream.js and are deliberately
# lenient (documented there and in docs/test-depth.md); a red run
# means "streams are hanging or erroring", not "we missed an SLO".
- name: Run k6 SSE scenario
env:
BASE_URL: ${{ inputs.target_url }}
AUTH_TOKEN: ${{ secrets.LOADTEST_AUTH_TOKEN }}
VUS: ${{ inputs.vus }}
HOLD_DURATION: ${{ inputs.hold_duration }}
run: |
# -u: the image's default user can't write summary.json into the
# runner-owned workspace mount.
docker run --rm -i -u "$(id -u):$(id -g)" \
-e BASE_URL -e AUTH_TOKEN -e VUS -e HOLD_DURATION \
-v "$PWD:/work" -w /work \
grafana/k6:latest run loadtest/sse-stream.js \
--summary-export summary.json || status=$?
# k6 exit 99 = thresholds crossed (results still valid); anything
# else is a harness/target failure. Both FAIL the job — the
# thresholds are this workflow's only detector for hung/failed
# streams, so a crossed threshold must never read as green. The
# summary artifact still uploads (that step is if: always()).
if [ "${status:-0}" = "99" ]; then
echo "::error::k6 thresholds crossed — streams hung or failed under load; see the k6-summary artifact"
fi
if [ "${status:-0}" != "0" ]; then
exit "${status}"
fi

- name: Upload k6 summary
if: always()
uses: actions/upload-artifact@v4
with:
name: k6-summary
path: summary.json
if-no-files-found: warn
56 changes: 56 additions & 0 deletions .github/workflows/mutation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Mutation testing: prove the security-critical unit tests assert behavior.
#
# Stryker mutates src/lib/access.ts, downloadTokens.ts, safeError.ts and
# chat/citations.ts (scope defined in backend/stryker.config.json) and
# re-runs the vitest suite per mutant. A surviving mutant = a behavior
# change (e.g. an access check inverted) that no test caught.
#
# ON-DEMAND, NOT a merge gate: it takes ~10 minutes of CI per run, which is
# too slow to put in front of every PR for a solo maintainer. Run it
# manually from the Actions tab, or let the monthly cron catch drift. The
# `thresholds.break` in stryker.config.json fails the run if the mutation
# score regresses below the documented floor. See docs/test-depth.md.
name: Mutation testing

on:
workflow_dispatch:
schedule:
# Monthly, on the 3rd at 06:17 UTC (odd minute to dodge the top-of-hour
# GitHub cron rush). Drift check only — failures show up in the Actions
# tab, they block nothing.
- cron: "17 6 3 * *"

jobs:
mutation:
name: Backend mutation testing (security libs)
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: backend/package-lock.json

# Same silent-merge-corruption guard as ci.yml (bit PR #233).
- name: Validate package.json and lockfile parse
run: node -e "for (const f of ['package.json','package-lock.json']) JSON.parse(require('fs').readFileSync(f, 'utf8'))"

- run: npm ci

- run: npm run test:mutation

# The HTML report is the useful output: per-file, per-mutant detail
# of what survived. Upload even when the score check fails — a failed
# run is exactly when you want to read it.
- name: Upload mutation report
if: always()
uses: actions/upload-artifact@v4
with:
name: mutation-report
path: backend/reports/mutation/
if-no-files-found: warn
5 changes: 5 additions & 0 deletions backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,8 @@ dist
*.raw-llm-stream.json
logs/
.DS_Store

# Stryker mutation-testing output
reports/
.stryker-tmp/
stryker.log
Loading
Loading