Skip to content

Commit 20814e8

Browse files
committed
Add smoke tests to Docker test infra + CI pipeline
Expand smoke-test.sh with Phase 5 (MCP stdio transport), Phase 6 (CLI subcommands: install/uninstall/update --dry-run), and Phase 7 (MCP advanced tool calls: search_code v2, get_code_snippet). Add smoke/smoke-amd64 services to Docker compose that build then run all 7 smoke test phases. Include in run.sh full/all flows. Add python3-minimal to Dockerfile for smoke test JSON parsing. Fix Phase 4a shutdown test to use portable background+kill pattern instead of `timeout` (not available on macOS). Add --dry-run, --standard, --ui flags to update command. Fix clang-tidy readability-implicit-bool-conversion in dry_run ternary.
1 parent 85da090 commit 20814e8

File tree

5 files changed

+217
-34
lines changed

5 files changed

+217
-34
lines changed

scripts/smoke-test.sh

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -177,15 +177,23 @@ cat > "$SHUTDOWN_TMPDIR/input.jsonl" << 'JSONL'
177177
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}
178178
JSONL
179179

180-
# Run binary with EOF and check it exits within 5 seconds
181-
timeout 5 "$BINARY" < "$SHUTDOWN_TMPDIR/input.jsonl" > /dev/null 2>&1 || true
182-
EXIT_CODE=$?
183-
rm -rf "$SHUTDOWN_TMPDIR"
184-
185-
if [ "$EXIT_CODE" -eq 124 ]; then
180+
# Run binary with EOF and wait up to 5 seconds (portable — no `timeout` needed)
181+
"$BINARY" < "$SHUTDOWN_TMPDIR/input.jsonl" > /dev/null 2>&1 &
182+
SHUTDOWN_PID=$!
183+
SHUTDOWN_WAITED=0
184+
while kill -0 "$SHUTDOWN_PID" 2>/dev/null && [ "$SHUTDOWN_WAITED" -lt 5 ]; do
185+
sleep 1
186+
SHUTDOWN_WAITED=$((SHUTDOWN_WAITED + 1))
187+
done
188+
if kill -0 "$SHUTDOWN_PID" 2>/dev/null; then
189+
kill "$SHUTDOWN_PID" 2>/dev/null || true
190+
wait "$SHUTDOWN_PID" 2>/dev/null || true
191+
rm -rf "$SHUTDOWN_TMPDIR"
186192
echo "FAIL: binary did not exit within 5 seconds after EOF"
187193
exit 1
188194
fi
195+
wait "$SHUTDOWN_PID" 2>/dev/null || true
196+
rm -rf "$SHUTDOWN_TMPDIR"
189197
echo "OK: clean shutdown"
190198

191199
# 4b: No residual processes (skip on Windows/MSYS2 where pgrep may not work)
@@ -337,5 +345,89 @@ echo "OK: Content-Length framing works (OpenCode compatible)"
337345

338346
rm -f "$MCP_CL_INPUT" "$MCP_CL_OUTPUT" "$MCP_TOOL_INPUT" "$MCP_TOOL_OUTPUT"
339347

