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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions tests/windows/mcp_stdio.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,34 @@ def close(self):
self.proc.kill()
except Exception:
pass


def wait_projects_with_stats(server, timeout=90.0, poll=1.0):
"""Poll list_projects until a project row carries node/edge counts.

index_repository returns when the pipeline finishes, but the project
stats are published asynchronously; on slow CI runners the first
list_projects can observe the registration row before its counts land
(`nodes` absent/None). That window is environmental, not a product
regression (#1952), so wait it out instead of failing setup on it.

Returns (projects, last_text): the parsed project list (possibly [])
and the last raw list_projects payload for diagnostics.
"""
deadline = time.time() + timeout
last_txt = ""
while True:
resp = server.call_tool("list_projects", {}, timeout=60)
txt, err = server.tool_text(resp)
projects = []
if not err and txt:
last_txt = txt
try:
projects = json.loads(txt).get("projects") or []
except ValueError:
projects = []
if projects and projects[0].get("nodes") is not None:
return projects, last_txt
if time.time() >= deadline:
return projects, last_txt
time.sleep(poll)
26 changes: 19 additions & 7 deletions tests/windows/test_cli_non_ascii_arg.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import subprocess
import sys
import tempfile
import time

MATH_TS = (
"export function add(a: number, b: number): number { return a + b; }\n"
Expand Down Expand Up @@ -67,13 +68,24 @@ def main():
# CLI path itself works and isolating the failure to argv encoding.
ascii_repo = os.path.join(work, "ascii_repo")
make_fixture(ascii_repo)
env = dict(os.environ)
env["CBM_CACHE_DIR"] = os.path.join(work, "cache_ascii")
ctrl = subprocess.run(
[binary, "cli", "index_repository",
json.dumps({"repo_path": ascii_repo})],
capture_output=True, timeout=120, env=env)
ctrl_out = (ctrl.stdout or b"").decode("utf-8", "replace")
# The control is setup, not the surface under test: a cold runner can
# lose the first one-shot to coordination-daemon startup latency
# (#1952). One retry against a fresh cache separates that from a real
# CLI indexing failure.
ctrl_out = ""
for attempt in ("cache_ascii", "cache_ascii_retry"):
env = dict(os.environ)
env["CBM_CACHE_DIR"] = os.path.join(work, attempt)
ctrl = subprocess.run(
[binary, "cli", "index_repository",
json.dumps({"repo_path": ascii_repo})],
capture_output=True, timeout=120, env=env)
ctrl_out = (ctrl.stdout or b"").decode("utf-8", "replace")
if '"nodes"' in ctrl_out:
break
print("SETUP: ASCII control attempt %r did not index via CLI:\n%s"
% (attempt, ctrl_out[:300]))
time.sleep(2)
if '"nodes"' not in ctrl_out:
print("SETUP FAIL: ASCII control did not index via CLI:\n%s" %
ctrl_out[:300])
Expand Down
32 changes: 25 additions & 7 deletions tests/windows/test_hook_augment.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import subprocess
import sys
import tempfile
import time

SYMBOL = "someIndexedSymbol"
SRC = "export function %s(a: number): number { return a + 1; }\n" % SYMBOL
Expand Down Expand Up @@ -63,9 +64,19 @@ def main():

# repo_path / cwd in the forward-slash drive form Claude Code passes.
repo_fwd = repo.replace("\\", "/")
idx = run_cli(binary, cache, ["cli", "index_repository",
json.dumps({"repo_path": repo_fwd})])
idx_out = (idx.stdout or b"").decode("utf-8", "replace")
# Setup, not the surface under test: retry once so a cold runner's
# coordination-daemon startup latency (#1952) is not misread as a
# broken CLI index. Reindexing the same cache is idempotent.
idx_out = ""
for attempt in (1, 2):
idx = run_cli(binary, cache, ["cli", "index_repository",
json.dumps({"repo_path": repo_fwd})])
idx_out = (idx.stdout or b"").decode("utf-8", "replace")
if '"nodes"' in idx_out:
break
print("SETUP: index attempt %d did not run:\n%s"
% (attempt, idx_out[:300]))
time.sleep(2)
if '"nodes"' not in idx_out:
print("SETUP FAIL: index did not run:\n%s" % idx_out[:300])
return 2
Expand All @@ -88,10 +99,17 @@ def main():
# first — the supported way to keep hooks armed outside MCP sessions —
# and retire it afterwards (with a kill-by-pid backstop so a stuck stop
# can never hang CI).
start = run_cli(binary, cache, ["daemon", "start"], timeout=60)
start_out = (start.stdout or b"").decode("utf-8", "replace")
print("daemon start rc=%d %r" % (start.returncode, start_out[:120]))
if start.returncode != 0:
start_out = ""
rc = 1
for attempt in (1, 2):
start = run_cli(binary, cache, ["daemon", "start"], timeout=60)
start_out = (start.stdout or b"").decode("utf-8", "replace")
rc = start.returncode
print("daemon start attempt %d rc=%d %r" % (attempt, rc, start_out[:120]))
if rc == 0:
break
time.sleep(2)
if rc != 0:
print("SETUP FAIL: permanent daemon did not start")
return 2
pid_match = re.search(r"pid (\d+)", start_out)
Expand Down
98 changes: 63 additions & 35 deletions tests/windows/test_non_ascii_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
import tempfile

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from mcp_stdio import McpServer # noqa: E402
from mcp_stdio import McpServer, wait_projects_with_stats # noqa: E402

MATH_TS = (
"export function add(a: number, b: number): number { return a + b; }\n"
Expand Down Expand Up @@ -246,6 +246,37 @@ def make_fixture(root):
f.write(text.encode("utf-8")) # exact bytes, identical across copies


def no_project_error(index_txt, repo, cache):
"""Assemble the venue diagnostics for an index that left no project row.

The count summary cannot explain a venue-specific empty listing; carry the
index response, the cache contents and the supervisor's worker logs (the
only record of the pipeline's own error) into the CI log.
"""
try:
cache_entries = sorted(os.listdir(cache))
except OSError as exc:
cache_entries = ["<listdir failed: %s>" % exc]
log_tails = []
logs_dir = os.path.join(cache, "logs")
if os.path.isdir(logs_dir):
for log_name in sorted(os.listdir(logs_dir)):
try:
with open(os.path.join(logs_dir, log_name), "rb") as lf:
tail = lf.read()[-800:].decode("utf-8", "replace")
log_tails.append("%s: %s" % (log_name, tail))
except OSError as exc:
log_tails.append("%s: <unreadable: %s>" % (log_name, exc))
try:
repo_entries = sorted(os.listdir(repo))
except OSError as exc:
repo_entries = ["<listdir failed: %s>" % exc]
return {"error": "no project listed after index; index said %r; "
"cache holds %r; repo holds %r; worker logs: %s"
% (index_txt[:400], cache_entries, repo_entries,
" | ".join(log_tails) or "<none>")}


def index_and_count(binary, repo, cache):
"""Index `repo` into an isolated cache and return label-resolved counts."""
os.makedirs(cache, exist_ok=True)
Expand All @@ -255,42 +286,29 @@ def index_and_count(binary, repo, cache):
index_txt, err = s.tool_text(resp)
if err:
return {"error": "index tools/call error: %r" % err}
lp = s.call_tool("list_projects", {}, timeout=60)
lp_txt, _ = s.tool_text(lp)
projects = json.loads(lp_txt).get("projects") or []
if not projects:
# The count summary cannot explain a venue-specific empty listing;
# carry the index response and the cache contents into the log.
try:
cache_entries = sorted(os.listdir(cache))
except OSError as exc:
cache_entries = ["<listdir failed: %s>" % exc]
# The supervisor's worker logs carry the actual pipeline error.
log_tails = []
logs_dir = os.path.join(cache, "logs")
if os.path.isdir(logs_dir):
for log_name in sorted(os.listdir(logs_dir)):
try:
with open(os.path.join(logs_dir, log_name), "rb") as lf:
tail = lf.read()[-800:].decode("utf-8", "replace")
log_tails.append("%s: %s" % (log_name, tail))
except OSError as exc:
log_tails.append("%s: <unreadable: %s>" % (log_name, exc))
try:
repo_entries = sorted(os.listdir(repo))
except OSError as exc:
repo_entries = ["<listdir failed: %s>" % exc]
return {"error": "no project listed after index; index said %r; "
"cache holds %r; repo holds %r; worker logs: %s"
% (index_txt[:400], cache_entries, repo_entries,
" | ".join(log_tails) or "<none>")}
p = projects[0]
out = {"name": p.get("name"), "nodes": p.get("nodes"),
"edges": p.get("edges")}
# The index response itself carries the synchronous, authoritative
# counts ("nodes"/"edges"). list_projects publishes its stats columns
# asynchronously — on some venues never within a one-shot session — so
# gating on it misreads a healthy index as a setup failure (#1952).
try:
summary = json.loads(index_txt)
except ValueError:
summary = {}
out = {"name": summary.get("project"), "nodes": summary.get("nodes"),
"edges": summary.get("edges")}
if out["nodes"] is None:
# Payload without counts: fall back to list_projects, polled
# because its stats row can trail the index on slow runners.
projects, _ = wait_projects_with_stats(s)
if not projects:
return no_project_error(index_txt, repo, cache)
p = projects[0]
out = {"name": p.get("name"), "nodes": p.get("nodes"),
"edges": p.get("edges")}
# Definition-level counts prove the parser ran (not just discovery).
# query_graph defaults to TOON text; this scripted consumer requests
# format="json" ({"columns":[...],"rows":[["<n>"]],...}) explicitly.
name = p.get("name")
name = out["name"]
defs = 0
for label in ("Function", "Class", "Method"):
q = "MATCH (n:%s) RETURN count(n)" % label
Expand Down Expand Up @@ -331,7 +349,17 @@ def main():

ascii_repo = os.path.join(work, "ascii_repo")
make_fixture(ascii_repo)
base = index_and_count(binary, ascii_repo, os.path.join(work, "c_ascii"))
# The baseline is setup, not the surface under test: a cold runner can
# lose the first index to daemon startup latency (#1952). One retry
# against a fresh cache separates that environmental window from a
# real indexing failure before the guard declares a precondition skip.
base = {}
for attempt in ("c_ascii", "c_ascii_retry"):
base = index_and_count(binary, ascii_repo, os.path.join(work, attempt))
if not base.get("error") and base.get("nodes"):
break
print("SETUP: ASCII baseline attempt %r did not index: %r"
% (attempt, base))
if base.get("error") or not base.get("nodes"):
print("SETUP FAIL: ASCII baseline did not index: %r" % base)
return 2
Expand Down
Loading