Skip to content

Commit 5ecf1a1

Browse files
feat(parse-server-mongo): real JS line coverage via NODE_V8_COVERAGE
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 <meakash7902@gmail.com>
1 parent 82baa51 commit 5ecf1a1

6 files changed

Lines changed: 383 additions & 166 deletions

File tree

Lines changed: 47 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,49 @@
11
#!/usr/bin/env bash
22
#
3-
# run-and-measure-parse-server.sh — bring parse-server + mongo up via
4-
# the sample's compose, run flow.sh bootstrap + record-traffic with
5-
# the per-call audit log enabled, run flow.sh coverage, and emit
6-
# `coverage=PCT` onto $GITHUB_OUTPUT for the downstream
7-
# coverage-gate job.
3+
# run-and-measure-parse-server.sh — bring parse-server + mongo up
4+
# under the coverage overlay, run flow.sh bootstrap + record-traffic,
5+
# stop parse-server cleanly so V8 flushes NODE_V8_COVERAGE, run
6+
# flow.sh coverage, and emit `coverage=PCT` onto $GITHUB_OUTPUT
7+
# for the downstream coverage-gate job.
88
#
9-
# Called from .github/workflows/parse-server-mongo.yml's
10-
# build-coverage and release-coverage jobs (one per ref under
11-
# comparison). Both jobs source the same script so the
12-
# measurement is identical across refs — any drift in the
13-
# numerator definition would otherwise produce a misleading
14-
# delta.
9+
# Coverage isolation contract:
10+
# * Base `Dockerfile` and `docker-compose.yml` are untouched.
11+
# * The overlay `Dockerfile.coverage` + `docker-compose.coverage.yml`
12+
# installs the V8 coverage entrypoint shim and sets
13+
# NODE_V8_COVERAGE. ONLY this script applies the overlay; the
14+
# keploy/integrations and keploy/enterprise CI lanes consume
15+
# the base compose and pay zero coverage-instrumentation cost.
1516
#
16-
# Inputs (all from the workflow env):
17-
# PARSE_FIRED_ROUTES_FILE — per-call audit log path; passed
18-
# through to flow.sh so its
19-
# record-traffic loop logs each
20-
# (METHOD, URL) pair, and so its
21-
# coverage subcommand uses that
22-
# file as the standalone numerator.
23-
# PARSE_PHASE — label spliced into flow.sh's
24-
# token-file slot so build vs.
25-
# release runs don't collide. Also
26-
# surfaced in the coverage report
27-
# header for log diffing.
28-
# GITHUB_OUTPUT — standard GH Actions sink for step
29-
# outputs.
17+
# Inputs (from the workflow env):
18+
# PARSE_PHASE — label spliced into flow.sh's token-file slot
19+
# so build vs. release runs don't collide.
20+
# GITHUB_OUTPUT — standard GH Actions sink for step outputs.
3021
set -Eeuo pipefail
3122

32-
# Defaults match the sample's docker-compose.yml env-substituted
33-
# vars; exporting them makes it explicit which values the helper
34-
# is pinning so a future compose-side rename doesn't silently
35-
# desync the helper from the sample.
3623
export PARSE_APP_CONTAINER="${PARSE_APP_CONTAINER:-parse-server-mongo-app}"
3724
export PARSE_MONGO_CONTAINER="${PARSE_MONGO_CONTAINER:-parse-server-mongo-mongo}"
3825
export PARSE_HOST_PORT="${PARSE_HOST_PORT:-6100}"
3926
export PARSE_CONTAINER_PORT="${PARSE_CONTAINER_PORT:-6100}"
4027
export PARSE_APP_ID="${PARSE_APP_ID:-keploy-parse-app}"
4128
export PARSE_MASTER_KEY="${PARSE_MASTER_KEY:-keploy-parse-master}"
4229
export PARSE_MOUNT_PATH="${PARSE_MOUNT_PATH:-/parse}"
43-
# flow.sh reads APP_PORT (host-side) for the curl base; keep it
44-
# aligned with PARSE_HOST_PORT.
4530
export APP_PORT="${APP_PORT:-${PARSE_HOST_PORT}}"
46-
: "${PARSE_FIRED_ROUTES_FILE:?PARSE_FIRED_ROUTES_FILE must be set by the workflow}"
4731

