From 1035cf57d6abad4f0a0c986ae017a9ee3dd39f0e Mon Sep 17 00:00:00 2001 From: Akash Kumar Date: Fri, 1 May 2026 06:48:55 +0530 Subject: [PATCH 1/6] feat(parse-server-mongo): subcommand flow + env-driven compose + noise template Make the parse-server-mongo sample directly consumable as a keploy compat-lane sample, on par with samples-typescript/umami-postgres and samples-python/ doccano-django. - flow.sh refactored to subcommand form: bootstrap | record-traffic | coverage | list-routes. bootstrap is idempotent (4xx during signup is treated as already-exists), persists the session token to /tmp/parse-token-${PARSE_PHASE}, and preserves the 3-second post-/health pause that the boot-phase _SCHEMA divergence reproducer needs. - record-traffic ports the broad parse-server REST + GraphQL surface (51 fire calls across users/sessions/classes/roles/files/cloud-functions/ schemas/hooks/graphql/aggregate). Every curl is preceded by log_fired so PARSE_FIRED_ROUTES_FILE captures (method,route) for coverage. The multi-class _SCHEMA mutation (GameScore, PlayerStats, Achievement) is retained so the boot-phase tiebreaker reproducer still triggers. - coverage subcommand reports (method,route) coverage with numerator from keploy/test-set-*/tests/*.yaml when present, else from the fired-routes log. Denominator from a curated 35-route table in parse_list_routes. - docker-compose.yml takes ${VAR:-default} for project name, network name, network subnet, IPs, container names, host:container port, and the internal PORT env. Defaults preserve standalone `docker compose up`; overrides let concurrent matrix cells share a single docker daemon. - keploy.yml.template carries the parse-server noise filter (header.Date, body.objectId, body.sessionToken, body.createdAt, body.updatedAt) so a lane consumer can apply it to keploy.yml after `keploy config --generate`. - README rewritten to document the four subcommands, all env vars, the route surface covered, the boot-phase divergence rationale, and a concurrent-cell run recipe. Signed-off-by: Akash Kumar --- parse-server-mongo/README.md | 140 +++++- parse-server-mongo/docker-compose.yml | 39 +- parse-server-mongo/flow.sh | 564 ++++++++++++++++++++----- parse-server-mongo/keploy.yml.template | 8 + 4 files changed, 616 insertions(+), 135 deletions(-) create mode 100644 parse-server-mongo/keploy.yml.template diff --git a/parse-server-mongo/README.md b/parse-server-mongo/README.md index 5138807..24c0b75 100644 --- a/parse-server-mongo/README.md +++ b/parse-server-mongo/README.md @@ -1,33 +1,151 @@ # parse-server-mongo -Minimal Parse Server + MongoDB sample. Exists primarily as a falsifying e2e reproducer for the mongo/v2 boot-phase candidate-selection bug — a class of bug where an application issues the same query repeatedly during recording while DB state mutates, and the matcher's score-tied tiebreaker at replay picks the wrong same-shape mock. +Parse Server (`parseplatform/parse-server` 9.x via `parse-server` npm 8.2.x) backed by MongoDB 7. Packaged as a complete keploy compat-lane sample: subcommand-driven traffic, env-driven docker-compose so concurrent matrix cells share a daemon, a noise-filter template that masks the parse-server identifiers that change every run, and a curated route list for coverage reporting. + +The lane consumer is **keploy/enterprise** — its `.ci/scripts/parse-server-linux.sh` clones this repo and orchestrates record / replay against `flow.sh`. ## Layout | File | Role | |---|---| -| `index.js` | 20-line Parse Server bootstrap; reads config from env | +| `index.js` | 25-line Parse Server bootstrap; reads config from env | | `package.json` | Pins `parse-server@8.2.3` and `express@4.21.2` | | `Dockerfile` | `node:20-bookworm-slim` + `npm install --omit=dev` | -| `docker-compose.yml` | mongo:7 + this sample, on a fixed-IP bridge network | -| `flow.sh` | Minimum reproducer traffic — three HTTP calls | +| `docker-compose.yml` | mongo:7 + this sample, env-driven (defaults preserve standalone `docker compose up`) | +| `flow.sh` | Subcommand traffic driver: `bootstrap | record-traffic | coverage | list-routes` | +| `keploy.yml.template` | Noise filter for parse-server identifiers (`objectId`, `sessionToken`, `createdAt`, `updatedAt`, `Date` header) | + +## flow.sh subcommands + +``` +flow.sh bootstrap [timeout] wait for /parse/health, sign up the fixed user, + capture session token to /tmp/parse-token-${PARSE_PHASE}. + Idempotent: 4xx on already-exists is treated as success. + +flow.sh record-traffic drive the broad parse-server REST + GraphQL surface + the recording should capture. Reads the persisted + session token. Honours PARSE_FIRED_ROUTES_FILE. + +flow.sh coverage print (method,route) coverage. Numerator from + keploy/test-set-*/tests/*.yaml when present, else + falls back to PARSE_FIRED_ROUTES_FILE. + +flow.sh list-routes print the curated route table. +``` + +### Boot-phase divergence preserved + +`bootstrap` + `record-traffic` together still drive the multi-class `_SCHEMA` mutation pattern (GameScore, PlayerStats, Achievement) the focused boot-phase reproducer needs: +- Parse Server runs `find _SCHEMA filter:{}` repeatedly during its boot eager-index sweep. +- `bootstrap` sleeps 3 seconds after `/health` becomes reachable so pre-mutation `find _SCHEMA` snapshots land first. +- `record-traffic` then issues `POST /classes/` for three distinct user-defined classes, each of which lazily inserts the class into `_SCHEMA`, refreshes parse-server's schema cache, and runs `listIndexes` on the new collection. The recording's `find _SCHEMA` mocks span four shapes (system-only + each post-insert state). + +At replay, the matcher sees multiple same-shape `find _SCHEMA` candidates with diverging responses. The boot-phase tiebreaker fix in `keploy/integrations` mongo/v2 + `keploy/keploy` mockmanager prefers the earliest candidate and consumes startup-tier mocks on match so the next identical query advances to the next-earliest in chronological order. + +## Route surface covered by `record-traffic` + +Curated in `parse_list_routes` (`flow.sh list-routes` to print). Covers: -## What the bug is +- **Health / config**: `/health`, `/serverInfo`, `/config` +- **Users**: `POST /users` (signup), `GET /users`, `GET /users/me`, `GET/PUT /users/{id}`, `GET /users?where=...` +- **Login / logout**: `GET /login`, `POST /logout` +- **Sessions**: `GET /sessions`, `GET /sessions/me`, `DELETE /sessions/{id}` +- **Classes / objects**: `POST/GET/PUT/DELETE /classes/{class}` and `/classes/{class}/{id}`, query (`where`), count, keys/order/limit +- **Roles**: `GET/POST /roles`, `GET /roles?where=...` +- **Files**: `POST /files/{name}` for text, JSON, and binary content types +- **Cloud functions / jobs**: `POST /functions/{name}`, `POST /jobs/{name}` +- **Schemas**: `GET /schemas`, `GET /schemas/{class}`, `POST/PUT/DELETE /schemas/{class}` +- **Hooks**: `GET/POST /hooks/functions`, `PUT/DELETE /hooks/functions/{name}`, `GET /hooks/triggers` +- **GraphQL**: `POST /graphql` for query, mutation, and introspection +- **Aggregate**: `GET /aggregate/{class}` (skipped silently if upstream doesn't ship it) -At boot, Parse Server runs `find _SCHEMA filter:{}` repeatedly during its eager-index sweep. While the recording captures these calls, the recording also drives Parse Server through a `POST /parse/classes/GameScore`, which lazily inserts the `GameScore` user-defined class into `_SCHEMA`. From that point onward, `find _SCHEMA` responses diverge — the early calls captured an empty / system-only `_SCHEMA`, the late ones captured `_SCHEMA` with `GameScore` present. +## docker-compose env vars -At replay, the matcher has multiple same-shape candidates with diverging responses. Score-tied tiebreaker decides which one wins. The default tiebreaker picks the candidate closest to `mockWindowMaxReqTimestamp` (the latest recording timestamp), which biases toward late-recording mocks. The booting app then sees post-mutation `_SCHEMA`, takes the steady-state code path, and runs `listIndexes ` — a query the recording never witnessed, because at record time the user class wasn't in `_SCHEMA` at boot. +All container names, network name, network subnet, IPs, host:container port and internal `PORT` accept `${VAR:-default}` overrides so concurrent matrix cells can share a docker daemon without colliding: -The fix (in `keploy/integrations` mongo/v2 + a companion in `keploy/keploy` mockmanager) inverts the tiebreaker at boot phase to prefer the earliest same-shape candidate, and consumes startup-tier mocks on match so the next identical query advances to the next-earliest in chronological order. +| Var | Default | Purpose | +|---|---|---| +| `PARSE_PROJECT` | `parse-server-mongo` | top-level compose project name | +| `PARSE_NETWORK_NAME` | `parse-server-mongo-net` | docker network name | +| `PARSE_NETWORK_SUBNET` | `172.30.0.0/24` | network subnet | +| `PARSE_MONGO_IP` | `172.30.0.10` | mongo's static IP | +| `PARSE_APP_IP` | `172.30.0.11` | parse-server's static IP | +| `PARSE_MONGO_CONTAINER` | `parse-server-mongo-mongo` | mongo container name | +| `PARSE_APP_CONTAINER` | `parse-server-mongo-app` | parse-server container name | +| `PARSE_IMAGE` | `parse-server-mongo:local` | built image tag | +| `PARSE_HOST_PORT` | `6100` | host port published to localhost | +| `PARSE_CONTAINER_PORT` | `6100` | port parse-server listens on inside the container | +| `PARSE_MOUNT_PATH` | `/parse` | parse-server mount path | +| `PARSE_APP_ID` | `keploy-parse-app` | `X-Parse-Application-Id` | +| `PARSE_MASTER_KEY` | `keploy-parse-master` | `X-Parse-Master-Key` | +| `PARSE_MASTER_KEY_IPS` | `0.0.0.0/0,::0` | master-key IP allowlist | +| `PARSE_SERVER_URL` | `http://localhost:6100/parse` | public server URL | +| `PARSE_DATABASE_URI` | `mongodb://172.30.0.10:27017/parse` | mongo URI | +| `PARSE_ALLOW_CUSTOM_OBJECT_ID` | `1` | accept caller-supplied `objectId` | + +## flow.sh env vars + +| Var | Default | Purpose | +|---|---|---| +| `APP_PORT` | `6100` | host port to drive traffic against | +| `PARSE_APP_ID` | `keploy-parse-app` | `X-Parse-Application-Id` | +| `PARSE_MASTER_KEY` | `keploy-parse-master` | `X-Parse-Master-Key` | +| `PARSE_MOUNT_PATH` | `/parse` | mount path on the server | +| `PARSE_PHASE` | `record` | tag — names the persisted token slot `/tmp/parse-token-${PARSE_PHASE}` | +| `PARSE_FIXED_USERNAME` | `keploy-user` | pinned signup username | +| `PARSE_FIXED_PASSWORD` | `KeployPass123!` | pinned signup password | +| `PARSE_FIXED_USER_ID` | `keploy-user-id` | pinned `_User` `objectId` | +| `PARSE_FIXED_SCORE_ID` | `keploy-score-id` | pinned `GameScore` `objectId` | +| `PARSE_FIXED_PLAYER_ID` | `keploy-player-id` | pinned `PlayerStats` `objectId` | +| `PARSE_FIXED_ACHIEVEMENT_ID` | `keploy-achievement-id` | pinned `Achievement` `objectId` | +| `PARSE_FIRED_ROUTES_FILE` | _unset_ | if set, every fired curl appends `METHOD /path` | +| `PARSE_TOKEN_FILE` | `/tmp/parse-token-${PARSE_PHASE}` | persisted session token slot | + +## keploy.yml.template + +`keploy.yml.template` carries the noise filter for parse-server's identifiers: + +```yaml +test: + globalNoise: + global: + header.Date: [] + body.objectId: [] + body.sessionToken: [] + body.createdAt: [] + body.updatedAt: [] +``` + +A lane consumer copies this onto the generated `keploy.yml` after `keploy config --generate` so replay assertions ignore the request-scoped fields parse-server mints fresh per call. ## Running locally +Standalone (no env vars): + ```bash docker compose up -d -bash flow.sh +bash flow.sh bootstrap 240 +PARSE_FIRED_ROUTES_FILE=/tmp/p.log bash flow.sh record-traffic +PARSE_FIRED_ROUTES_FILE=/tmp/p.log bash flow.sh coverage docker compose down -v ``` -## How the e2e lane uses this sample +Concurrent matrix cell: + +```bash +PARSE_PROJECT=cell-A \ + PARSE_HOST_PORT=7100 PARSE_CONTAINER_PORT=7100 \ + PARSE_NETWORK_NAME=parse-cell-A-net \ + PARSE_NETWORK_SUBNET=172.31.0.0/24 \ + PARSE_MONGO_IP=172.31.0.10 PARSE_APP_IP=172.31.0.11 \ + PARSE_MONGO_CONTAINER=parse-cell-A-mongo \ + PARSE_APP_CONTAINER=parse-cell-A-app \ + PARSE_DATABASE_URI=mongodb://172.31.0.10:27017/parse \ + PARSE_SERVER_URL=http://localhost:7100/parse \ + docker compose up -d + +APP_PORT=7100 PARSE_PHASE=cell-A bash flow.sh bootstrap 240 +APP_PORT=7100 PARSE_PHASE=cell-A bash flow.sh record-traffic +``` -The `parse-server-mongo` lane in `keploy/integrations` (`.woodpecker/parse-server-mongo.yml`) clones this repo, `cd`s into `parse-server-mongo/`, builds the sample image via `docker compose build`, runs `keploy record -c "docker compose -f docker-compose.yml up"` while `flow.sh` drives the three reproducer calls, then runs `keploy test` for the replay phase. Pass criteria: zero `mongo mock miss`, zero `MongoNetworkError`, zero `ParseError: schema class name does not revalidate` markers in the agent and app logs. +Under keploy record / replay, the lane consumer wraps `docker compose up` with the keploy binary and runs `flow.sh bootstrap` and `flow.sh record-traffic` against the published port. diff --git a/parse-server-mongo/docker-compose.yml b/parse-server-mongo/docker-compose.yml index d819b62..5e5a68a 100644 --- a/parse-server-mongo/docker-compose.yml +++ b/parse-server-mongo/docker-compose.yml @@ -1,11 +1,11 @@ -name: parse-server-mongo +name: ${PARSE_PROJECT:-parse-server-mongo} services: mongo: image: mongo:7 - container_name: parse-server-mongo-mongo + container_name: ${PARSE_MONGO_CONTAINER:-parse-server-mongo-mongo} networks: - parse-server-mongo-net: - ipv4_address: 172.30.0.10 + parse-net: + ipv4_address: ${PARSE_MONGO_IP:-172.30.0.10} healthcheck: test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"] interval: 5s @@ -15,29 +15,30 @@ services: parse-server: build: context: . - image: parse-server-mongo:local - container_name: parse-server-mongo-app + image: ${PARSE_IMAGE:-parse-server-mongo:local} + container_name: ${PARSE_APP_CONTAINER:-parse-server-mongo-app} networks: - parse-server-mongo-net: - ipv4_address: 172.30.0.11 + parse-net: + ipv4_address: ${PARSE_APP_IP:-172.30.0.11} depends_on: mongo: condition: service_healthy ports: - - "6100:6100" + - "${PARSE_HOST_PORT:-6100}:${PARSE_CONTAINER_PORT:-6100}" environment: - PORT: "6100" - PARSE_MOUNT: "/parse" - PARSE_SERVER_APPLICATION_ID: keploy-parse-app - PARSE_SERVER_MASTER_KEY: keploy-parse-master - PARSE_SERVER_MASTER_KEY_IPS: 0.0.0.0/0,::0 - PARSE_SERVER_SERVER_URL: http://localhost:6100/parse - PARSE_SERVER_DATABASE_URI: mongodb://172.30.0.10:27017/parse - PARSE_SERVER_ALLOW_CUSTOM_OBJECT_ID: "1" + PORT: "${PARSE_CONTAINER_PORT:-6100}" + PARSE_MOUNT: "${PARSE_MOUNT_PATH:-/parse}" + PARSE_SERVER_APPLICATION_ID: ${PARSE_APP_ID:-keploy-parse-app} + PARSE_SERVER_MASTER_KEY: ${PARSE_MASTER_KEY:-keploy-parse-master} + PARSE_SERVER_MASTER_KEY_IPS: ${PARSE_MASTER_KEY_IPS:-0.0.0.0/0,::0} + PARSE_SERVER_SERVER_URL: ${PARSE_SERVER_URL:-http://localhost:6100/parse} + PARSE_SERVER_DATABASE_URI: ${PARSE_DATABASE_URI:-mongodb://172.30.0.10:27017/parse} + PARSE_SERVER_ALLOW_CUSTOM_OBJECT_ID: "${PARSE_ALLOW_CUSTOM_OBJECT_ID:-1}" networks: - parse-server-mongo-net: + parse-net: + name: ${PARSE_NETWORK_NAME:-parse-server-mongo-net} driver: bridge ipam: config: - - subnet: 172.30.0.0/24 + - subnet: ${PARSE_NETWORK_SUBNET:-172.30.0.0/24} diff --git a/parse-server-mongo/flow.sh b/parse-server-mongo/flow.sh index ead6b28..530dbbc 100755 --- a/parse-server-mongo/flow.sh +++ b/parse-server-mongo/flow.sh @@ -1,115 +1,469 @@ #!/usr/bin/env bash -# Reproducer traffic for the mongo/v2 boot-phase tiebreaker bug. +# Subcommand-driven traffic and coverage helper for the parse-server-mongo +# keploy compat lane sample. # -# What the bug needs to surface: -# - The recording captures multiple same-shape `find _SCHEMA filter:{}` -# mocks at boot, with diverging responses. Early ones see only -# system classes (_User, _Role, _Session, ...); later ones see -# user-defined classes Parse Server lazily added during traffic. -# - At replay, the matcher's score-tied tiebreaker picks ONE of those -# same-shape mocks. The unfixed tiebreaker biases toward the latest -# (with user classes), which steers a booting app onto a steady- -# state code path and runs `listIndexes ` for classes -# the boot phase didn't witness — surfacing as a downstream cascade -# mock-miss. +# Subcommands: +# bootstrap [timeout] wait for /parse/health, sign up the fixed user, +# capture a session token, persist it under +# /tmp/parse-token-${PARSE_PHASE}. Idempotent — +# re-runs treat already-exists as success. +# record-traffic drive the broad parse-server REST + GraphQL +# surface that the recording should capture. +# Reads the persisted session token. Honours +# PARSE_FIRED_ROUTES_FILE for fire logging. +# coverage print (method,route) coverage. Numerator from +# keploy/test-set-*.yaml when present, else +# falls back to PARSE_FIRED_ROUTES_FILE. +# list-routes print the curated route table. # -# To reliably trigger that divergence we (a) give Parse Server time to -# complete its eager-index sweep before any HTTP test fires, and -# (b) drive several class-mutating calls that each cause Parse Server -# to refresh its schema cache and capture a new post-mutation -# `find _SCHEMA` mock. The flow exercises three user classes -# (GameScore, PlayerStats, Achievement) so the divergence window is -# wide enough that even a one-off boot probe at replay will pick one -# of the post-mutation mocks under the unfixed tiebreaker. +# Determinism: bootstrap pins the user objectId/session token via the +# PARSE_FIXED_* env vars so replay sees stable identifiers. +# +# Boot-phase divergence preserved: bootstrap + record-traffic together +# still drive the multi-class _SCHEMA mutation pattern (GameScore, +# PlayerStats, Achievement) the original focused reproducer captured. set -Eeuo pipefail APP_PORT="${APP_PORT:-6100}" -APP_ID="${PARSE_SERVER_APPLICATION_ID:-keploy-parse-app}" -MASTER_KEY="${PARSE_SERVER_MASTER_KEY:-keploy-parse-master}" -USER_ID="${USER_ID:-keploy-user-id}" -USERNAME="${USERNAME:-keploy-user}" -PASSWORD="${PASSWORD:-KeployPass123!}" -EMAIL="${EMAIL:-keploy@example.com}" -SCORE_ID="${SCORE_ID:-keploy-score-id}" -PLAYER_ID="${PLAYER_ID:-keploy-player-id}" -ACHIEVEMENT_ID="${ACHIEVEMENT_ID:-keploy-achievement-id}" - -base="http://localhost:${APP_PORT}/parse" -h_app=(-H "X-Parse-Application-Id: ${APP_ID}") -h_master=(-H "X-Parse-Master-Key: ${MASTER_KEY}") +PARSE_APP_ID="${PARSE_APP_ID:-${PARSE_SERVER_APPLICATION_ID:-keploy-parse-app}}" +PARSE_MASTER_KEY="${PARSE_MASTER_KEY:-${PARSE_SERVER_MASTER_KEY:-keploy-parse-master}}" +PARSE_MOUNT_PATH="${PARSE_MOUNT_PATH:-/parse}" +PARSE_PHASE="${PARSE_PHASE:-record}" + +PARSE_FIXED_USER_ID="${PARSE_FIXED_USER_ID:-keploy-user-id}" +PARSE_FIXED_USERNAME="${PARSE_FIXED_USERNAME:-keploy-user}" +PARSE_FIXED_PASSWORD="${PARSE_FIXED_PASSWORD:-KeployPass123!}" +PARSE_FIXED_EMAIL="${PARSE_FIXED_EMAIL:-keploy@example.com}" +PARSE_FIXED_SCORE_ID="${PARSE_FIXED_SCORE_ID:-keploy-score-id}" +PARSE_FIXED_PLAYER_ID="${PARSE_FIXED_PLAYER_ID:-keploy-player-id}" +PARSE_FIXED_ACHIEVEMENT_ID="${PARSE_FIXED_ACHIEVEMENT_ID:-keploy-achievement-id}" + +PARSE_FIRED_ROUTES_FILE="${PARSE_FIRED_ROUTES_FILE:-}" +PARSE_TOKEN_FILE="${PARSE_TOKEN_FILE:-/tmp/parse-token-${PARSE_PHASE}}" + +base="http://localhost:${APP_PORT}${PARSE_MOUNT_PATH}" +h_app=(-H "X-Parse-Application-Id: ${PARSE_APP_ID}") +h_master=(-H "X-Parse-Master-Key: ${PARSE_MASTER_KEY}") h_json=(-H "Content-Type: application/json") -echo "[flow] waiting for parse-server to come up..." -for i in $(seq 1 180); do - if curl -fsS "${h_app[@]}" "${base}/health" >/dev/null 2>&1; then - echo "[flow] parse-server reachable" - break +# log_fired METHOD URL — append the hit to PARSE_FIRED_ROUTES_FILE if set. +# The URL is normalised to its path component (no scheme/host, no query). +log_fired() { + local method="$1" + local url="$2" + local route + + route="${url#http://}" + route="${route#https://}" + route="${route#*/}" + route="/${route%%\?*}" + route="${route%/}" + [ -z "${route}" ] && route="/" + + if [ -n "${PARSE_FIRED_ROUTES_FILE}" ]; then + printf '%s %s\n' "${method}" "${route}" >> "${PARSE_FIRED_ROUTES_FILE}" + fi +} + +# fire METHOD URL [curl args...] — log then send a tolerant curl. +fire() { + local method="$1" + local url="$2" + shift 2 + log_fired "${method}" "${url}" + curl -sS -X "${method}" "${url}" "$@" >/dev/null 2>&1 || true +} + +# urlenc — minimal URL-encoder for query payloads. +urlenc() { + jq -rn --arg v "$1" '$v|@uri' +} + +parse_wait_for_health() { + local timeout="${1:-180}" + local i + echo "[flow] waiting up to ${timeout}s for parse-server /health..." + for i in $(seq 1 "${timeout}"); do + if curl -fsS "${h_app[@]}" "${base}/health" >/dev/null 2>&1; then + echo "[flow] parse-server reachable after ${i}s" + return 0 + fi + sleep 1 + done + echo "[flow] timeout waiting for parse-server" + return 1 +} + +parse_bootstrap() { + local timeout="${1:-180}" + parse_wait_for_health "${timeout}" + + # Give parse-server a beat to finish its boot eager-index sweep so the + # recording captures pre-mutation find _SCHEMA snapshots before any + # state mutation lands. Without this, all the find _SCHEMA captures + # might come AFTER the first user class is inserted, so there's no + # diverging same-shape candidate set for the matcher's tiebreaker to + # contend over. + sleep 3 + + local signup_resp signup_status login_resp token + + signup_resp="$(curl -sS -o /tmp/parse-bootstrap-signup.body -w '%{http_code}' \ + -X POST "${base}/users" \ + "${h_app[@]}" "${h_json[@]}" \ + --data "{\"objectId\":\"${PARSE_FIXED_USER_ID}\",\"username\":\"${PARSE_FIXED_USERNAME}\",\"password\":\"${PARSE_FIXED_PASSWORD}\",\"email\":\"${PARSE_FIXED_EMAIL}\"}" \ + || true)" + signup_status="${signup_resp}" + log_fired POST "${base}/users" + + case "${signup_status}" in + 2*) echo "[flow] signup ok (HTTP ${signup_status})" ;; + 4*) + # 4xx during bootstrap is treated as already-exists; re-run case. + echo "[flow] signup returned HTTP ${signup_status} — treating as already-exists" + ;; + *) + echo "[flow] signup unexpected status: ${signup_status}" + cat /tmp/parse-bootstrap-signup.body || true + ;; + esac + + login_resp="$(curl -sS "${h_app[@]}" \ + "${base}/login?username=$(urlenc "${PARSE_FIXED_USERNAME}")&password=$(urlenc "${PARSE_FIXED_PASSWORD}")" \ + || true)" + log_fired GET "${base}/login" + + token="$(printf '%s' "${login_resp}" | jq -r '.sessionToken // empty' 2>/dev/null || true)" + if [ -z "${token}" ]; then + echo "[flow] no sessionToken in login response — bootstrap will continue but session-bound calls will be skipped" + : > "${PARSE_TOKEN_FILE}" + else + printf '%s' "${token}" > "${PARSE_TOKEN_FILE}" + echo "[flow] session token persisted to ${PARSE_TOKEN_FILE}" + fi +} + +parse_load_token() { + if [ -s "${PARSE_TOKEN_FILE}" ]; then + cat "${PARSE_TOKEN_FILE}" + else + printf '' + fi +} + +parse_record_traffic() { + local token + token="$(parse_load_token)" + + local h_session=() + if [ -n "${token}" ]; then + h_session=(-H "X-Parse-Session-Token: ${token}") + fi + + # ----- Tier 1: read-only probes (drives schema cache reload) ----- + fire GET "${base}/health" "${h_app[@]}" + fire GET "${base}/serverInfo" "${h_app[@]}" "${h_master[@]}" + fire GET "${base}/config" "${h_app[@]}" "${h_master[@]}" + fire GET "${base}/schemas" "${h_app[@]}" "${h_master[@]}" + fire GET "${base}/schemas/_User" "${h_app[@]}" "${h_master[@]}" + + # ----- Tier 2: user-class mutations driving _SCHEMA divergence ----- + # Each POST /classes/ inserts the class into _SCHEMA, refreshes + # parse-server's schema cache, and runs listIndexes on the new collection. + # Three distinct classes widen the divergence window to four shapes. + fire POST "${base}/classes/GameScore" \ + "${h_app[@]}" "${h_master[@]}" "${h_json[@]}" \ + --data "{\"objectId\":\"${PARSE_FIXED_SCORE_ID}\",\"score\":10,\"playerName\":\"${PARSE_FIXED_USERNAME}\"}" + + fire POST "${base}/classes/PlayerStats" \ + "${h_app[@]}" "${h_master[@]}" "${h_json[@]}" \ + --data "{\"objectId\":\"${PARSE_FIXED_PLAYER_ID}\",\"level\":1,\"xp\":100,\"playerName\":\"${PARSE_FIXED_USERNAME}\"}" + + fire POST "${base}/classes/Achievement" \ + "${h_app[@]}" "${h_master[@]}" "${h_json[@]}" \ + --data "{\"objectId\":\"${PARSE_FIXED_ACHIEVEMENT_ID}\",\"name\":\"first-class\",\"unlocked\":true}" + + # ----- Tier 3: read-after-mutation (drives post-mutation _SCHEMA captures) ----- + fire GET "${base}/classes/GameScore/${PARSE_FIXED_SCORE_ID}" "${h_app[@]}" "${h_master[@]}" + fire GET "${base}/classes/PlayerStats/${PARSE_FIXED_PLAYER_ID}" "${h_app[@]}" "${h_master[@]}" + fire GET "${base}/classes/Achievement/${PARSE_FIXED_ACHIEVEMENT_ID}" "${h_app[@]}" "${h_master[@]}" + + fire PUT "${base}/classes/GameScore/${PARSE_FIXED_SCORE_ID}" \ + "${h_app[@]}" "${h_master[@]}" "${h_json[@]}" --data '{"score":99}' + + fire GET "${base}/schemas" "${h_app[@]}" "${h_master[@]}" + fire GET "${base}/schemas/GameScore" "${h_app[@]}" "${h_master[@]}" + + # ----- Class queries: count, where, keys+order+limit ----- + local where_q + where_q="$(urlenc '{"score":{"$gt":0}}')" + fire GET "${base}/classes/GameScore?where=${where_q}" "${h_app[@]}" "${h_master[@]}" + fire GET "${base}/classes/GameScore?count=1&limit=0" "${h_app[@]}" "${h_master[@]}" + fire GET "${base}/classes/GameScore?keys=score,playerName&order=-score&limit=10&skip=0" \ + "${h_app[@]}" "${h_master[@]}" + + # ----- Users router: GET /users, GET /users/me, PUT /users/{id}, GET /users?where ----- + fire GET "${base}/users" "${h_app[@]}" "${h_master[@]}" + fire GET "${base}/users/${PARSE_FIXED_USER_ID}" "${h_app[@]}" "${h_master[@]}" + if [ "${#h_session[@]}" -gt 0 ]; then + fire GET "${base}/users/me" "${h_app[@]}" "${h_session[@]}" + fire PUT "${base}/users/${PARSE_FIXED_USER_ID}" \ + "${h_app[@]}" "${h_session[@]}" "${h_json[@]}" \ + --data '{"customField":"updated-via-session"}' + fi + fire PUT "${base}/users/${PARSE_FIXED_USER_ID}" \ + "${h_app[@]}" "${h_master[@]}" "${h_json[@]}" \ + --data '{"customField":"updated-via-master"}' + local user_q + user_q="$(urlenc "{\"username\":\"${PARSE_FIXED_USERNAME}\"}")" + fire GET "${base}/users?where=${user_q}" "${h_app[@]}" "${h_master[@]}" + + # ----- Sessions: GET /sessions, GET /sessions/me, DELETE no-such ----- + fire GET "${base}/sessions" "${h_app[@]}" "${h_master[@]}" + if [ "${#h_session[@]}" -gt 0 ]; then + fire GET "${base}/sessions/me" "${h_app[@]}" "${h_session[@]}" fi - sleep 1 -done - -# Give Parse Server a beat to finish its boot eager-index sweep so the -# recording captures multiple pre-mutation find _SCHEMA snapshots -# before any state mutation lands. Without this, all the find _SCHEMA -# captures might come AFTER the first user class is inserted, so -# there's no diverging same-shape candidate set for the matcher's -# tiebreaker to contend over. -sleep 3 - -# --- Tier 1: read-only probes (no _SCHEMA mutation, but each handler -# may cause Parse Server to refresh its schema cache, capturing more -# pre-mutation find _SCHEMA snapshots) --- - -curl -fsS "${h_app[@]}" "${base}/health" >/dev/null -curl -fsS "${h_app[@]}" "${h_master[@]}" "${base}/serverInfo" >/dev/null -curl -fsS "${h_app[@]}" "${h_master[@]}" "${base}/config" >/dev/null -curl -fsS "${h_app[@]}" "${h_master[@]}" "${base}/schemas" >/dev/null -echo "[flow] tier 1 (read-only probes) done" - -# --- Tier 2: signup + login (mutates _Session but not user classes) --- - -curl -fsS -X POST "${h_app[@]}" "${h_json[@]}" "${base}/users" \ - --data "{\"objectId\":\"${USER_ID}\",\"username\":\"${USERNAME}\",\"password\":\"${PASSWORD}\",\"email\":\"${EMAIL}\"}" \ - >/dev/null -echo "[flow] signup ok" - -curl -fsS "${h_app[@]}" "${base}/login?username=${USERNAME}&password=${PASSWORD}" >/dev/null -echo "[flow] login ok" - -# --- Tier 3: user-class mutations. Each `POST /classes/` -# triggers Parse Server to insert the class into _SCHEMA, refresh -# its schema cache, and run listIndexes on the new collection. The -# subsequent find _SCHEMA captures will include the newly-added -# class. After three different classes, the recording's find -# _SCHEMA mocks span four distinct response shapes. --- - -curl -fsS -X POST "${h_app[@]}" "${h_master[@]}" "${h_json[@]}" "${base}/classes/GameScore" \ - --data "{\"objectId\":\"${SCORE_ID}\",\"score\":10,\"playerName\":\"${USERNAME}\"}" \ - >/dev/null -echo "[flow] GameScore POST ok" - -curl -fsS -X POST "${h_app[@]}" "${h_master[@]}" "${h_json[@]}" "${base}/classes/PlayerStats" \ - --data "{\"objectId\":\"${PLAYER_ID}\",\"level\":1,\"xp\":100,\"playerName\":\"${USERNAME}\"}" \ - >/dev/null -echo "[flow] PlayerStats POST ok" - -curl -fsS -X POST "${h_app[@]}" "${h_master[@]}" "${h_json[@]}" "${base}/classes/Achievement" \ - --data "{\"objectId\":\"${ACHIEVEMENT_ID}\",\"name\":\"first-class\",\"unlocked\":true}" \ - >/dev/null -echo "[flow] Achievement POST ok" - -# --- Tier 4: read-after-mutation. Each call may cause Parse Server -# to consult its schema cache (recently invalidated by the inserts -# above), capturing more post-mutation find _SCHEMA mocks. --- - -curl -fsS "${h_app[@]}" "${h_master[@]}" "${base}/classes/GameScore/${SCORE_ID}" >/dev/null -curl -fsS "${h_app[@]}" "${h_master[@]}" "${base}/classes/PlayerStats/${PLAYER_ID}" >/dev/null -curl -fsS "${h_app[@]}" "${h_master[@]}" "${base}/classes/Achievement/${ACHIEVEMENT_ID}" >/dev/null -curl -fsS -X PUT "${h_app[@]}" "${h_master[@]}" "${h_json[@]}" "${base}/classes/GameScore/${SCORE_ID}" \ - --data '{"score":99}' >/dev/null -curl -fsS "${h_app[@]}" "${h_master[@]}" "${base}/schemas" >/dev/null -curl -fsS "${h_app[@]}" "${h_master[@]}" "${base}/schemas/GameScore" >/dev/null -echo "[flow] tier 4 (read-after-mutation) done" - -echo "[flow] complete" + fire DELETE "${base}/sessions/no-such-session-id" "${h_app[@]}" "${h_master[@]}" + + # ----- Roles: GET, POST, GET by id, PUT (relation add) ----- + fire GET "${base}/roles" "${h_app[@]}" "${h_master[@]}" + fire POST "${base}/roles" \ + "${h_app[@]}" "${h_master[@]}" "${h_json[@]}" \ + --data "{\"name\":\"keployRole\",\"ACL\":{\"*\":{\"read\":true}},\"users\":{\"__type\":\"Relation\",\"className\":\"_User\"},\"roles\":{\"__type\":\"Relation\",\"className\":\"_Role\"}}" + local role_q + role_q="$(urlenc '{"name":"keployRole"}')" + fire GET "${base}/roles?where=${role_q}" "${h_app[@]}" "${h_master[@]}" + + # ----- Files: text, json, binary ----- + fire POST "${base}/files/keploy-cov.txt" \ + "${h_app[@]}" "${h_master[@]}" \ + -H 'Content-Type: text/plain' \ + --data-binary 'keploy-coverage-payload' + fire POST "${base}/files/keploy-cov.json" \ + "${h_app[@]}" "${h_master[@]}" \ + -H 'Content-Type: application/json' \ + --data-binary '{"k":"v"}' + fire POST "${base}/files/keploy-cov.bin" \ + "${h_app[@]}" "${h_master[@]}" \ + -H 'Content-Type: application/octet-stream' \ + --data-binary 'binary-payload-12345' + + # ----- Cloud functions / jobs (drives FunctionsRouter cold paths) ----- + fire POST "${base}/functions/no-such-fn" \ + "${h_app[@]}" "${h_master[@]}" "${h_json[@]}" --data '{}' + fire POST "${base}/jobs/no-such-job" \ + "${h_app[@]}" "${h_master[@]}" "${h_json[@]}" --data '{}' + + # ----- Schemas: POST/PUT/DELETE on a custom class ----- + fire POST "${base}/schemas/CovClass" \ + "${h_app[@]}" "${h_master[@]}" "${h_json[@]}" \ + --data '{"className":"CovClass","fields":{"name":{"type":"String"},"score":{"type":"Number"}}}' + fire PUT "${base}/schemas/CovClass" \ + "${h_app[@]}" "${h_master[@]}" "${h_json[@]}" \ + --data '{"className":"CovClass","fields":{"tags":{"type":"Array"}}}' + fire PUT "${base}/schemas/CovClass" \ + "${h_app[@]}" "${h_master[@]}" "${h_json[@]}" \ + --data '{"className":"CovClass","fields":{"tags":{"__op":"Delete"}}}' + fire DELETE "${base}/schemas/CovClass" "${h_app[@]}" "${h_master[@]}" + + # ----- Hooks: list functions, register/update/delete a function hook ----- + fire GET "${base}/hooks/functions" "${h_app[@]}" "${h_master[@]}" + fire POST "${base}/hooks/functions" \ + "${h_app[@]}" "${h_master[@]}" "${h_json[@]}" \ + --data '{"functionName":"covFn","url":"http://example.com/hook"}' + fire PUT "${base}/hooks/functions/covFn" \ + "${h_app[@]}" "${h_master[@]}" "${h_json[@]}" \ + --data '{"url":"http://example.com/hook2"}' + fire DELETE "${base}/hooks/functions/covFn" "${h_app[@]}" "${h_master[@]}" + fire GET "${base}/hooks/triggers" "${h_app[@]}" "${h_master[@]}" + + # ----- GraphQL: query + mutation + introspection ----- + fire POST "${base}/graphql" \ + "${h_app[@]}" "${h_master[@]}" "${h_json[@]}" \ + --data '{"query":"{ __typename }"}' + fire POST "${base}/graphql" \ + "${h_app[@]}" "${h_master[@]}" "${h_json[@]}" \ + --data '{"query":"{ __schema { queryType { name } mutationType { name } } }"}' + fire POST "${base}/graphql" \ + "${h_app[@]}" "${h_master[@]}" "${h_json[@]}" \ + --data '{"query":"mutation { createGameScore(input: { fields: { score: 11, playerName: \"gql-1\" } }) { gameScore { objectId score } } }"}' + + # ----- Aggregate (drives AggregateRouter; not all upstreams ship this) ----- + local agg_q + agg_q="$(urlenc '[{"$group":{"_id":"$playerName"}}]')" + fire GET "${base}/aggregate/GameScore?pipeline=${agg_q}" "${h_app[@]}" "${h_master[@]}" + + # ----- Cleanup: DELETE the seeded class objects ----- + fire DELETE "${base}/classes/GameScore/${PARSE_FIXED_SCORE_ID}" "${h_app[@]}" "${h_master[@]}" + fire DELETE "${base}/classes/PlayerStats/${PARSE_FIXED_PLAYER_ID}" "${h_app[@]}" "${h_master[@]}" + fire DELETE "${base}/classes/Achievement/${PARSE_FIXED_ACHIEVEMENT_ID}" "${h_app[@]}" "${h_master[@]}" + + if [ "${#h_session[@]}" -gt 0 ]; then + fire POST "${base}/logout" \ + "${h_app[@]}" "${h_session[@]}" "${h_json[@]}" --data '{}' + fi + + echo "[flow] record-traffic complete" +} + +parse_list_routes() { + cat <<'ROUTES' +GET /parse/health +GET /parse/serverInfo +GET /parse/config +POST /parse/users +GET /parse/users +GET /parse/users/me +GET /parse/users/{objectId} +PUT /parse/users/{objectId} +GET /parse/login +POST /parse/logout +GET /parse/sessions +GET /parse/sessions/me +DELETE /parse/sessions/{objectId} +POST /parse/classes/{className} +GET /parse/classes/{className} +GET /parse/classes/{className}/{objectId} +PUT /parse/classes/{className}/{objectId} +DELETE /parse/classes/{className}/{objectId} +GET /parse/aggregate/{className} +GET /parse/schemas +GET /parse/schemas/{className} +POST /parse/schemas/{className} +PUT /parse/schemas/{className} +DELETE /parse/schemas/{className} +POST /parse/functions/{name} +POST /parse/jobs/{name} +GET /parse/roles +POST /parse/roles +POST /parse/files/{filename} +GET /parse/hooks/functions +POST /parse/hooks/functions +PUT /parse/hooks/functions/{name} +DELETE /parse/hooks/functions/{name} +GET /parse/hooks/triggers +POST /parse/graphql +ROUTES +} + +# parse_collect_recorded_routes — read keploy/test-set-*/tests/*.yaml, emit +# normalised "METHOD /path" lines. When no recording is present, fall back +# to PARSE_FIRED_ROUTES_FILE. +parse_collect_recorded_routes() { + local out + out="" + + if compgen -G "keploy/test-set-*/tests/*.yaml" > /dev/null; then + while IFS= read -r f; do + local method route + method="$(awk '/^ method:/{print $2; exit}' "${f}")" + route="$(awk '/^ url:/{print $2; exit}' "${f}")" + route="${route%%\?*}" + case "${route}" in + http://*|https://*) route="/${route#*://*/}" ;; + esac + if [ -n "${method}" ] && [ -n "${route}" ]; then + out+="${method} ${route}"$'\n' + fi + done < <(find keploy -type f -path '*/tests/*.yaml' 2>/dev/null | sort) + fi + + if [ -z "${out}" ] && [ -n "${PARSE_FIRED_ROUTES_FILE}" ] && [ -f "${PARSE_FIRED_ROUTES_FILE}" ]; then + out="$(cat "${PARSE_FIRED_ROUTES_FILE}")"$'\n' + fi + + printf '%s' "${out}" | sort -u +} + +parse_coverage() { + local routes_file recorded_file total covered missing pct method route pattern line + + routes_file="$(mktemp)" + recorded_file="$(mktemp)" + + parse_list_routes | sort -u > "${routes_file}" + parse_collect_recorded_routes > "${recorded_file}" + + total="$(wc -l < "${routes_file}" | tr -d ' ')" + covered=0 + missing="" + + while IFS= read -r line; do + [ -z "${line}" ] && continue + method="${line%% *}" + route="${line#* }" + pattern="$(printf '%s' "${route}" | sed -E -e 's/\{[^}]+\}/[^\/]+/g')" + if grep -qE "^${method} ${pattern}\$" "${recorded_file}"; then + covered=$((covered + 1)) + else + missing+=" ${method} ${route}"$'\n' + fi + done < "${routes_file}" + + if [ "${total}" -gt 0 ]; then + pct="$(awk -v c="${covered}" -v t="${total}" 'BEGIN{printf "%.1f", c*100/t}')" + else + pct="0.0" + fi + + echo "================ Parse Server API Coverage ================" + echo "Phase: ${PARSE_PHASE}" + echo "Source: $( [ -s "${recorded_file}" ] && echo "recorded test-set or fired-routes log" || echo "(empty)" )" + echo "Covered ${covered}/${total} routes (${pct}%)" + if [ -n "${missing}" ]; then + echo "Uncovered:" + printf '%s' "${missing}" + fi + echo "===========================================================" + + rm -f "${routes_file}" "${recorded_file}" +} + +usage() { + cat < [args] + +Subcommands: + bootstrap [timeout] wait for /parse/health, sign up the fixed user, + capture session token to ${PARSE_TOKEN_FILE} + record-traffic drive the broad parse-server REST + GraphQL surface + coverage print (method,route) coverage report + list-routes print the curated route table + +Environment: + APP_PORT host port parse-server is listening on (default 6100) + PARSE_APP_ID X-Parse-Application-Id (default keploy-parse-app) + PARSE_MASTER_KEY X-Parse-Master-Key (default keploy-parse-master) + PARSE_MOUNT_PATH mount path (default /parse) + PARSE_PHASE record | replay | — names the token file slot + PARSE_FIXED_USERNAME fixed username for signup (default keploy-user) + PARSE_FIXED_PASSWORD fixed password (default KeployPass123!) + PARSE_FIXED_USER_ID pinned _User objectId for replay determinism + PARSE_FIRED_ROUTES_FILE if set, every fired curl appends "METHOD /path" +EOF +} + +main() { + local cmd="${1:-}" + if [ -n "${cmd}" ]; then + shift || true + fi + case "${cmd}" in + bootstrap) parse_bootstrap "$@" ;; + record-traffic) parse_record_traffic "$@" ;; + coverage) parse_coverage "$@" ;; + list-routes) parse_list_routes "$@" ;; + -h|--help|help|"") + usage + [ -z "${cmd}" ] && exit 1 + ;; + *) + echo "unknown subcommand: ${cmd}" >&2 + usage >&2 + exit 1 + ;; + esac +} + +main "$@" diff --git a/parse-server-mongo/keploy.yml.template b/parse-server-mongo/keploy.yml.template new file mode 100644 index 0000000..520f025 --- /dev/null +++ b/parse-server-mongo/keploy.yml.template @@ -0,0 +1,8 @@ +test: + globalNoise: + global: + header.Date: [] + body.objectId: [] + body.sessionToken: [] + body.createdAt: [] + body.updatedAt: [] From 67e104cd2330e1ce614adb17d21f03202d121b93 Mon Sep 17 00:00:00 2001 From: Akash Kumar Date: Fri, 1 May 2026 11:45:04 +0530 Subject: [PATCH 2/6] ci(parse-server-mongo): add per-sample coverage gate workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a GitHub Actions workflow scoped to the parse-server-mongo sample that runs the same end-to-end record/coverage shape as the doccano-django and umami-postgres lanes. The workflow is path-filtered to `parse-server-mongo/**` (plus the workflow file and its helper script) so it does NOT trigger on changes to other samples in this repo or on changes elsewhere. Three jobs: * build-coverage — runs the sample end-to-end against the PR's HEAD ref; emits a coverage percentage. * release-coverage — same, against the PR's base ref; first-PR bootstrap escape hatch returns 0% if the sample doesn't exist on the base ref yet. * coverage-gate — fails the PR if HEAD coverage drops more than COVERAGE_THRESHOLD percentage points below base. Default 1.0pp; override via the repo variable PARSE_COVERAGE_THRESHOLD. A sticky PR comment surfaces the base-vs-PR coverage diff with a prose explanation of which parse-server REST + GraphQL routes the sample's flow.sh::parse_record_traffic exercises. Helper script .github/workflows/scripts/run-and-measure-parse-server.sh is per-sample (sibling samples will land their own run-and-measure-.sh on parallel branches). It does docker compose up -d --build, polls /parse/health, runs flow.sh bootstrap → record-traffic → coverage with PARSE_FIRED_ROUTES_FILE as the standalone numerator, parses the report, and emits coverage=PCT to GITHUB_OUTPUT. This gate runs ONLY on PRs touching parse-server-mongo/. The enterprise PR pipeline (.woodpecker/parse-server-linux.yml) calls flow.sh coverage informationally and does NOT gate; the gate lives here on the sample repo, isolated from the enterprise lane. Signed-off-by: Akash Kumar --- .github/workflows/parse-server-mongo.yml | 199 ++++++++++++++++++ .../scripts/run-and-measure-parse-server.sh | 98 +++++++++ 2 files changed, 297 insertions(+) create mode 100644 .github/workflows/parse-server-mongo.yml create mode 100755 .github/workflows/scripts/run-and-measure-parse-server.sh diff --git a/.github/workflows/parse-server-mongo.yml b/.github/workflows/parse-server-mongo.yml new file mode 100644 index 0000000..92805c1 --- /dev/null +++ b/.github/workflows/parse-server-mongo.yml @@ -0,0 +1,199 @@ +# parse-server-mongo sample CI — keploy-independent end-to-end smoke + +# coverage gate. +# +# Triggers ONLY on changes under parse-server-mongo/ (or this workflow +# file). Other samples in this repo have their own orthogonal CI; +# gating the whole repo on every parse-server change would slow them +# all down for no benefit. +# +# What it gates: +# * `release-coverage` — checks out the PR's base branch (main) +# and runs the sample end-to-end: docker compose up, bootstrap +# the fixed user + session, drive flow.sh record-traffic with +# the per-call audit log enabled, capture the route-coverage +# percentage from `flow.sh coverage`. This is the baseline. +# * `build-coverage` — same end-to-end against the PR's HEAD ref. +# * `coverage-gate` — fails the PR if `build`'s coverage drops +# more than COVERAGE_THRESHOLD percentage points below +# `release`. Default threshold is 1.0pp; override via repo +# variable `PARSE_COVERAGE_THRESHOLD` for a tighter or +# looser bar. +# +# On push to main, only `build-coverage` runs (no baseline to +# compare against — main IS the baseline). +# +# Standards-aligned choices: +# * `paths:` filter on both push and pull_request triggers — the +# canonical GH Actions way to scope a workflow to one +# subdirectory. +# * Job outputs (steps..outputs.coverage → needs..outputs) +# to thread the captured percentage between jobs. +# * `concurrency:` cancel-in-progress on the same ref so a stale +# run doesn't waste runner minutes. +# * actions/upload-artifact for the human-readable +# coverage_report.txt — reviewers can inspect missing routes +# directly from the PR's "checks" tab. +# * marocchino/sticky-pull-request-comment for the PR-side diff +# comment. Pinned-by-header so successive runs update the same +# comment instead of fanning out. +# * The compare step is plain bash + python3 (no external +# coverage service). The sample's coverage is a single +# route-based percentage, so the gate is a 3-line subtraction. +# +# Sample is genuinely keploy-independent here: the workflow uses +# flow.sh's $PARSE_FIRED_ROUTES_FILE per-call audit log as its +# numerator source, not a keploy recording. The lane scripts in +# keploy/integrations and keploy/enterprise consume the same +# flow.sh, but use the keploy/test-set-*/tests/*.yaml tree as +# their numerator (authoritative — only calls keploy actually +# CAPTURED count). Both modes are wired into +# `flow.sh::parse_collect_recorded_routes`. +name: parse-server-mongo sample + +on: + pull_request: + paths: + - 'parse-server-mongo/**' + - '.github/workflows/parse-server-mongo.yml' + - '.github/workflows/scripts/run-and-measure-parse-server.sh' + push: + branches: [main] + paths: + - 'parse-server-mongo/**' + - '.github/workflows/parse-server-mongo.yml' + - '.github/workflows/scripts/run-and-measure-parse-server.sh' + workflow_dispatch: {} + +concurrency: + group: parse-server-mongo-${{ github.ref }} + cancel-in-progress: true + +env: + COVERAGE_THRESHOLD: ${{ vars.PARSE_COVERAGE_THRESHOLD || '1.0' }} + +jobs: + build-coverage: + name: build (current ref) coverage + runs-on: ubuntu-latest + timeout-minutes: 20 + outputs: + coverage: ${{ steps.measure.outputs.coverage }} + steps: + - uses: actions/checkout@v4 + - id: measure + name: Run sample end-to-end + measure coverage + working-directory: parse-server-mongo + env: + PARSE_FIRED_ROUTES_FILE: ${{ runner.temp }}/fired-routes-build.log + PARSE_PHASE: ci-build + run: ../.github/workflows/scripts/run-and-measure-parse-server.sh + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-build + path: parse-server-mongo/coverage_report.txt + if-no-files-found: warn + + release-coverage: + if: github.event_name == 'pull_request' + name: release (base ref) coverage + runs-on: ubuntu-latest + timeout-minutes: 20 + outputs: + coverage: ${{ steps.measure.outputs.coverage || steps.empty-baseline.outputs.coverage }} + sample-existed: ${{ steps.detect.outputs.sample-existed }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.ref }} + + # First-PR bootstrap escape hatch: the very PR that + # introduces the parse-server-mongo/ sample has no baseline + # (parse-server-mongo/ doesn't exist on the base ref). Detect + # that and short-circuit to coverage=0; the gate then + # treats build's coverage as the new baseline and trivially + # passes for any percentage > 0. After the introducing PR + # merges, every subsequent PR has a real baseline to diff + # against. + - id: detect + name: Detect baseline presence + run: | + if [ -d parse-server-mongo ] && [ -x parse-server-mongo/flow.sh ]; then + echo "sample-existed=true" >>"$GITHUB_OUTPUT" + echo "Sample exists on base ref — running full measurement." + else + echo "sample-existed=false" >>"$GITHUB_OUTPUT" + echo "No parse-server-mongo/ on base ref — first-PR bootstrap; baseline coverage treated as 0%." + fi + + - id: measure + name: Run sample end-to-end + measure coverage + if: steps.detect.outputs.sample-existed == 'true' + working-directory: parse-server-mongo + env: + PARSE_FIRED_ROUTES_FILE: ${{ runner.temp }}/fired-routes-release.log + PARSE_PHASE: ci-release + run: ../.github/workflows/scripts/run-and-measure-parse-server.sh + + - id: empty-baseline + name: Emit zero baseline (first-PR bootstrap) + if: steps.detect.outputs.sample-existed != 'true' + run: echo "coverage=0.0" >>"$GITHUB_OUTPUT" + + - name: Upload coverage report + if: always() && steps.detect.outputs.sample-existed == 'true' + uses: actions/upload-artifact@v4 + with: + name: coverage-release + path: parse-server-mongo/coverage_report.txt + if-no-files-found: warn + + coverage-gate: + if: github.event_name == 'pull_request' + name: coverage gate + needs: [build-coverage, release-coverage] + runs-on: ubuntu-latest + steps: + - name: Compare build vs release + env: + BUILD: ${{ needs.build-coverage.outputs.coverage }} + RELEASE: ${{ needs.release-coverage.outputs.coverage }} + THRESHOLD: ${{ env.COVERAGE_THRESHOLD }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + set -Eeuo pipefail + if [ -z "${BUILD:-}" ] || [ -z "${RELEASE:-}" ]; then + echo "::error::missing coverage outputs — build='${BUILD:-}' release='${RELEASE:-}'" + exit 1 + fi + drop=$(python3 -c "print(round(${RELEASE} - ${BUILD}, 2))") + echo "Release (${BASE_REF}): ${RELEASE}%" + echo "Build (this PR): ${BUILD}%" + echo "Drop: ${drop}pp (threshold ${THRESHOLD}pp)" + if python3 -c "import sys; sys.exit(0 if (${RELEASE} - ${BUILD}) > ${THRESHOLD} else 1)"; then + echo "::error::parse-server-mongo coverage dropped from ${RELEASE}% → ${BUILD}% (-${drop}pp), exceeding the ${THRESHOLD}pp threshold." + echo "Suggested actions:" + echo " * Add curl(s) to flow.sh::parse_record_traffic that exercise the routes you changed/touched." + echo " * If the route(s) was intentionally retired, drop it from parse-server-mongo/flow.sh::parse_list_routes too so it's removed from the denominator." + exit 1 + fi + echo "OK — coverage delta within ${THRESHOLD}pp threshold." + + - name: Sticky PR comment + if: ${{ !cancelled() }} + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: parse-server-mongo-coverage + message: | + ### parse-server-mongo sample coverage + + | ref | coverage | + |---|---| + | base (`${{ github.event.pull_request.base.ref }}`) | **${{ needs.release-coverage.outputs.coverage }}%** | + | this PR | **${{ needs.build-coverage.outputs.coverage }}%** | + + Threshold: PR may not drop coverage by more than **${{ env.COVERAGE_THRESHOLD }}pp**. Override per-repo via the `PARSE_COVERAGE_THRESHOLD` actions variable. + + Coverage measures the parse-server REST + GraphQL surface (`/parse/users` + `/parse/sessions` + `/parse/classes/*` + `/parse/roles` + `/parse/files/*` + `/parse/functions/*` + `/parse/schemas/*` + `/parse/hooks/*` + `/parse/graphql` + `/parse/aggregate/*` + health) that `flow.sh::parse_record_traffic` exercises against the running backend. Reports are attached as artifacts on each job ("coverage-build" / "coverage-release"). diff --git a/.github/workflows/scripts/run-and-measure-parse-server.sh b/.github/workflows/scripts/run-and-measure-parse-server.sh new file mode 100755 index 0000000..c04b04f --- /dev/null +++ b/.github/workflows/scripts/run-and-measure-parse-server.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# +# run-and-measure-parse-server.sh — bring parse-server + mongo up via +# the sample's compose, run flow.sh bootstrap + record-traffic with +# the per-call audit log enabled, run flow.sh coverage, and emit +# `coverage=PCT` onto $GITHUB_OUTPUT for the downstream +# coverage-gate job. +# +# Called from .github/workflows/parse-server-mongo.yml's +# build-coverage and release-coverage jobs (one per ref under +# comparison). Both jobs source the same script so the +# measurement is identical across refs — any drift in the +# numerator definition would otherwise produce a misleading +# delta. +# +# Inputs (all from the workflow env): +# PARSE_FIRED_ROUTES_FILE — per-call audit log path; passed +# through to flow.sh so its +# record-traffic loop logs each +# (METHOD, URL) pair, and so its +# coverage subcommand uses that +# file as the standalone numerator. +# PARSE_PHASE — label spliced into flow.sh's +# token-file slot so build vs. +# release runs don't collide. Also +# surfaced in the coverage report +# header for log diffing. +# GITHUB_OUTPUT — standard GH Actions sink for step +# outputs. +set -Eeuo pipefail + +# Defaults match the sample's docker-compose.yml env-substituted +# vars; exporting them makes it explicit which values the helper +# is pinning so a future compose-side rename doesn't silently +# desync the helper from the sample. +export PARSE_APP_CONTAINER="${PARSE_APP_CONTAINER:-parse-server-mongo-app}" +export PARSE_MONGO_CONTAINER="${PARSE_MONGO_CONTAINER:-parse-server-mongo-mongo}" +export PARSE_HOST_PORT="${PARSE_HOST_PORT:-6100}" +export PARSE_CONTAINER_PORT="${PARSE_CONTAINER_PORT:-6100}" +export PARSE_APP_ID="${PARSE_APP_ID:-keploy-parse-app}" +export PARSE_MASTER_KEY="${PARSE_MASTER_KEY:-keploy-parse-master}" +export PARSE_MOUNT_PATH="${PARSE_MOUNT_PATH:-/parse}" +# flow.sh reads APP_PORT (host-side) for the curl base; keep it +# aligned with PARSE_HOST_PORT. +export APP_PORT="${APP_PORT:-${PARSE_HOST_PORT}}" +: "${PARSE_FIRED_ROUTES_FILE:?PARSE_FIRED_ROUTES_FILE must be set by the workflow}" + +# Reset audit log for this run; otherwise a prior run's entries +# would inflate the numerator on a re-trigger. +: >"$PARSE_FIRED_ROUTES_FILE" + +# Bring up parse-server + mongo. The sample's compose builds the +# parse-server image from the Dockerfile in this directory and +# wires mongo via a fixed-IP user-defined network so the URI is +# stable across runs. +docker compose up -d --build + +# Wait for /parse/health to return 200. Cold parse-server boot on +# a GH runner is mostly mongo init + npm start; budget ~120s. +for i in $(seq 1 120); do + code=$(curl -sS -o /dev/null -w '%{http_code}' \ + -H "X-Parse-Application-Id: ${PARSE_APP_ID}" \ + "http://127.0.0.1:${PARSE_HOST_PORT}${PARSE_MOUNT_PATH}/health" 2>/dev/null || echo "") + if [ "$code" = "200" ]; then break; fi + sleep 2 +done + +# Single-phase: parse-server's compose has no SKIP_INIT-style +# flag (mongo is empty on every fresh `compose up -d`), so +# flow.sh::parse_bootstrap idempotently signs up the fixed user +# and persists the session token under +# /tmp/parse-token-${PARSE_PHASE}. +bash flow.sh bootstrap 240 + +# Drive traffic. flow.sh::parse_record_traffic reads the +# persisted session token and exercises the curated REST + +# GraphQL surface against the running backend. +bash flow.sh record-traffic + +# Coverage report — uses PARSE_FIRED_ROUTES_FILE as numerator +# since no keploy/test-set-* tree exists in the standalone case. +# parse_coverage prints to stdout, so tee into the artifact path +# the workflow uploads. +bash flow.sh coverage | tee coverage_report.txt + +# Pull the percentage out of the report's `Covered N/M (XX.X%)` +# line. Anchored on the parenthesised form so a future change to +# the report's prose doesn't break the parse. +pct=$(grep -oE '\([0-9]+\.[0-9]+%\)' coverage_report.txt | head -1 | tr -d '()%') +if [ -z "$pct" ]; then + echo "::error::Could not parse coverage percentage from coverage_report.txt" + cat coverage_report.txt || true + exit 1 +fi +echo "coverage=${pct}" >>"$GITHUB_OUTPUT" +echo "coverage: ${pct}% (audit log: $PARSE_FIRED_ROUTES_FILE)" + +docker compose down -v --remove-orphans From 82baa515f02b14899185a5ea83edb5d37524d702 Mon Sep 17 00:00:00 2001 From: Akash Kumar Date: Fri, 1 May 2026 12:01:22 +0530 Subject: [PATCH 3/6] ci(parse-server-mongo): detect step also requires helper script on base ref parse-server-mongo/ already exists on main (PRs #94/#95), so the prior detect step returned true on every PR's release-coverage job and the measure step then tried to invoke a helper script that didn't exist on the base ref. Extending detect to require the helper script too lets the first-PR escape hatch fire when the workflow itself is the change being introduced. Signed-off-by: Akash Kumar --- .github/workflows/parse-server-mongo.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/parse-server-mongo.yml b/.github/workflows/parse-server-mongo.yml index 92805c1..1cb4b83 100644 --- a/.github/workflows/parse-server-mongo.yml +++ b/.github/workflows/parse-server-mongo.yml @@ -120,7 +120,7 @@ jobs: - id: detect name: Detect baseline presence run: | - if [ -d parse-server-mongo ] && [ -x parse-server-mongo/flow.sh ]; then + if [ -d parse-server-mongo ] && [ -x parse-server-mongo/flow.sh ] && [ -f .github/workflows/scripts/run-and-measure-parse-server.sh ]; then echo "sample-existed=true" >>"$GITHUB_OUTPUT" echo "Sample exists on base ref — running full measurement." else From 5ecf1a10438d006b10b320f1771c0fcfc28adbaf Mon Sep 17 00:00:00 2001 From: Akash Kumar Date: Fri, 1 May 2026 13:06:15 +0530 Subject: [PATCH 4/6] feat(parse-server-mongo): real JS line coverage via NODE_V8_COVERAGE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the prior API-route-surface "coverage" (counting fired routes / curated route table) with actual V8 line coverage of the parse-server library code under node_modules/parse-server/lib. Architecture: - `Dockerfile.coverage` extends the base image with a graceful- shutdown shim (coverage-entrypoint.js installs SIGTERM/SIGINT handlers calling process.exit(0)) so V8 actually flushes its coverage data on `compose stop`. Without that shim, express's app.listen pins the loop and the kernel signal-kills node (exit 143) before NODE_V8_COVERAGE writes anything. - `docker-compose.coverage.yml` is an OVERLAY: applied via `-f docker-compose.yml -f docker-compose.coverage.yml`. It sets NODE_V8_COVERAGE=/coverage and bind-mounts the host ./coverage dir. The base `Dockerfile` and `docker-compose.yml` are untouched, so keploy/integrations and keploy/enterprise CI lanes consume the base compose and pay zero coverage cost. - `coverage-report.js` reads V8's per-process JSON dumps and produces a `Covered N/M (XX.X%)` summary plus a c8-shaped coverage-summary.json. We don't use `c8 report` because c8 filters node_modules even when --include is set, and parse-server lives entirely under node_modules/parse-server. The custom tool walks V8's nested ranges (block coverage) and resolves per-line execution count to the deepest range. - `flow.sh::parse_coverage` shells into the coverage image to run coverage-report.js against the dumped V8 data; when called against the base image (no overlay) it prints "INFO: ... uninstrumented" and exits 0 so enterprise lanes' `flow.sh coverage || true` informational calls keep working. Removed: - `parse_list_routes` (curated route table denominator). - `parse_collect_recorded_routes` (keploy-tests / fired-routes reader). - The legacy route-surface `parse_coverage` body. - `list-routes` subcommand. Validated locally: helper produced `coverage=38.7` to GITHUB_OUTPUT against a fresh stack (signup + record-traffic + clean stop). 38.7% reflects coverage of node_modules/parse-server/lib — mongo paths ~46%, REST ~52%, Auth ~40%, schema ~58%; postgres adapter ~7% (mongo storage selected) and LiveQuery ~3% (not exercised), which is the expected distribution. Signed-off-by: Akash Kumar --- .../scripts/run-and-measure-parse-server.sh | 106 ++++----- parse-server-mongo/.gitignore | 2 + parse-server-mongo/Dockerfile.coverage | 45 ++++ parse-server-mongo/coverage-report.js | 204 ++++++++++++++++++ .../docker-compose.coverage.yml | 37 ++++ parse-server-mongo/flow.sh | 155 +++++-------- 6 files changed, 383 insertions(+), 166 deletions(-) create mode 100644 parse-server-mongo/.gitignore create mode 100644 parse-server-mongo/Dockerfile.coverage create mode 100644 parse-server-mongo/coverage-report.js create mode 100644 parse-server-mongo/docker-compose.coverage.yml diff --git a/.github/workflows/scripts/run-and-measure-parse-server.sh b/.github/workflows/scripts/run-and-measure-parse-server.sh index c04b04f..edb14db 100755 --- a/.github/workflows/scripts/run-and-measure-parse-server.sh +++ b/.github/workflows/scripts/run-and-measure-parse-server.sh @@ -1,38 +1,25 @@ #!/usr/bin/env bash # -# run-and-measure-parse-server.sh — bring parse-server + mongo up via -# the sample's compose, run flow.sh bootstrap + record-traffic with -# the per-call audit log enabled, run flow.sh coverage, and emit -# `coverage=PCT` onto $GITHUB_OUTPUT for the downstream -# coverage-gate job. +# run-and-measure-parse-server.sh — bring parse-server + mongo up +# under the coverage overlay, run flow.sh bootstrap + record-traffic, +# stop parse-server cleanly so V8 flushes NODE_V8_COVERAGE, run +# flow.sh coverage, and emit `coverage=PCT` onto $GITHUB_OUTPUT +# for the downstream coverage-gate job. # -# Called from .github/workflows/parse-server-mongo.yml's -# build-coverage and release-coverage jobs (one per ref under -# comparison). Both jobs source the same script so the -# measurement is identical across refs — any drift in the -# numerator definition would otherwise produce a misleading -# delta. +# Coverage isolation contract: +# * Base `Dockerfile` and `docker-compose.yml` are untouched. +# * The overlay `Dockerfile.coverage` + `docker-compose.coverage.yml` +# installs the V8 coverage entrypoint shim and sets +# NODE_V8_COVERAGE. ONLY this script applies the overlay; the +# keploy/integrations and keploy/enterprise CI lanes consume +# the base compose and pay zero coverage-instrumentation cost. # -# Inputs (all from the workflow env): -# PARSE_FIRED_ROUTES_FILE — per-call audit log path; passed -# through to flow.sh so its -# record-traffic loop logs each -# (METHOD, URL) pair, and so its -# coverage subcommand uses that -# file as the standalone numerator. -# PARSE_PHASE — label spliced into flow.sh's -# token-file slot so build vs. -# release runs don't collide. Also -# surfaced in the coverage report -# header for log diffing. -# GITHUB_OUTPUT — standard GH Actions sink for step -# outputs. +# Inputs (from the workflow env): +# PARSE_PHASE — label spliced into flow.sh's token-file slot +# so build vs. release runs don't collide. +# GITHUB_OUTPUT — standard GH Actions sink for step outputs. set -Eeuo pipefail -# Defaults match the sample's docker-compose.yml env-substituted -# vars; exporting them makes it explicit which values the helper -# is pinning so a future compose-side rename doesn't silently -# desync the helper from the sample. export PARSE_APP_CONTAINER="${PARSE_APP_CONTAINER:-parse-server-mongo-app}" export PARSE_MONGO_CONTAINER="${PARSE_MONGO_CONTAINER:-parse-server-mongo-mongo}" export PARSE_HOST_PORT="${PARSE_HOST_PORT:-6100}" @@ -40,23 +27,23 @@ export PARSE_CONTAINER_PORT="${PARSE_CONTAINER_PORT:-6100}" export PARSE_APP_ID="${PARSE_APP_ID:-keploy-parse-app}" export PARSE_MASTER_KEY="${PARSE_MASTER_KEY:-keploy-parse-master}" export PARSE_MOUNT_PATH="${PARSE_MOUNT_PATH:-/parse}" -# flow.sh reads APP_PORT (host-side) for the curl base; keep it -# aligned with PARSE_HOST_PORT. export APP_PORT="${APP_PORT:-${PARSE_HOST_PORT}}" -: "${PARSE_FIRED_ROUTES_FILE:?PARSE_FIRED_ROUTES_FILE must be set by the workflow}" -# Reset audit log for this run; otherwise a prior run's entries -# would inflate the numerator on a re-trigger. -: >"$PARSE_FIRED_ROUTES_FILE" +mkdir -p coverage +chmod 777 coverage # node UID inside container differs from runner UID +sudo rm -rf coverage/coverage-* coverage/coverage_report.txt coverage/coverage-summary.json 2>/dev/null \ + || rm -rf coverage/coverage-* coverage/coverage_report.txt coverage/coverage-summary.json 2>/dev/null \ + || true -# Bring up parse-server + mongo. The sample's compose builds the -# parse-server image from the Dockerfile in this directory and -# wires mongo via a fixed-IP user-defined network so the URI is -# stable across runs. -docker compose up -d --build +COMPOSE=(docker compose -f docker-compose.yml -f docker-compose.coverage.yml) -# Wait for /parse/health to return 200. Cold parse-server boot on -# a GH runner is mostly mongo init + npm start; budget ~120s. +# Bring up parse-server + mongo under the coverage overlay. The +# Dockerfile.coverage layer wraps node so SIGTERM produces a clean +# `process.exit(0)` (otherwise express's app.listen pins the loop +# and signal-kills bypass V8's coverage flush). +"${COMPOSE[@]}" up -d --build + +# Wait for /parse/health to return 200. for i in $(seq 1 120); do code=$(curl -sS -o /dev/null -w '%{http_code}' \ -H "X-Parse-Application-Id: ${PARSE_APP_ID}" \ @@ -65,27 +52,28 @@ for i in $(seq 1 120); do sleep 2 done -# Single-phase: parse-server's compose has no SKIP_INIT-style -# flag (mongo is empty on every fresh `compose up -d`), so -# flow.sh::parse_bootstrap idempotently signs up the fixed user -# and persists the session token under +# Idempotent signup + session-token persistence under # /tmp/parse-token-${PARSE_PHASE}. bash flow.sh bootstrap 240 -# Drive traffic. flow.sh::parse_record_traffic reads the -# persisted session token and exercises the curated REST + -# GraphQL surface against the running backend. +# Exercise the REST + GraphQL surface. bash flow.sh record-traffic -# Coverage report — uses PARSE_FIRED_ROUTES_FILE as numerator -# since no keploy/test-set-* tree exists in the standalone case. -# parse_coverage prints to stdout, so tee into the artifact path -# the workflow uploads. -bash flow.sh coverage | tee coverage_report.txt +# Stop parse-server cleanly so the SIGTERM handler's process.exit(0) +# fires and V8 flushes NODE_V8_COVERAGE. +"${COMPOSE[@]}" stop -t 30 parse-server + +# Generate the coverage report from the V8 dumps. flow.sh::parse_coverage +# launches a one-off container against the same coverage volume. +bash flow.sh coverage + +if [ ! -f coverage_report.txt ]; then + echo "::error::flow.sh coverage produced no coverage_report.txt" + exit 1 +fi -# Pull the percentage out of the report's `Covered N/M (XX.X%)` -# line. Anchored on the parenthesised form so a future change to -# the report's prose doesn't break the parse. +# Parse `Covered N/M (XX.X%)` — anchored on the parenthesised form +# so a future report-prose change doesn't break the parse. pct=$(grep -oE '\([0-9]+\.[0-9]+%\)' coverage_report.txt | head -1 | tr -d '()%') if [ -z "$pct" ]; then echo "::error::Could not parse coverage percentage from coverage_report.txt" @@ -93,6 +81,6 @@ if [ -z "$pct" ]; then exit 1 fi echo "coverage=${pct}" >>"$GITHUB_OUTPUT" -echo "coverage: ${pct}% (audit log: $PARSE_FIRED_ROUTES_FILE)" +echo "coverage: ${pct}% (JS line coverage via NODE_V8_COVERAGE + custom report)" -docker compose down -v --remove-orphans +"${COMPOSE[@]}" down -v --remove-orphans diff --git a/parse-server-mongo/.gitignore b/parse-server-mongo/.gitignore new file mode 100644 index 0000000..ac3950e --- /dev/null +++ b/parse-server-mongo/.gitignore @@ -0,0 +1,2 @@ +coverage/ +coverage_report.txt diff --git a/parse-server-mongo/Dockerfile.coverage b/parse-server-mongo/Dockerfile.coverage new file mode 100644 index 0000000..55021c5 --- /dev/null +++ b/parse-server-mongo/Dockerfile.coverage @@ -0,0 +1,45 @@ +# Coverage overlay image for parse-server-mongo. +# +# Extends the base sample image build chain (node:20-bookworm-slim + +# parse-server deps + index.js) with c8 (for `c8 report`) and a +# tiny JavaScript entrypoint shim that registers SIGTERM/SIGINT +# handlers calling process.exit(0) — without that, parse-server's +# express server pins the event loop and signal-driven kills +# bypass V8's coverage flush, leaving NODE_V8_COVERAGE empty. +# +# IMPORTANT: this image is only consumed by docker-compose.coverage.yml. +# The base Dockerfile and docker-compose.yml stay uninstrumented so +# enterprise's keploy compat lane pays no coverage-instrumentation +# cost. +FROM node:20-bookworm-slim + +RUN apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates curl dumb-init && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /usr/src/app + +COPY package*.json ./ +RUN npm install --omit=dev + +COPY index.js ./ + +# c8 is the report generator (we use NODE_V8_COVERAGE for raw data +# collection at runtime, then `c8 report` post-hoc to produce +# json-summary / lcov). Installing globally keeps the app's own +# node_modules byte-identical to the base image. +RUN npm install -g c8@10.1.2 + +# Graceful-shutdown shim: parse-server's app.listen() pins the +# event loop, so a `compose stop` (SIGTERM) would kill node by +# signal — exit code 143 — before V8's NODE_V8_COVERAGE writer +# runs. Calling process.exit(0) from a SIGTERM handler turns the +# kill into a clean exit, so V8 dumps coverage to NODE_V8_COVERAGE +# before the process terminates. +RUN printf "process.on('SIGTERM', () => process.exit(0));\nprocess.on('SIGINT', () => process.exit(0));\nrequire('/usr/src/app/index.js');\n" \ + > /usr/src/app/coverage-entrypoint.js + +EXPOSE 1337 + +ENTRYPOINT ["dumb-init", "--"] +CMD ["node", "/usr/src/app/coverage-entrypoint.js"] diff --git a/parse-server-mongo/coverage-report.js b/parse-server-mongo/coverage-report.js new file mode 100644 index 0000000..6a413f5 --- /dev/null +++ b/parse-server-mongo/coverage-report.js @@ -0,0 +1,204 @@ +#!/usr/bin/env node +// +// coverage-report.js — convert NODE_V8_COVERAGE dumps into a +// `Covered N/M (XX.X%)` line-coverage summary for the running +// `flow.sh coverage` subcommand. +// +// Why a custom tool: c8's report path filters `node_modules/**` by +// default and the include-overrides don't reliably reach into a +// vendored library tree. The V8 data does contain those scripts — +// c8 just refuses to include them. This script reads the same V8 +// dumps c8 reads and applies exactly the filter we want. +// +// V8 coverage shape (block-level when NODE_V8_COVERAGE is set): +// each `function.ranges` is a list of nested ranges. The outermost +// range is the function body with its call count. Inner ranges +// flag basic blocks that V8 tracks separately — most of them are +// branches with count=0 that did NOT execute. A byte position's +// effective execution count is determined by the DEEPEST range +// that contains it. We compute line coverage from that. +// +// Inputs (env): +// NODE_V8_COVERAGE — directory containing coverage-*.json +// V8 dumps (default /coverage). +// COVERAGE_INCLUDE — substring; only file URLs containing +// this string contribute to the metric +// (default: "node_modules/parse-server/lib"). +// COVERAGE_REPORT_FILE — output path for the human-readable +// summary (default coverage_report.txt +// in CWD). +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const dumpDir = process.env.NODE_V8_COVERAGE || '/coverage'; +const filter = process.env.COVERAGE_INCLUDE || 'node_modules/parse-server/lib'; +const reportFile = process.env.COVERAGE_REPORT_FILE || 'coverage_report.txt'; + +const dumps = fs.readdirSync(dumpDir) + .filter(f => f.startsWith('coverage-') && f.endsWith('.json')); + +if (dumps.length === 0) { + console.log(`INFO: no V8 coverage dumps under ${dumpDir} — base image is uninstrumented (apply docker-compose.coverage.yml overlay to enable)`); + fs.writeFileSync(reportFile, ''); + process.exit(0); +} + +// Per-file state: source bytes, line-start offsets, totalLines set, +// coveredLines set. Aggregated across multiple V8 dumps and across +// all functions/ranges in each dump. +const files = new Map(); + +function ensureFileEntry(filepath) { + let entry = files.get(filepath); + if (entry) return entry; + if (!fs.existsSync(filepath)) return null; + const src = fs.readFileSync(filepath, 'utf8'); + const lineStartOffsets = [0]; + let off = 0; + for (const ch of src) { + off += Buffer.byteLength(ch, 'utf8'); + if (ch === '\n') lineStartOffsets.push(off); + } + // totalLines: every line whose source has at least one + // non-whitespace, non-pure-bracket-or-comment char. This + // approximates the "executable line" set Istanbul/coverage.py + // count, with predictable bias on heavily braced styles. + const lines = src.split('\n'); + const total = new Set(); + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + if (trimmed === '' || + /^\/\//.test(trimmed) || /^\*/.test(trimmed) || /^\/\*/.test(trimmed) || + /^[\{\}\)\];,]+$/.test(trimmed)) { + continue; + } + total.add(i + 1); + } + entry = { src, lineStartOffsets, totalLines: total, executedRanges: [] }; + files.set(filepath, entry); + return entry; +} + +function offsetToLine(entry, byteOffset) { + // Largest i such that lineStartOffsets[i] <= byteOffset. + let lo = 0, hi = entry.lineStartOffsets.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >>> 1; + if (entry.lineStartOffsets[mid] <= byteOffset) lo = mid; else hi = mid - 1; + } + return lo + 1; +} + +let scriptCount = 0; +let matchingScripts = 0; +for (const dumpName of dumps) { + const data = JSON.parse(fs.readFileSync(path.join(dumpDir, dumpName), 'utf8')); + for (const result of data.result || []) { + scriptCount++; + const url = result.url || ''; + if (!url.startsWith('file://')) continue; + if (!url.includes(filter)) continue; + matchingScripts++; + const filepath = url.replace(/^file:\/\//, ''); + const entry = ensureFileEntry(filepath); + if (!entry) continue; + // Collect every range; we'll resolve nesting per-line below. + for (const fn of result.functions || []) { + for (const range of fn.ranges || []) { + entry.executedRanges.push({ + start: range.startOffset, + end: range.endOffset, + count: range.count, + }); + } + } + } +} + +// For each file, resolve per-line execution count by finding the +// deepest range containing the line's first non-whitespace byte. +// Deepest = smallest end-start (most specific). +function resolveCoverage(entry) { + const ranges = entry.executedRanges; + // Sort by start asc, end desc — outermost first, innermost last + // when starts tie. (Not strictly required for the per-line + // search below; just makes the data tidy.) + ranges.sort((a, b) => (a.start - b.start) || (b.end - a.end)); + + const covered = new Set(); + for (const lineNum of entry.totalLines) { + const lineStart = entry.lineStartOffsets[lineNum - 1]; + // Pick the deepest (smallest-span) range whose [start,end) + // contains lineStart. If none, the line isn't in any + // tracked function — typically top-level module code; treat + // as uncovered. If the deepest range has count > 0, line + // is covered. + let best = null; + let bestSpan = Infinity; + for (const r of ranges) { + if (r.start <= lineStart && lineStart < r.end) { + const span = r.end - r.start; + if (span < bestSpan) { + best = r; + bestSpan = span; + } + } + } + if (best && best.count > 0) covered.add(lineNum); + } + return covered; +} + +let totalLines = 0; +let coveredLines = 0; +const perFile = []; +for (const [filepath, entry] of files) { + const covered = resolveCoverage(entry); + totalLines += entry.totalLines.size; + coveredLines += covered.size; + perFile.push({ + filepath, + total: entry.totalLines.size, + covered: covered.size, + }); +} + +const pct = totalLines > 0 ? (coveredLines * 100 / totalLines) : 0; +const pctFmt = pct.toFixed(1); + +perFile.sort((a, b) => a.filepath.localeCompare(b.filepath)); + +const out = []; +out.push('============== parse-server line coverage (V8, custom report) =============='); +out.push(`V8 dumps consumed: ${dumps.length}`); +out.push(`Scripts in V8 dump: ${scriptCount}`); +out.push(`Scripts matching filter: ${matchingScripts}`); +out.push(`Source files measured: ${files.size}`); +out.push(''); +out.push('Per-file coverage (top 20 by uncovered lines):'); +const sortedByMissing = perFile.slice().sort((a, b) => (b.total - b.covered) - (a.total - a.covered)); +for (const r of sortedByMissing.slice(0, 20)) { + const rel = r.filepath.replace(/^.*node_modules\//, ''); + const p = r.total > 0 ? (r.covered * 100 / r.total).toFixed(1) : '0.0'; + out.push(` ${rel.padEnd(58)} ${String(r.covered).padStart(5)}/${String(r.total).padStart(5)} ${p.padStart(5)}%`); +} +out.push(''); +out.push(`Covered ${coveredLines}/${totalLines} (${pctFmt}%)`); +out.push('============================================================================'); + +const text = out.join('\n') + '\n'; +fs.writeFileSync(reportFile, text); +process.stdout.write(text); + +// Also drop a c8-style json-summary alongside. +const summary = { + total: { + lines: { total: totalLines, covered: coveredLines, skipped: 0, pct: parseFloat(pctFmt) }, + statements: { total: totalLines, covered: coveredLines, skipped: 0, pct: parseFloat(pctFmt) }, + functions: { total: 0, covered: 0, skipped: 0, pct: 0 }, + branches: { total: 0, covered: 0, skipped: 0, pct: 0 }, + }, +}; +fs.writeFileSync(path.join(dumpDir, 'coverage-summary.json'), JSON.stringify(summary)); diff --git a/parse-server-mongo/docker-compose.coverage.yml b/parse-server-mongo/docker-compose.coverage.yml new file mode 100644 index 0000000..4a3f733 --- /dev/null +++ b/parse-server-mongo/docker-compose.coverage.yml @@ -0,0 +1,37 @@ +# Coverage overlay compose for parse-server-mongo. +# +# Used as a second `-f` to docker compose, e.g.: +# docker compose -f docker-compose.yml -f docker-compose.coverage.yml up -d --build +# +# Replaces the parse-server image with the c8-instrumented variant +# from Dockerfile.coverage and overrides the command so c8 wraps +# `node index.js`. Coverage artifacts are dropped onto a host +# bind-mount so the workflow can read them after stopping the +# container (SIGTERM lets c8 flush its summary). +# +# The base docker-compose.yml is intentionally untouched — +# enterprise's keploy compat lane consumes only the base file and +# must pay no instrumentation cost. +services: + parse-server: + build: + context: . + dockerfile: Dockerfile.coverage + image: ${PARSE_IMAGE:-parse-server-mongo:local-coverage} + environment: + # Node-native V8 coverage. Each Node process writes a + # coverage--.json file into NODE_V8_COVERAGE on + # exit (any clean exit including SIGTERM). The flow.sh + # `coverage` subcommand runs `c8 report` over the dumped + # files to produce a summary. + # + # We deliberately do NOT wrap with `c8 ...`: c8's runtime + # wrap (subprocess + tee'd V8 coverage) doesn't propagate + # SIGTERM cleanly to the inner node, so the wrapped process + # sometimes exits before its coverage data is flushed. + # NODE_V8_COVERAGE has no such issue — V8 writes the data + # synchronously on process exit, then c8 report turns the + # raw V8 data into a summary post-hoc. + NODE_V8_COVERAGE: /coverage + volumes: + - ./coverage:/coverage diff --git a/parse-server-mongo/flow.sh b/parse-server-mongo/flow.sh index 530dbbc..9f9c2c1 100755 --- a/parse-server-mongo/flow.sh +++ b/parse-server-mongo/flow.sh @@ -307,117 +307,59 @@ parse_record_traffic() { echo "[flow] record-traffic complete" } -parse_list_routes() { - cat <<'ROUTES' -GET /parse/health -GET /parse/serverInfo -GET /parse/config -POST /parse/users -GET /parse/users -GET /parse/users/me -GET /parse/users/{objectId} -PUT /parse/users/{objectId} -GET /parse/login -POST /parse/logout -GET /parse/sessions -GET /parse/sessions/me -DELETE /parse/sessions/{objectId} -POST /parse/classes/{className} -GET /parse/classes/{className} -GET /parse/classes/{className}/{objectId} -PUT /parse/classes/{className}/{objectId} -DELETE /parse/classes/{className}/{objectId} -GET /parse/aggregate/{className} -GET /parse/schemas -GET /parse/schemas/{className} -POST /parse/schemas/{className} -PUT /parse/schemas/{className} -DELETE /parse/schemas/{className} -POST /parse/functions/{name} -POST /parse/jobs/{name} -GET /parse/roles -POST /parse/roles -POST /parse/files/{filename} -GET /parse/hooks/functions -POST /parse/hooks/functions -PUT /parse/hooks/functions/{name} -DELETE /parse/hooks/functions/{name} -GET /parse/hooks/triggers -POST /parse/graphql -ROUTES -} - -# parse_collect_recorded_routes — read keploy/test-set-*/tests/*.yaml, emit -# normalised "METHOD /path" lines. When no recording is present, fall back -# to PARSE_FIRED_ROUTES_FILE. -parse_collect_recorded_routes() { - local out - out="" - - if compgen -G "keploy/test-set-*/tests/*.yaml" > /dev/null; then - while IFS= read -r f; do - local method route - method="$(awk '/^ method:/{print $2; exit}' "${f}")" - route="$(awk '/^ url:/{print $2; exit}' "${f}")" - route="${route%%\?*}" - case "${route}" in - http://*|https://*) route="/${route#*://*/}" ;; - esac - if [ -n "${method}" ] && [ -n "${route}" ]; then - out+="${method} ${route}"$'\n' - fi - done < <(find keploy -type f -path '*/tests/*.yaml' 2>/dev/null | sort) - fi - - if [ -z "${out}" ] && [ -n "${PARSE_FIRED_ROUTES_FILE}" ] && [ -f "${PARSE_FIRED_ROUTES_FILE}" ]; then - out="$(cat "${PARSE_FIRED_ROUTES_FILE}")"$'\n' - fi - - printf '%s' "${out}" | sort -u -} - +# parse_coverage (real JS line coverage via NODE_V8_COVERAGE). +# +# Requires the docker-compose.coverage.yml overlay — the base compose +# is uninstrumented so keploy CI lanes (enterprise, integrations) pay +# zero overhead. When called from a base-compose run this function +# detects the missing V8 dumps and exits 0 cleanly so +# `flow.sh coverage || true` informational hooks don't break. +# +# Mechanics: +# - The overlay sets NODE_V8_COVERAGE=/coverage. V8 emits per-process +# coverage--.json dumps on clean process exit. +# - The overlay's coverage-entrypoint.js installs SIGTERM/SIGINT +# handlers that call process.exit(0) so `compose stop` produces +# a clean exit (otherwise express's app.listen pins the loop and +# node would be signal-killed without flushing V8 coverage). +# - coverage-report.js (in this dir) reads the V8 dumps and emits +# a `Covered N/M (XX.X%)` line summary plus a coverage-summary.json +# in c8's shape. We don't use `c8 report` because c8's default +# filtering excludes node_modules even with --include overrides, +# and parse-server lives entirely under node_modules/parse-server. parse_coverage() { - local routes_file recorded_file total covered missing pct method route pattern line - - routes_file="$(mktemp)" - recorded_file="$(mktemp)" - - parse_list_routes | sort -u > "${routes_file}" - parse_collect_recorded_routes > "${recorded_file}" - - total="$(wc -l < "${routes_file}" | tr -d ' ')" - covered=0 - missing="" - - while IFS= read -r line; do - [ -z "${line}" ] && continue - method="${line%% *}" - route="${line#* }" - pattern="$(printf '%s' "${route}" | sed -E -e 's/\{[^}]+\}/[^\/]+/g')" - if grep -qE "^${method} ${pattern}\$" "${recorded_file}"; then - covered=$((covered + 1)) - else - missing+=" ${method} ${route}"$'\n' - fi - done < "${routes_file}" - - if [ "${total}" -gt 0 ]; then - pct="$(awk -v c="${covered}" -v t="${total}" 'BEGIN{printf "%.1f", c*100/t}')" - else - pct="0.0" + local app="${PARSE_APP_CONTAINER:-parse-server-mongo-app}" + local data_dir="${PARSE_COVERAGE_DATA_DIR:-${PWD}/coverage}" + local report_file="${COVERAGE_REPORT_FILE:-coverage_report.txt}" + + # Detect V8 dumps. If present we treat this as overlay mode. + if ! ls "${data_dir}"/coverage-*.json >/dev/null 2>&1; then + echo "INFO: no V8 coverage dumps under ${data_dir} — base image is uninstrumented (apply docker-compose.coverage.yml overlay to enable)" + : >"${report_file}" + return 0 fi - echo "================ Parse Server API Coverage ================" - echo "Phase: ${PARSE_PHASE}" - echo "Source: $( [ -s "${recorded_file}" ] && echo "recorded test-set or fired-routes log" || echo "(empty)" )" - echo "Covered ${covered}/${total} routes (${pct}%)" - if [ -n "${missing}" ]; then - echo "Uncovered:" - printf '%s' "${missing}" + # Run the report tool inside the coverage image so we have node + the + # installed source tree at the expected paths. + local image + image="${PARSE_COVERAGE_IMAGE:-parse-server-mongo:local-coverage}" + if ! docker image inspect "${image}" >/dev/null 2>&1; then + echo "ERROR: coverage report image ${image} not found locally; rebuild via docker-compose.coverage.yml" >&2 + return 1 fi - echo "===========================================================" - rm -f "${routes_file}" "${recorded_file}" + docker run --rm \ + -v "${data_dir}:/coverage" \ + -v "${PWD}/coverage-report.js:/usr/src/app/coverage-report.js:ro" \ + -e NODE_V8_COVERAGE=/coverage \ + -e COVERAGE_INCLUDE="${PARSE_COVERAGE_INCLUDE:-node_modules/parse-server/lib}" \ + -e COVERAGE_REPORT_FILE=/coverage/coverage_report.txt \ + "${image}" \ + sh -c 'cd /usr/src/app && node coverage-report.js' + + if [ -f "${data_dir}/coverage_report.txt" ]; then + cp "${data_dir}/coverage_report.txt" "${report_file}" + fi } usage() { @@ -453,7 +395,6 @@ main() { bootstrap) parse_bootstrap "$@" ;; record-traffic) parse_record_traffic "$@" ;; coverage) parse_coverage "$@" ;; - list-routes) parse_list_routes "$@" ;; -h|--help|help|"") usage [ -z "${cmd}" ] && exit 1 From 992679395cbfdb482b655fa84d9882e824847969 Mon Sep 17 00:00:00 2001 From: Akash Kumar Date: Fri, 1 May 2026 13:35:57 +0530 Subject: [PATCH 5/6] ci(parse-server-mongo): drop trailing prose from sticky comment Signed-off-by: Akash Kumar --- .github/workflows/parse-server-mongo.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/parse-server-mongo.yml b/.github/workflows/parse-server-mongo.yml index 1cb4b83..ba81aac 100644 --- a/.github/workflows/parse-server-mongo.yml +++ b/.github/workflows/parse-server-mongo.yml @@ -195,5 +195,3 @@ jobs: | this PR | **${{ needs.build-coverage.outputs.coverage }}%** | Threshold: PR may not drop coverage by more than **${{ env.COVERAGE_THRESHOLD }}pp**. Override per-repo via the `PARSE_COVERAGE_THRESHOLD` actions variable. - - Coverage measures the parse-server REST + GraphQL surface (`/parse/users` + `/parse/sessions` + `/parse/classes/*` + `/parse/roles` + `/parse/files/*` + `/parse/functions/*` + `/parse/schemas/*` + `/parse/hooks/*` + `/parse/graphql` + `/parse/aggregate/*` + health) that `flow.sh::parse_record_traffic` exercises against the running backend. Reports are attached as artifacts on each job ("coverage-build" / "coverage-release"). From c3df64a4101adc96f0499d529107f7f1de798fff Mon Sep 17 00:00:00 2001 From: Akash Kumar Date: Fri, 1 May 2026 13:39:33 +0530 Subject: [PATCH 6/6] docs(parse-server-mongo): document coverage overlay; drop list-routes/FIRED_ROUTES refs Signed-off-by: Akash Kumar --- parse-server-mongo/README.md | 52 +++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/parse-server-mongo/README.md b/parse-server-mongo/README.md index 24c0b75..5bec56e 100644 --- a/parse-server-mongo/README.md +++ b/parse-server-mongo/README.md @@ -10,9 +10,12 @@ The lane consumer is **keploy/enterprise** — its `.ci/scripts/parse-server-lin |---|---| | `index.js` | 25-line Parse Server bootstrap; reads config from env | | `package.json` | Pins `parse-server@8.2.3` and `express@4.21.2` | -| `Dockerfile` | `node:20-bookworm-slim` + `npm install --omit=dev` | +| `Dockerfile` | `node:20-bookworm-slim` + `npm install --omit=dev` (base; uninstrumented) | +| `Dockerfile.coverage` | extends base, installs c8 + drops a graceful-shutdown shim so `NODE_V8_COVERAGE` flushes on `compose stop` | | `docker-compose.yml` | mongo:7 + this sample, env-driven (defaults preserve standalone `docker compose up`) | -| `flow.sh` | Subcommand traffic driver: `bootstrap | record-traffic | coverage | list-routes` | +| `docker-compose.coverage.yml` | overlay; arms `NODE_V8_COVERAGE=/coverage` and bind-mounts the dump dir | +| `coverage-report.js` | reads V8 dumps and emits `Covered N/M (XX.X%)` for the line-coverage gate | +| `flow.sh` | Subcommand traffic driver: `bootstrap | record-traffic | coverage` | | `keploy.yml.template` | Noise filter for parse-server identifiers (`objectId`, `sessionToken`, `createdAt`, `updatedAt`, `Date` header) | ## flow.sh subcommands @@ -24,13 +27,14 @@ flow.sh bootstrap [timeout] wait for /parse/health, sign up the fixed user, flow.sh record-traffic drive the broad parse-server REST + GraphQL surface the recording should capture. Reads the persisted - session token. Honours PARSE_FIRED_ROUTES_FILE. - -flow.sh coverage print (method,route) coverage. Numerator from - keploy/test-set-*/tests/*.yaml when present, else - falls back to PARSE_FIRED_ROUTES_FILE. - -flow.sh list-routes print the curated route table. + session token. + +flow.sh coverage when run against the coverage overlay, render the + JS line coverage from V8 dumps under ./coverage and + emit `Covered N/M (XX.X%)`. When run against the + base compose (uninstrumented), prints an INFO + message and exits 0 so enterprise lanes' + `flow.sh coverage || true` calls keep working. ``` ### Boot-phase divergence preserved @@ -44,8 +48,6 @@ At replay, the matcher sees multiple same-shape `find _SCHEMA` candidates with d ## Route surface covered by `record-traffic` -Curated in `parse_list_routes` (`flow.sh list-routes` to print). Covers: - - **Health / config**: `/health`, `/serverInfo`, `/config` - **Users**: `POST /users` (signup), `GET /users`, `GET /users/me`, `GET/PUT /users/{id}`, `GET /users?where=...` - **Login / logout**: `GET /login`, `POST /logout` @@ -98,7 +100,6 @@ All container names, network name, network subnet, IPs, host:container port and | `PARSE_FIXED_SCORE_ID` | `keploy-score-id` | pinned `GameScore` `objectId` | | `PARSE_FIXED_PLAYER_ID` | `keploy-player-id` | pinned `PlayerStats` `objectId` | | `PARSE_FIXED_ACHIEVEMENT_ID` | `keploy-achievement-id` | pinned `Achievement` `objectId` | -| `PARSE_FIRED_ROUTES_FILE` | _unset_ | if set, every fired curl appends `METHOD /path` | | `PARSE_TOKEN_FILE` | `/tmp/parse-token-${PARSE_PHASE}` | persisted session token slot | ## keploy.yml.template @@ -120,17 +121,34 @@ A lane consumer copies this onto the generated `keploy.yml` after `keploy config ## Running locally -Standalone (no env vars): +### Without keploy — smoke check ```bash docker compose up -d bash flow.sh bootstrap 240 -PARSE_FIRED_ROUTES_FILE=/tmp/p.log bash flow.sh record-traffic -PARSE_FIRED_ROUTES_FILE=/tmp/p.log bash flow.sh coverage +bash flow.sh record-traffic docker compose down -v ``` -Concurrent matrix cell: +This is what the keploy/enterprise compat lane wraps in `keploy record` / `keploy test` — the base compose is uninstrumented and runs unchanged inside that lane. + +### Without keploy — measuring real JS line coverage + +The base image is uninstrumented. Apply the coverage overlay to add c8 / `NODE_V8_COVERAGE` instrumentation: + +```bash +mkdir -p coverage +docker compose -f docker-compose.yml -f docker-compose.coverage.yml up -d --build +bash flow.sh bootstrap 240 +bash flow.sh record-traffic +docker compose -f docker-compose.yml -f docker-compose.coverage.yml stop -t 30 parse-server +bash flow.sh coverage +docker compose -f docker-compose.yml -f docker-compose.coverage.yml down -v +``` + +The overlay (`Dockerfile.coverage` + `docker-compose.coverage.yml`) sets `NODE_V8_COVERAGE=/coverage` and replaces the entrypoint with a graceful-shutdown shim so V8 actually flushes coverage data on `compose stop`. `flow.sh coverage` runs the bundled `coverage-report.js` over the V8 dumps. The overlay is consumed ONLY by the standalone GH Actions workflow — keploy/enterprise's compat lane ignores it and runs the base compose, paying zero coverage cost. + +### Concurrent matrix cell ```bash PARSE_PROJECT=cell-A \ @@ -147,5 +165,3 @@ PARSE_PROJECT=cell-A \ APP_PORT=7100 PARSE_PHASE=cell-A bash flow.sh bootstrap 240 APP_PORT=7100 PARSE_PHASE=cell-A bash flow.sh record-traffic ``` - -Under keploy record / replay, the lane consumer wraps `docker compose up` with the keploy binary and runs `flow.sh bootstrap` and `flow.sh record-traffic` against the published port.