smoke-api.sh 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. #!/usr/bin/env bash
  2. # End-to-end check of the chart API against the committed fixture.
  3. #
  4. # Every expected number below is worked out by hand from scripts/fixture.sql —
  5. # the header comment there lists all twelve machines and what each one does — so
  6. # a failure here means the SQL changed its mind, not that a golden file drifted.
  7. #
  8. # ./scripts/smoke-api.sh (or: npm run smoke:api)
  9. set -uo pipefail
  10. cd "$(dirname "$0")/.."
  11. # Deliberately NOT $PORT — see smoke-auth.sh.
  12. DASH_PORT="${DASH_PORT:-8789}"
  13. BASE="http://127.0.0.1:${DASH_PORT}"
  14. PASSWORD="$(grep '^ADMIN_PASSWORD=' .dev.vars | cut -d'"' -f2)"
  15. JAR="$(mktemp -t cg-api-jar)"
  16. LOG="$(mktemp -t cg-api-log)"
  17. PASS=0
  18. FAIL=0
  19. # The fixture's own window. Every assertion is scoped to it, so a later fixture
  20. # row outside these days cannot silently change an expected number.
  21. FROM=2026-07-01
  22. TO=2026-07-10
  23. RANGE="from=$FROM&to=$TO"
  24. cleanup() {
  25. [[ -n "${DEV_PID:-}" ]] && kill "$DEV_PID" 2>/dev/null
  26. rm -f "$JAR" "$LOG"
  27. }
  28. trap cleanup EXIT
  29. status() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
  30. get() { curl -s -b "$JAR" "$BASE$1"; }
  31. # Resolves a dotted path through the JSON. Numeric segments index arrays, so
  32. # `datasets.0.data` works. Node rather than jq: this is a Node project, jq is not.
  33. jget() {
  34. node -e '
  35. let v = JSON.parse(process.argv[1]);
  36. for (const key of process.argv[2].split(".")) v = v?.[key];
  37. console.log(v === undefined ? "<missing>" : typeof v === "object" && v !== null ? JSON.stringify(v) : String(v));
  38. ' "$1" "$2"
  39. }
  40. check() { # check <description> <expected> <actual>
  41. if [[ "$2" == "$3" ]]; then
  42. printf ' ok %s\n' "$1"
  43. PASS=$((PASS + 1))
  44. else
  45. printf ' FAIL %s (expected %s, got %s)\n' "$1" "$2" "$3"
  46. FAIL=$((FAIL + 1))
  47. fi
  48. }
  49. field() { # field <description> <path> <expected> <json>
  50. check "$1" "$3" "$(jget "$4" "$2")"
  51. }
  52. echo "Seeding the local D1 fixture…"
  53. ./scripts/seed-fixture.sh || exit 1
  54. echo "Starting wrangler dev on :${DASH_PORT}…"
  55. npx wrangler dev --port "$DASH_PORT" --ip 127.0.0.1 >"$LOG" 2>&1 &
  56. DEV_PID=$!
  57. READY=""
  58. for _ in $(seq 1 90); do
  59. if [[ "$(curl -s "$BASE/robots.txt")" == "User-agent: *"* ]]; then READY=1; break; fi
  60. sleep 1
  61. done
  62. if [[ -z "$READY" ]]; then
  63. echo "wrangler dev never came up on :${DASH_PORT} — log follows"
  64. cat "$LOG"
  65. exit 1
  66. fi
  67. echo
  68. echo "The gate still holds on every new endpoint"
  69. for path in summary meta timeseries breakdown activation retention; do
  70. check "GET /api/$path without a cookie → 401" 401 "$(status "$BASE/api/$path")"
  71. done
  72. curl -s -o /dev/null -c "$JAR" -X POST -d "password=$PASSWORD" "$BASE/login"
  73. check "signed in" 200 "$(status -b "$JAR" "$BASE/api/session")"
  74. echo
  75. echo "Caching"
  76. check "chart data is privately cacheable" "private, max-age=300" \
  77. "$(curl -sD - -o /dev/null -b "$JAR" "$BASE/api/summary" | grep -i '^cache-control:' | cut -d' ' -f2- | tr -d '\r')"
  78. check "health stays uncached" "no-store" \
  79. "$(curl -sD - -o /dev/null -b "$JAR" "$BASE/api/health" | grep -i '^cache-control:' | cut -d' ' -f2- | tr -d '\r')"
  80. echo
  81. echo "/api/meta — what the range picker anchors on"
  82. META="$(get "/api/meta")"
  83. field "latest day" latest_day 2026-07-10 "$META"
  84. field "earliest day" earliest_day 2026-07-01 "$META"
  85. field "raw events start" earliest_raw_day 2026-07-01 "$META"
  86. field "retention window" retention_days 14 "$META"
  87. echo
  88. echo "/api/summary — the big numbers (12 machines, one of them CI)"
  89. SUMMARY="$(get "/api/summary?$RANGE")"
  90. field "production users (m12 is CI)" production_users 11 "$SUMMARY"
  91. field "active machines" active_machines 12 "$SUMMARY"
  92. field "new machines" new_machines 12 "$SUMMARY"
  93. field "installs" installs 12 "$SUMMARY"
  94. field "uninstalls" uninstalls 2 "$SUMMARY"
  95. field "indexing runs" index_runs 13 "$SUMMARY"
  96. field "tool calls (SUM of count)" tool_calls 85 "$SUMMARY"
  97. field "range echoed back" range.days 10 "$SUMMARY"
  98. echo
  99. echo "/api/timeseries — one dense point per day, zeros where nothing happened"
  100. TS="$(get "/api/timeseries?metric=installs_uninstalls&$RANGE")"
  101. field "10 labels" labels.0 2026-07-01 "$TS"
  102. field "installs" datasets.0.data '[4,2,1,0,2,0,0,1,2,0]' "$TS"
  103. field "uninstalls" datasets.1.data '[0,0,0,0,0,1,1,0,0,0]' "$TS"
  104. field "legend labels" datasets.1.label Uninstalls "$TS"
  105. TS="$(get "/api/timeseries?metric=new_installs&$RANGE")"
  106. field "new installs by first-seen day" datasets.0.data '[4,2,1,0,2,0,0,1,2,0]' "$TS"
  107. TS="$(get "/api/timeseries?metric=production_users&$RANGE")"
  108. field "daily production users" datasets.0.data '[4,3,4,1,2,3,2,1,1,1]' "$TS"
  109. TS="$(get "/api/timeseries?metric=indexing_activity&$RANGE")"
  110. field "indexing runs" datasets.0.data '[2,2,2,1,1,1,1,1,1,1]' "$TS"
  111. field "machines indexing" datasets.1.data '[2,2,2,1,1,1,1,1,1,1]' "$TS"
  112. TS="$(get "/api/timeseries?metric=tool_calls&$RANGE")"
  113. field "calls per day" datasets.0.data '[0,40,28,0,0,12,0,0,0,5]' "$TS"
  114. field "machines per day" datasets.1.data '[0,1,2,0,0,1,0,0,0,1]' "$TS"
  115. TS="$(get "/api/timeseries?metric=duration_buckets&$RANGE")"
  116. field "bucket order is the scale" datasets.0.label '<10s' "$TS"
  117. field "…and ends at the longest" datasets.3.label '5m+' "$TS"
  118. field "<10s over time" datasets.0.data '[2,0,1,1,0,0,0,1,0,0]' "$TS"
  119. field "10-60s over time" datasets.1.data '[0,2,0,0,0,0,1,0,0,1]' "$TS"
  120. field "1-5m over time" datasets.2.data '[0,0,0,0,1,1,0,0,0,0]' "$TS"
  121. field "5m+ over time" datasets.3.data '[0,0,1,0,0,0,0,0,1,0]' "$TS"
  122. echo
  123. echo "/api/breakdown — bars and pies"
  124. # machine-days, taking the largest per-event count per day so one machine's
  125. # install + index + usage_rollup on one day is not counted three times.
  126. BD="$(get "/api/breakdown?dim=os&$RANGE")"
  127. field "os labels" labels '["linux","darwin","win32"]' "$BD"
  128. field "os machine-days" datasets.0.data '[9,8,4]' "$BD"
  129. field "os metric named" datasets.0.label 'Machine-days' "$BD"
  130. field "os total" total 21 "$BD"
  131. BD="$(get "/api/breakdown?dim=os&metric=count&$RANGE")"
  132. field "os by events sums every event" total 112 "$BD"
  133. BD="$(get "/api/breakdown?dim=language&$RANGE")"
  134. field "languages, most-indexed first" labels '["typescript","csharp","go","javascript","python","rust","java"]' "$BD"
  135. field "language counts" datasets.0.data '[7,2,2,2,2,2,1]' "$BD"
  136. field "language rows total" total 18 "$BD"
  137. BD="$(get "/api/breakdown?dim=file_count_bucket&$RANGE")"
  138. field "codebase size keeps bucket order" labels '["<100","100-1k","1k-10k","10k+"]' "$BD"
  139. field "codebase size counts" datasets.0.data '[2,5,4,2]' "$BD"
  140. BD="$(get "/api/breakdown?dim=duration_bucket&$RANGE")"
  141. field "run length keeps bucket order" labels '["<10s","10-60s","1-5m","5m+"]' "$BD"
  142. field "run length counts" datasets.0.data '[5,4,2,2]' "$BD"
  143. field "run length total = index runs" total 13 "$BD"
  144. BD="$(get "/api/breakdown?dim=target&$RANGE")"
  145. field "agent targets are the installs" event install "$BD"
  146. field "agent target labels" labels '["claude","cursor","codex","opencode"]' "$BD"
  147. field "agent target counts" datasets.0.data '[9,3,2,1]' "$BD"
  148. BD="$(get "/api/breakdown?dim=codegraph_version&$RANGE")"
  149. field "versions sort newest first" labels '["1.5.0","1.4.1","1.4.0"]' "$BD"
  150. field "version machine-days" datasets.0.data '[8,3,10]' "$BD"
  151. BD="$(get "/api/breakdown?dim=name&$RANGE")"
  152. field "tool names by call volume" labels '["codegraph_explore","index"]' "$BD"
  153. field "tool call counts" datasets.0.data '[82,3]' "$BD"
  154. BD="$(get "/api/breakdown?dim=client_name&$RANGE")"
  155. field "agents by call volume" labels '["Claude Code","Cursor"]' "$BD"
  156. field "agent call counts" datasets.0.data '[70,12]' "$BD"
  157. BD="$(get "/api/breakdown?dim=kind&$RANGE")"
  158. field "install kinds" labels '["fresh","upgrade"]' "$BD"
  159. field "install kind counts" datasets.0.data '[11,1]' "$BD"
  160. BD="$(get "/api/breakdown?dim=scope&$RANGE")"
  161. field "install scopes" datasets.0.data '[9,3]' "$BD"
  162. BD="$(get "/api/breakdown?dim=name_error&$RANGE")"
  163. field "errors by tool" datasets.0.data '[1]' "$BD"
  164. BD="$(get "/api/breakdown?dim=language&limit=2&$RANGE")"
  165. field "the tail folds into Other, never truncates" labels '["typescript","csharp","Other"]' "$BD"
  166. field "Other keeps the total honest" total 18 "$BD"
  167. field "truncation is declared" truncated true "$BD"
  168. echo
  169. echo "/api/activation — install → first index within 7 days"
  170. ACT="$(get "/api/activation?$RANGE")"
  171. field "cohort is every machine first seen" installs 12 "$ACT"
  172. field "m04 and m06 never indexed" activated 10 "$ACT"
  173. field "…so two dropped" dropped 2 "$ACT"
  174. field "window" window_days 7 "$ACT"
  175. field "daily rate, null where no cohort" datasets.0.data '[75,50,100,null,100,null,null,100,100,null]' "$ACT"
  176. field "recent cohorts flagged incomplete" incomplete_from 2026-07-04 "$ACT"
  177. field "…and the completed ones are not" rows.2.complete true "$ACT"
  178. field "…while the last week is" rows.8.complete false "$ACT"
  179. # Narrowing the window drops m03 alone: it installed on 07-01 and did not index
  180. # until 07-03. Everyone else who ever indexed did it on day 0 or day 1.
  181. ACT="$(get "/api/activation?window=1&$RANGE")"
  182. field "a 1-day window converts fewer" activated 9 "$ACT"
  183. echo
  184. echo "/api/retention — day 0–14, denominator per day"
  185. RET="$(get "/api/retention?$RANGE")"
  186. field "cohort size" cohort 12 "$RET"
  187. field "15 points" labels.14 'Day 14' "$RET"
  188. # Day 2 divides by 10, not 12: m11/m12 arrived on 07-09 and cannot have a day-2
  189. # data point yet. Day 10+ is null — nobody in the cohort is old enough at all.
  190. field "retention curve" datasets.0.data \
  191. '[100,41.7,30,11.1,0,22.2,0,0,0,0,null,null,null,null,null]' "$RET"
  192. field "day 2 eligible excludes the newest cohorts" rows.2.eligible 10 "$RET"
  193. field "day 10 has nobody old enough" rows.10.eligible 0 "$RET"
  194. echo
  195. echo "Bad input is rejected, never guessed at"
  196. check "unknown dim → 400" 400 "$(status -b "$JAR" "$BASE/api/breakdown?dim=machine_id")"
  197. check "missing dim → 400" 400 "$(status -b "$JAR" "$BASE/api/breakdown")"
  198. check "unknown metric → 400" 400 "$(status -b "$JAR" "$BASE/api/breakdown?dim=os&metric=secrets")"
  199. check "unknown series → 400" 400 "$(status -b "$JAR" "$BASE/api/timeseries?metric=everything")"
  200. check "limit out of range → 400" 400 "$(status -b "$JAR" "$BASE/api/breakdown?dim=os&limit=0")"
  201. check "impossible date → 400" 400 "$(status -b "$JAR" "$BASE/api/summary?from=2026-02-31&to=2026-07-10")"
  202. check "malformed date → 400" 400 "$(status -b "$JAR" "$BASE/api/summary?from=yesterday")"
  203. check "backwards range → 400" 400 "$(status -b "$JAR" "$BASE/api/summary?from=2026-07-10&to=2026-07-01")"
  204. check "window out of range → 400" 400 "$(status -b "$JAR" "$BASE/api/activation?window=99")"
  205. check "unknown endpoint → 404" 404 "$(status -b "$JAR" "$BASE/api/everything")"
  206. check "event name is a closed shape → 400" 400 \
  207. "$(status -b "$JAR" "$BASE/api/breakdown?dim=os&event=install%27%20OR%201=1")"
  208. CLAMPED="$(get "/api/breakdown?dim=os&from=2019-01-01&to=$TO")"
  209. field "a decade-wide range clamps to a year" range.days 366 "$CLAMPED"
  210. field "…and says so" range.clamped true "$CLAMPED"
  211. field "…kept against the recent end" range.from 2025-07-10 "$CLAMPED"
  212. echo
  213. echo "An empty range renders as empty, not as an error"
  214. EMPTY="$(get "/api/summary?from=2025-01-01&to=2025-01-07")"
  215. field "no machines" production_users 0 "$EMPTY"
  216. field "no installs" installs 0 "$EMPTY"
  217. EMPTY="$(get "/api/breakdown?dim=os&from=2025-01-01&to=2025-01-07")"
  218. field "no bars" labels '[]' "$EMPTY"
  219. EMPTY="$(get "/api/timeseries?metric=production_users&from=2025-01-01&to=2025-01-03")"
  220. field "still a dense axis" datasets.0.data '[0,0,0]' "$EMPTY"
  221. echo
  222. printf '%d passed, %d failed\n' "$PASS" "$FAIL"
  223. [[ "$FAIL" -eq 0 ]]