48-
# Reset audit log for this run; otherwise a prior run's entries
49-
# would inflate the numerator on a re-trigger.
50-
: >"$PARSE_FIRED_ROUTES_FILE"
32+
mkdir -p coverage
33+
chmod 777 coverage # node UID inside container differs from runner UID
34+
sudo rm -rf coverage/coverage-* coverage/coverage_report.txt coverage/coverage-summary.json 2>/dev/null \
35+
|| rm -rf coverage/coverage-* coverage/coverage_report.txt coverage/coverage-summary.json 2>/dev/null \
36+
|| true
5137

52-
# Bring up parse-server + mongo. The sample's compose builds the
53-
# parse-server image from the Dockerfile in this directory and
54-
# wires mongo via a fixed-IP user-defined network so the URI is
55-
# stable across runs.
56-
docker compose up -d --build
38+
COMPOSE=(docker compose -f docker-compose.yml -f docker-compose.coverage.yml)
5739

58-
# Wait for /parse/health to return 200. Cold parse-server boot on
59-
# a GH runner is mostly mongo init + npm start; budget ~120s.
40+
# Bring up parse-server + mongo under the coverage overlay. The
41+
# Dockerfile.coverage layer wraps node so SIGTERM produces a clean
42+
# `process.exit(0)` (otherwise express's app.listen pins the loop
43+
# and signal-kills bypass V8's coverage flush).
44+
"${COMPOSE[@]}" up -d --build
45+
46+
# Wait for /parse/health to return 200.
6047
for i in $(seq 1 120); do
6148
code=$(curl -sS -o /dev/null -w '%{http_code}' \
6249
-H "X-Parse-Application-Id: ${PARSE_APP_ID}" \
@@ -65,34 +52,35 @@ for i in $(seq 1 120); do
6552
sleep 2
6653
done
6754

68-
# Single-phase: parse-server's compose has no SKIP_INIT-style
69-
# flag (mongo is empty on every fresh `compose up -d`), so
70-
# flow.sh::parse_bootstrap idempotently signs up the fixed user
71-
# and persists the session token under
55+
# Idempotent signup + session-token persistence under
7256
# /tmp/parse-token-${PARSE_PHASE}.
7357
bash flow.sh bootstrap 240
7458

75-
# Drive traffic. flow.sh::parse_record_traffic reads the
76-
# persisted session token and exercises the curated REST +
77-
# GraphQL surface against the running backend.
59+
# Exercise the REST + GraphQL surface.
7860
bash flow.sh record-traffic
7961

80-
# Coverage report — uses PARSE_FIRED_ROUTES_FILE as numerator
81-
# since no keploy/test-set-* tree exists in the standalone case.
82-
# parse_coverage prints to stdout, so tee into the artifact path
83-
# the workflow uploads.
84-
bash flow.sh coverage | tee coverage_report.txt
62+
# Stop parse-server cleanly so the SIGTERM handler's process.exit(0)
63+
# fires and V8 flushes NODE_V8_COVERAGE.
64+
"${COMPOSE[@]}" stop -t 30 parse-server
65+
66+
# Generate the coverage report from the V8 dumps. flow.sh::parse_coverage
67+
# launches a one-off container against the same coverage volume.
68+
bash flow.sh coverage
69+
70+
if [ ! -f coverage_report.txt ]; then
71+
echo "::error::flow.sh coverage produced no coverage_report.txt"
72+
exit 1
73+
fi
8574

86-
# Pull the percentage out of the report's `Covered N/M (XX.X%)`
87-
# line. Anchored on the parenthesised form so a future change to
88-
# the report's prose doesn't break the parse.
75+
# Parse `Covered N/M (XX.X%)` — anchored on the parenthesised form
76+
# so a future report-prose change doesn't break the parse.
8977
pct=$(grep -oE '\([0-9]+\.[0-9]+%\)' coverage_report.txt | head -1 | tr -d '()%')
9078
if [ -z "$pct" ]; then
9179
echo "::error::Could not parse coverage percentage from coverage_report.txt"
9280
cat coverage_report.txt || true
9381
exit 1
9482
fi
9583
echo "coverage=${pct}" >>"$GITHUB_OUTPUT"
96-
echo "coverage: ${pct}% (audit log: $PARSE_FIRED_ROUTES_FILE)"
84+
echo "coverage: ${pct}% (JS line coverage via NODE_V8_COVERAGE + custom report)"
9785

98-
docker compose down -v --remove-orphans
86+
"${COMPOSE[@]}" down -v --remove-orphans

parse-server-mongo/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
coverage/
2+
coverage_report.txt
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Coverage overlay image for parse-server-mongo.
2+
#
3+
# Extends the base sample image build chain (node:20-bookworm-slim +
4+
# parse-server deps + index.js) with c8 (for `c8 report`) and a
5+
# tiny JavaScript entrypoint shim that registers SIGTERM/SIGINT
6+
# handlers calling process.exit(0) — without that, parse-server's
7+
# express server pins the event loop and signal-driven kills
8+
# bypass V8's coverage flush, leaving NODE_V8_COVERAGE empty.
9+
#
10+
# IMPORTANT: this image is only consumed by docker-compose.coverage.yml.
11+
# The base Dockerfile and docker-compose.yml stay uninstrumented so
12+
# enterprise's keploy compat lane pays no coverage-instrumentation
13+
# cost.
14+
FROM node:20-bookworm-slim
15+
16+
RUN apt-get update && \
17+
apt-get install -y --no-install-recommends ca-certificates curl dumb-init && \
18+
rm -rf /var/lib/apt/lists/*
19+
20+
WORKDIR /usr/src/app
21+
22+
COPY package*.json ./
23+
RUN npm install --omit=dev
24+
25+
COPY index.js ./
26+
27+
# c8 is the report generator (we use NODE_V8_COVERAGE for raw data
28+
# collection at runtime, then `c8 report` post-hoc to produce
29+
# json-summary / lcov). Installing globally keeps the app's own
30+
# node_modules byte-identical to the base image.
31+
RUN npm install -g c8@10.1.2
32+
33+
# Graceful-shutdown shim: parse-server's app.listen() pins the
34+
# event loop, so a `compose stop` (SIGTERM) would kill node by
35+
# signal — exit code 143 — before V8's NODE_V8_COVERAGE writer
36+
# runs. Calling process.exit(0) from a SIGTERM handler turns the
37+
# kill into a clean exit, so V8 dumps coverage to NODE_V8_COVERAGE
38+
# before the process terminates.
39+
RUN printf "process.on('SIGTERM', () => process.exit(0));\nprocess.on('SIGINT', () => process.exit(0));\nrequire('/usr/src/app/index.js');\n" \
40+
> /usr/src/app/coverage-entrypoint.js
41+
42+
EXPOSE 1337
43+
44+
ENTRYPOINT ["dumb-init", "--"]
45+
CMD ["node", "/usr/src/app/coverage-entrypoint.js"]
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
#!/usr/bin/env node
2+
//
3+
// coverage-report.js — convert NODE_V8_COVERAGE dumps into a
4+
// `Covered N/M (XX.X%)` line-coverage summary for the running
5+
// `flow.sh coverage` subcommand.
6+
//
7+
// Why a custom tool: c8's report path filters `node_modules/**` by
8+
// default and the include-overrides don't reliably reach into a
9+
// vendored library tree. The V8 data does contain those scripts —
10+
// c8 just refuses to include them. This script reads the same V8
11+
// dumps c8 reads and applies exactly the filter we want.
12+
//
13+
// V8 coverage shape (block-level when NODE_V8_COVERAGE is set):
14+
// each `function.ranges` is a list of nested ranges. The outermost
15+
// range is the function body with its call count. Inner ranges
16+
// flag basic blocks that V8 tracks separately — most of them are
17+
// branches with count=0 that did NOT execute. A byte position's
18+
// effective execution count is determined by the DEEPEST range
19+
// that contains it. We compute line coverage from that.
20+
//
21+
// Inputs (env):
22+
// NODE_V8_COVERAGE — directory containing coverage-*.json
23+
// V8 dumps (default /coverage).
24+
// COVERAGE_INCLUDE — substring; only file URLs containing
25+
// this string contribute to the metric
26+
// (default: "node_modules/parse-server/lib").
27+
// COVERAGE_REPORT_FILE — output path for the human-readable
28+
// summary (default coverage_report.txt
29+
// in CWD).
30+
'use strict';
31+
32+
const fs = require('fs');
33+
const path = require('path');
34+
35+
const dumpDir = process.env.NODE_V8_COVERAGE || '/coverage';
36+
const filter = process.env.COVERAGE_INCLUDE || 'node_modules/parse-server/lib';
37+
const reportFile = process.env.COVERAGE_REPORT_FILE || 'coverage_report.txt';
38+
39+
const dumps = fs.readdirSync(dumpDir)
40+
.filter(f => f.startsWith('coverage-') && f.endsWith('.json'));
41+
42+
if (dumps.length === 0) {
43+
console.log(`INFO: no V8 coverage dumps under ${dumpDir} — base image is uninstrumented (apply docker-compose.coverage.yml overlay to enable)`);
44+
fs.writeFileSync(reportFile, '');
45+
process.exit(0);
46+
}
47+
48+
// Per-file state: source bytes, line-start offsets, totalLines set,
49+
// coveredLines set. Aggregated across multiple V8 dumps and across
50+
// all functions/ranges in each dump.
51+
const files = new Map();
52+
53+
function ensureFileEntry(filepath) {
54+
let entry = files.get(filepath);
55+
if (entry) return entry;
56+
if (!fs.existsSync(filepath)) return null;
57+
const src = fs.readFileSync(filepath, 'utf8');
58+
const lineStartOffsets = [0];
59+
let off = 0;
60+
for (const ch of src) {
61+
off += Buffer.byteLength(ch, 'utf8');
62+
if (ch === '\n') lineStartOffsets.push(off);
63+
}
64+
// totalLines: every line whose source has at least one
65+
// non-whitespace, non-pure-bracket-or-comment char. This
66+
// approximates the "executable line" set Istanbul/coverage.py
67+
// count, with predictable bias on heavily braced styles.
68+
const lines = src.split('\n');
69+
const total = new Set();
70+
for (let i = 0; i < lines.length; i++) {
71+
const trimmed = lines[i].trim();
72+
if (trimmed === '' ||
73+
/^\/\//.test(trimmed) || /^\*/.test(trimmed) || /^\/\*/.test(trimmed) ||
74+
/^[\{\}\)\];,]+$/.test(trimmed)) {
75+
continue;
76+
}
77+
total.add(i + 1);
78+
}
79+
entry = { src, lineStartOffsets, totalLines: total, executedRanges: [] };
80+
files.set(filepath, entry);
81+
return entry;
82+
}
83+
84+
function offsetToLine(entry, byteOffset) {
85+
// Largest i such that lineStartOffsets[i] <= byteOffset.
86+
let lo = 0, hi = entry.lineStartOffsets.length - 1;
87+
while (lo < hi) {
88+
const mid = (lo + hi + 1) >>> 1;
89+
if (entry.lineStartOffsets[mid] <= byteOffset) lo = mid; else hi = mid - 1;
90+
}
91+
return lo + 1;
92+
}
93+
94+
let scriptCount = 0;
95+
let matchingScripts = 0;
96+
for (const dumpName of dumps) {
97+
const data = JSON.parse(fs.readFileSync(path.join(dumpDir, dumpName), 'utf8'));
98+
for (const result of data.result || []) {
99+
scriptCount++;
100+
const url = result.url || '';
101+
if (!url.startsWith('file://')) continue;
102+
if (!url.includes(filter)) continue;
103+
matchingScripts++;
104+
const filepath = url.replace(/^file:\/\//, '');
105+
const entry = ensureFileEntry(filepath);
106+
if (!entry) continue;
107+
// Collect every range; we'll resolve nesting per-line below.
108+
for (const fn of result.functions || []) {
109+
for (const range of fn.ranges || []) {
110+
entry.executedRanges.push({
111+
start: range.startOffset,
112+
end: range.endOffset,
113+
count: range.count,
114+
});
115+
}
116+
}
117+
}
118+
}
119+
120+
// For each file, resolve per-line execution count by finding the
121+
// deepest range containing the line's first non-whitespace byte.
122+
// Deepest = smallest end-start (most specific).
123+
function resolveCoverage(entry) {
124+
const ranges = entry.executedRanges;
125+
// Sort by start asc, end desc — outermost first, innermost last
126+
// when starts tie. (Not strictly required for the per-line
127+
// search below; just makes the data tidy.)
128+
ranges.sort((a, b) => (a.start - b.start) || (b.end - a.end));
129+
130+
const covered = new Set();
131+
for (const lineNum of entry.totalLines) {
132+
const lineStart = entry.lineStartOffsets[lineNum - 1];
133+
// Pick the deepest (smallest-span) range whose [start,end)
134+
// contains lineStart. If none, the line isn't in any
135+
// tracked function — typically top-level module code; treat
136+
// as uncovered. If the deepest range has count > 0, line
137+
// is covered.
138+
let best = null;
139+
let bestSpan = Infinity;
140+
for (const r of ranges) {
141+
if (r.start <= lineStart && lineStart < r.end) {
142+
const span = r.end - r.start;
143+
if (span < bestSpan) {
144+
best = r;
145+
bestSpan = span;
146+
}
147+
}
148+
}
149+
if (best && best.count > 0) covered.add(lineNum);
150+
}
151+
return covered;
152+
}
153+
154+
let totalLines = 0;
155+
let coveredLines = 0;
156+
const perFile = [];
157+
for (const [filepath, entry] of files) {
158+
const covered = resolveCoverage(entry);
159+
totalLines += entry.totalLines.size;
160+
coveredLines += covered.size;
161+
perFile.push({
162+
filepath,
163+
total: entry.totalLines.size,
164+
covered: covered.size,
165+
});
166+
}
167+
168+
const pct = totalLines > 0 ? (coveredLines * 100 / totalLines) : 0;
169+
const pctFmt = pct.toFixed(1);
170+
171+
perFile.sort((a, b) => a.filepath.localeCompare(b.filepath));
172+
173+
const out = [];
174+
out.push('============== parse-server line coverage (V8, custom report) ==============');
175+
out.push(`V8 dumps consumed: ${dumps.length}`);
176+
out.push(`Scripts in V8 dump: ${scriptCount}`);
177+
out.push(`Scripts matching filter: ${matchingScripts}`);
178+
out.push(`Source files measured: ${files.size}`);
179+
out.push('');
180+
out.push('Per-file coverage (top 20 by uncovered lines):');
181+
const sortedByMissing = perFile.slice().sort((a, b) => (b.total - b.covered) - (a.total - a.covered));
182+
for (const r of sortedByMissing.slice(0, 20)) {
183+
const rel = r.filepath.replace(/^.*node_modules\//, '');
184+
const p = r.total > 0 ? (r.covered * 100 / r.total).toFixed(1) : '0.0';
185+
out.push(` ${rel.padEnd(58)} ${String(r.covered).padStart(5)}/${String(r.total).padStart(5)} ${p.padStart(5)}%`);
186+
}
187+
out.push('');
188+
out.push(`Covered ${coveredLines}/${totalLines} (${pctFmt}%)`);
189+
out.push('============================================================================');
190+
191+
const text = out.join('\n') + '\n';
192+
fs.writeFileSync(reportFile, text);
193+
process.stdout.write(text);
194+
195+
// Also drop a c8-style json-summary alongside.
196+
const summary = {
197+
total: {
198+
lines: { total: totalLines, covered: coveredLines, skipped: 0, pct: parseFloat(pctFmt) },
199+
statements: { total: totalLines, covered: coveredLines, skipped: 0, pct: parseFloat(pctFmt) },
200+
functions: { total: 0, covered: 0, skipped: 0, pct: 0 },
201+
branches: { total: 0, covered: 0, skipped: 0, pct: 0 },
202+
},
203+
};
204+
fs.writeFileSync(path.join(dumpDir, 'coverage-summary.json'), JSON.stringify(summary));

0 commit comments

Comments
 (0)