348+
echo ""
349+
echo "=== Phase 6: CLI subcommands ==="
350+
351+
# 6a: install --dry-run -y
352+
echo "--- Phase 6a: install --dry-run ---"
353+
INSTALL_OUT=$("$BINARY" install --dry-run -y 2>&1)
354+
if ! echo "$INSTALL_OUT" | grep -qi 'install\|skill\|mcp\|agent'; then
355+
echo "FAIL: install --dry-run produced unexpected output"
356+
echo "$INSTALL_OUT"
357+
exit 1
358+
fi
359+
if ! echo "$INSTALL_OUT" | grep -qi 'dry-run'; then
360+
echo "FAIL: install --dry-run did not indicate dry-run mode"
361+
exit 1
362+
fi
363+
echo "OK: install --dry-run completed"
364+
365+
# 6b: uninstall --dry-run -y
366+
echo "--- Phase 6b: uninstall --dry-run ---"
367+
UNINSTALL_OUT=$("$BINARY" uninstall --dry-run -y 2>&1)
368+
if ! echo "$UNINSTALL_OUT" | grep -qi 'uninstall\|remov'; then
369+
echo "FAIL: uninstall --dry-run produced unexpected output"
370+
echo "$UNINSTALL_OUT"
371+
exit 1
372+
fi
373+
echo "OK: uninstall --dry-run completed"
374+
375+
# 6c: update --dry-run --standard -y
376+
echo "--- Phase 6c: update --dry-run ---"
377+
UPDATE_OUT=$("$BINARY" update --dry-run --standard -y 2>&1)
378+
if ! echo "$UPDATE_OUT" | grep -qi 'dry-run'; then
379+
echo "FAIL: update --dry-run did not indicate dry-run mode"
380+
echo "$UPDATE_OUT"
381+
exit 1
382+
fi
383+
if ! echo "$UPDATE_OUT" | grep -qi 'standard'; then
384+
echo "FAIL: update --dry-run did not respect --standard flag"
385+
exit 1
386+
fi
387+
echo "OK: update --dry-run --standard completed"
388+
389+
# 6d: config set/get/reset round-trip
390+
echo "--- Phase 6d: config set/get/reset ---"
391+
"$BINARY" config set auto_index true 2>/dev/null
392+
CONFIG_VAL=$("$BINARY" config get auto_index 2>/dev/null)
393+
if ! echo "$CONFIG_VAL" | grep -q 'true'; then
394+
echo "FAIL: config get auto_index returned '$CONFIG_VAL', expected 'true'"
395+
exit 1
396+
fi
397+
"$BINARY" config reset auto_index 2>/dev/null
398+
echo "OK: config set/get/reset round-trip"
399+
400+
echo ""
401+
echo "=== Phase 7: MCP advanced tool calls ==="
402+
403+
# 7a: search_code via MCP (graph-augmented v2)
404+
echo "--- Phase 7a: search_code via MCP ---"
405+
MCP_SC_INPUT=$(mktemp)
406+
MCP_SC_OUTPUT=$(mktemp)
407+
cat > "$MCP_SC_INPUT" << SCEOF
408+
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"1.0"}}}
409+
{"jsonrpc":"2.0","method":"notifications/initialized"}
410+
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"index_repository","arguments":{"repo_path":"$TMPDIR","mode":"fast"}}}
411+
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_code","arguments":{"pattern":"compute","mode":"compact","limit":3}}}
412+
{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"get_code_snippet","arguments":{"qualified_name":"compute"}}}
413+
SCEOF
414+
415+
mcp_run "$MCP_SC_INPUT" "$MCP_SC_OUTPUT" 30
416+
417+
if ! grep -q '"id":3' "$MCP_SC_OUTPUT"; then
418+
echo "FAIL: search_code response (id:3) missing"
419+
exit 1
420+
fi
421+
echo "OK: search_code v2 via MCP"
422+
423+
# 7b: get_code_snippet via MCP
424+
if ! grep -q '"id":4' "$MCP_SC_OUTPUT"; then
425+
echo "FAIL: get_code_snippet response (id:4) missing"
426+
exit 1
427+
fi
428+
echo "OK: get_code_snippet via MCP"
429+
430+
rm -f "$MCP_SC_INPUT" "$MCP_SC_OUTPUT"
431+
340432
echo ""
341433
echo "=== smoke-test: ALL PASSED ==="

src/cli/cli.c

Lines changed: 80 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -258,17 +258,34 @@ int cbm_copy_file(const char *src, const char *dst) {
258258
return rc == 0 ? 0 : -1;
259259
}
260260

261-
/* Replace a binary file: unlink first (handles read-only existing files),
262-
* then create with the given data and permissions. */
261+
/* Replace a binary file. Unlinks the old file first (handles read-only and
262+
* running binaries on Unix where unlink succeeds on open files). On all
263+
* platforms, the caller should tell the user to restart after update. */
263264
int cbm_replace_binary(const char *path, const unsigned char *data, int len, int mode) {
264265
if (!path || !data || len <= 0) {
265266
return -1;
266267
}
267268

268-
/* Remove existing file first — handles the case where the old binary
269-
* has no write permission (e.g., 0500). unlink() only requires write
270-
* permission on the parent directory, not the file itself. */
271-
(void)cbm_unlink(path);
269+
/* Remove existing file if it exists. On Unix, unlink works even if the
270+
* binary is running (inode stays alive until the process exits). On Windows,
271+
* unlink fails on running .exe — rename it aside as fallback. */
272+
struct stat st_check;
273+
if (stat(path, &st_check) == 0) {
274+
/* File exists — remove or rename it */
275+
if (cbm_unlink(path) != 0) {
276+
#ifdef _WIN32
277+
/* Windows: can't unlink running .exe — rename aside */
278+
char old_path[1024];
279+
snprintf(old_path, sizeof(old_path), "%s.old", path);
280+
(void)cbm_unlink(old_path);
281+
if (rename(path, old_path) != 0) {
282+
return -1;
283+
}
284+
#else
285+
return -1;
286+
#endif
287+
}
288+
}
272289

273290
#ifndef _WIN32
274291
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, (mode_t)mode);
@@ -281,6 +298,7 @@ int cbm_replace_binary(const char *path, const unsigned char *data, int len, int
281298
return -1;
282299
}
283300
#else
301+
(void)mode;
284302
FILE *f = fopen(path, "wb");
285303
if (!f) {
286304
return -1;
@@ -2705,6 +2723,18 @@ int cbm_cmd_uninstall(int argc, char **argv) {
27052723
int cbm_cmd_update(int argc, char **argv) {
27062724
parse_auto_answer(argc, argv);
27072725

2726+
bool dry_run = false;
2727+
int variant_flag = 0; /* 0 = ask, 1 = standard, 2 = ui */
2728+
for (int i = 0; i < argc; i++) {
2729+
if (strcmp(argv[i], "--dry-run") == 0) {
2730+
dry_run = true;
2731+
} else if (strcmp(argv[i], "--standard") == 0) {
2732+
variant_flag = 1;
2733+
} else if (strcmp(argv[i], "--ui") == 0) {
2734+
variant_flag = 2;
2735+
}
2736+
}
2737+
27082738
const char *home = cbm_get_home_dir();
27092739
if (!home) {
27102740
fprintf(stderr, "error: HOME not set (use USERPROFILE on Windows)\n");
@@ -2734,29 +2764,39 @@ int cbm_cmd_update(int argc, char **argv) {
27342764
printf("Found %d existing index(es) that must be rebuilt after update:\n", index_count);
27352765
cbm_list_indexes(home);
27362766
printf("\n");
2737-
if (!prompt_yn("Delete these indexes and continue with update?")) {
2738-
printf("Update cancelled.\n");
2739-
return 1;
2767+
if (!dry_run) {
2768+
if (!prompt_yn("Delete these indexes and continue with update?")) {
2769+
printf("Update cancelled.\n");
2770+
return 1;
2771+
}
2772+
int removed = cbm_remove_indexes(home);
2773+
printf("Removed %d index(es).\n\n", removed);
2774+
} else {
2775+
printf("(dry-run — indexes would be deleted)\n\n");
27402776
}
2741-
int removed = cbm_remove_indexes(home);
2742-
printf("Removed %d index(es).\n\n", removed);
27432777
}
27442778

2745-
/* Step 2: Ask for UI variant */
2746-
printf("Which binary variant do you want?\n");
2747-
printf(" 1) standard — MCP server only\n");
2748-
printf(" 2) ui — MCP server + embedded graph visualization\n");
2749-
printf("Choose (1/2): ");
2750-
(void)fflush(stdout);
2751-
2752-
char choice[16];
2753-
if (!fgets(choice, sizeof(choice), stdin)) {
2754-
fprintf(stderr, "error: failed to read input\n");
2755-
return 1;
2779+
/* Step 2: Determine variant (--standard / --ui flags skip interactive prompt) */
2780+
bool want_ui = false;
2781+
if (variant_flag == 1) {
2782+
want_ui = false;
2783+
} else if (variant_flag == 2) {
2784+
want_ui = true;
2785+
} else {
2786+
printf("Which binary variant do you want?\n");
2787+
printf(" 1) standard — MCP server only\n");
2788+
printf(" 2) ui — MCP server + embedded graph visualization\n");
2789+
printf("Choose (1/2): ");
2790+
(void)fflush(stdout);
2791+
2792+
char choice[16];
2793+
if (!fgets(choice, sizeof(choice), stdin)) {
2794+
fprintf(stderr, "error: failed to read input\n");
2795+
return 1;
2796+
}
2797+
want_ui = (choice[0] == '2');
27562798
}
27572799
// NOLINTNEXTLINE(readability-implicit-bool-conversion)
2758-
bool want_ui = (choice[0] == '2') ? true : false;
2759-
// NOLINTNEXTLINE(readability-implicit-bool-conversion)
27602800
const char *variant = want_ui ? "ui-" : "";
27612801
// NOLINTNEXTLINE(readability-implicit-bool-conversion)
27622802
const char *variant_label = want_ui ? "ui" : "standard";
@@ -2779,9 +2819,23 @@ int cbm_cmd_update(int argc, char **argv) {
27792819
os, arch, ext);
27802820
}
27812821

2782-
printf("\nDownloading %s binary for %s/%s ...\n", variant_label, os, arch);
2822+
if (dry_run) {
2823+
printf("\nWould download %s binary for %s/%s ...\n", variant_label, os, arch);
2824+
} else {
2825+
printf("\nDownloading %s binary for %s/%s ...\n", variant_label, os, arch);
2826+
}
27832827
printf(" %s\n", url);
27842828

2829+
if (dry_run) {
2830+
printf("\n(dry-run — skipping download, extraction, and binary replacement)\n");
2831+
printf(" target: %s/.local/bin/codebase-memory-mcp\n", home);
2832+
printf(" variant: %s\n", variant_label);
2833+
printf(" os/arch: %s/%s\n", os, arch);
2834+
printf("\nUpdate dry-run complete.\n");
2835+
(void)variant;
2836+
return 0;
2837+
}
2838+
27852839
/* Step 4: Download using curl */
27862840
char tmp_archive[256];
27872841
snprintf(tmp_archive, sizeof(tmp_archive), "%s/cbm-update.%s", cbm_tmpdir(), ext);
@@ -2897,6 +2951,7 @@ int cbm_cmd_update(int argc, char **argv) {
28972951

28982952
printf("\nAll project indexes were cleared. They will be rebuilt\n");
28992953
printf("automatically when you next use the MCP server.\n");
2954+
printf("\nPlease restart your MCP client to use the new binary.\n");
29002955
(void)variant;
29012956
return 0;
29022957
}

test-infrastructure/Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
1313
gcc g++ make \
1414
zlib1g-dev \
1515
pkg-config \
16+
python3-minimal \
1617
&& rm -rf /var/lib/apt/lists/*
1718

1819
WORKDIR /src

test-infrastructure/docker-compose.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,33 @@ services:
7272
WINEDEBUG=-all wine64 build/c/test-runner.exe &&
7373
echo '=== Windows test: OK ==='
7474
75+
# ── Smoke test (build + run smoke-test.sh) ──────────────────
76+
smoke:
77+
build:
78+
context: ..
79+
dockerfile: test-infrastructure/Dockerfile
80+
platform: linux/arm64
81+
volumes:
82+
- ..:/src
83+
entrypoint: ["/bin/bash", "-c"]
84+
command:
85+
- |
86+
scripts/build.sh CC=gcc CXX=g++ &&
87+
scripts/smoke-test.sh ./build/c/codebase-memory-mcp
88+
89+
smoke-amd64:
90+
build:
91+
context: ..
92+
dockerfile: test-infrastructure/Dockerfile
93+
platform: linux/amd64
94+
volumes:
95+
- ..:/src
96+
entrypoint: ["/bin/bash", "-c"]
97+
command:
98+
- |
99+
scripts/build.sh CC=gcc CXX=g++ &&
100+
scripts/smoke-test.sh ./build/c/codebase-memory-mcp
101+
75102
# ── Lint ────────────────────────────────────────────────────
76103
lint:
77104
build:

test-infrastructure/run.sh

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ case "${1:-full}" in
2626
echo "=== Linux arm64: test + build ==="
2727
$COMPOSE run --rm test
2828
$COMPOSE run --rm build
29+
echo "=== Linux arm64: smoke test ==="
30+
$COMPOSE run --rm smoke
2931
echo "=== Windows: cross-compile ==="
3032
$COMPOSE run --rm build-windows
3133
echo "=== All passed ==="
@@ -38,6 +40,10 @@ case "${1:-full}" in
3840
echo "=== Linux arm64: production build (-O2 -Werror) ==="
3941
$COMPOSE run --rm build
4042
;;
43+
smoke)
44+
echo "=== Linux arm64: smoke test (build + run all 7 phases) ==="
45+
$COMPOSE run --rm smoke
46+
;;
4147
windows)
4248
echo "=== Windows: cross-compile (mingw-w64) ==="
4349
$COMPOSE run --rm build-windows
@@ -48,12 +54,14 @@ case "${1:-full}" in
4854
$COMPOSE run --rm build-amd64
4955
;;
5056
all)
51-
echo "=== Linux arm64: test + build ==="
57+
echo "=== Linux arm64: test + build + smoke ==="
5258
$COMPOSE run --rm test
5359
$COMPOSE run --rm build
54-
echo "=== Linux amd64: test + build ==="
60+
$COMPOSE run --rm smoke
61+
echo "=== Linux amd64: test + build + smoke ==="
5562
$COMPOSE run --rm test-amd64
5663
$COMPOSE run --rm build-amd64
64+
$COMPOSE run --rm smoke-amd64
5765
echo "=== Windows: cross-compile ==="
5866
$COMPOSE run --rm build-windows
5967
echo "=== All platforms passed ==="
@@ -67,7 +75,7 @@ case "${1:-full}" in
6775
$COMPOSE run --rm --entrypoint bash test
6876
;;
6977
*)
70-
echo "Usage: $0 {full|test|build|windows|amd64|all|lint|shell}"
78+
echo "Usage: $0 {full|test|build|smoke|windows|amd64|all|lint|shell}"
7179
exit 1
7280
;;
7381
esac

0 commit comments

Comments
 (0)