mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
tools/hogbox-preview drives posthog-hogland — PostHog's own internal Firecracker CLI and SDK, the one their engineers install with `brew install posthog/tap/hogland`. It is not a library we can point somewhere else: its default host is https://box.mascot-kitefin.ts.net, a machine on PostHog's tailnet, and that is the only .ts.net address in this entire tree. So it could never have run here. Nothing outside its own directory imports it — 13 files, 2,867 lines, self-contained and unreachable. insights-hogland and pixelhog leave pyproject's exclude-newer-package with it, since nothing asks for either now. Preview environments belong on our own compute. The Go cloud already serves /v1/compute/*; a backend against that is a thing to write, not a thing to rename PostHog's into. Also finishes the rename in packages/quill, tools and common/scriptvm — 74 files, plus renderHogChart and the scriptvm python objects. Co-authored-by: Hanzo Dev <[email protected]>
803 lines
30 KiB
Python
803 lines
30 KiB
Python
"""Auto-detecting test runner for insightscli.
|
|
|
|
Detects the test type from a file path and dispatches to the correct runner
|
|
(pytest, Jest, Playwright, cargo test, go test) with appropriate options.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shlex
|
|
import tomllib
|
|
import platform
|
|
import subprocess
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path, PurePosixPath
|
|
|
|
import click
|
|
from insightscli.command_types import _run
|
|
from insightscli.manifest import REPO_ROOT
|
|
|
|
from insightscli_commands.change_detection import changed_files
|
|
|
|
_PYTHON_ROOTS = ("insights/", "ee/", "products/", "common/", "dags/", "tools/", "services/")
|
|
|
|
_DESKTOP_ROOT = "products/desktop"
|
|
|
|
|
|
def _is_test_file(path: str, rs_cfg_test: set[str] | None = None) -> bool:
|
|
"""Check if a file path looks like a test file for any supported language.
|
|
|
|
Args:
|
|
path: Repo-relative file path.
|
|
rs_cfg_test: Pre-computed set of .rs files containing #[cfg(test)].
|
|
When provided, avoids reading each .rs file from disk.
|
|
"""
|
|
name = PurePosixPath(path).name
|
|
if path.endswith(".py"):
|
|
return name.startswith("test_") or (name.startswith("eval_") and path.startswith("ee/scriptai/eval/"))
|
|
if path.endswith((".test.ts", ".test.tsx")):
|
|
return True
|
|
if path.endswith(".spec.ts") and path.startswith("playwright/"):
|
|
return True
|
|
if path.endswith("_test.go"):
|
|
return True
|
|
if path.endswith(".rs"):
|
|
if path.endswith("_test.rs") or "/tests/" in path:
|
|
return True
|
|
if rs_cfg_test is not None:
|
|
return path in rs_cfg_test
|
|
return False
|
|
|
|
|
|
def _batch_find_rs_cfg_test(dir_path: str) -> set[str]:
|
|
"""Batch-find .rs files containing inline #[cfg(test)] with a single grep."""
|
|
try:
|
|
result = subprocess.run(
|
|
["grep", "-rl", "--include=*.rs", r"#\[cfg(test)\]", dir_path],
|
|
cwd=REPO_ROOT,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return {line for line in result.stdout.strip().splitlines() if line}
|
|
except OSError:
|
|
return set()
|
|
|
|
|
|
def _find_test_files_for_source(path: str) -> list[str]:
|
|
"""Given a non-test source file, find corresponding test files using naming conventions.
|
|
|
|
Returns repo-relative paths to test files that exist on disk.
|
|
"""
|
|
p = PurePosixPath(path)
|
|
stem = p.stem
|
|
suffix = p.suffix
|
|
parent = str(p.parent)
|
|
|
|
candidates: list[str] = []
|
|
|
|
if suffix == ".py":
|
|
test_name = f"test_{stem}.py"
|
|
candidates.append(str(PurePosixPath(parent) / test_name))
|
|
candidates.append(str(PurePosixPath(parent) / "test" / test_name))
|
|
candidates.append(str(PurePosixPath(parent) / "tests" / test_name))
|
|
|
|
elif suffix in (".ts", ".tsx"):
|
|
candidates.append(str(PurePosixPath(parent) / f"{stem}.test{suffix}"))
|
|
if suffix == ".ts":
|
|
candidates.append(str(PurePosixPath(parent) / f"{stem}.test.tsx"))
|
|
|
|
elif suffix == ".go":
|
|
candidates.append(str(PurePosixPath(parent) / f"{stem}_test.go"))
|
|
|
|
elif suffix == ".rs":
|
|
candidates.append(str(PurePosixPath(parent) / f"{stem}_test.rs"))
|
|
parts = PurePosixPath(parent).parts
|
|
if "src" in parts:
|
|
src_idx = len(parts) - 1 - list(reversed(parts)).index("src")
|
|
tests_parent = PurePosixPath(*parts[:src_idx]) / "tests"
|
|
candidates.append(str(tests_parent / f"{stem}.rs"))
|
|
candidates.append(str(tests_parent / f"{stem}_test.rs"))
|
|
|
|
rs_cfg_test = _batch_find_rs_cfg_test(parent) if suffix == ".rs" else None
|
|
return [c for c in candidates if (REPO_ROOT / c).is_file() and _is_test_file(c, rs_cfg_test=rs_cfg_test)]
|
|
|
|
|
|
def _find_test_files(dir_path: str) -> list[str]:
|
|
"""Find all test files recursively in a directory."""
|
|
abs_dir = REPO_ROOT / dir_path
|
|
rs_cfg_test = _batch_find_rs_cfg_test(dir_path)
|
|
|
|
test_files = []
|
|
for p in sorted(abs_dir.rglob("*")):
|
|
if p.is_file():
|
|
try:
|
|
rel = str(p.relative_to(REPO_ROOT))
|
|
except ValueError:
|
|
continue
|
|
if _is_test_file(rel, rs_cfg_test=rs_cfg_test):
|
|
test_files.append(rel)
|
|
return test_files
|
|
|
|
|
|
@dataclass
|
|
class TestRunConfig:
|
|
"""Result of test type detection."""
|
|
|
|
test_type: str
|
|
command: list[str]
|
|
description: str
|
|
env: dict[str, str] = field(default_factory=dict)
|
|
cwd: Path | None = None
|
|
|
|
@property
|
|
def env_or_none(self) -> dict[str, str] | None:
|
|
return self.env or None
|
|
|
|
|
|
def _python_env() -> dict[str, str]:
|
|
"""Environment variables needed for Python test runs."""
|
|
env: dict[str, str] = {"REDIS_URL": "redis:///"}
|
|
if platform.system() == "Darwin":
|
|
env["OBJC_DISABLE_INITIALIZE_FORK_SAFETY"] = "YES"
|
|
return env
|
|
|
|
|
|
def _resolve_to_repo_relative(file_path: str) -> str:
|
|
"""Convert absolute or relative path to repo-relative path.
|
|
|
|
Handles pytest node IDs (path::Class::method) by only resolving
|
|
the file path portion, then reattaching the node ID.
|
|
"""
|
|
node_id_suffix = ""
|
|
if "::" in file_path:
|
|
parts = file_path.split("::", 1)
|
|
file_path = parts[0]
|
|
node_id_suffix = "::" + parts[1]
|
|
|
|
path = Path(file_path)
|
|
if path.is_absolute():
|
|
try:
|
|
rel = path.relative_to(REPO_ROOT)
|
|
except ValueError:
|
|
return str(path) + node_id_suffix
|
|
return str(rel) + node_id_suffix
|
|
|
|
# Relative path: resolve from cwd then make repo-relative
|
|
resolved = Path.cwd() / path
|
|
try:
|
|
rel = resolved.relative_to(REPO_ROOT)
|
|
return str(rel) + node_id_suffix
|
|
except ValueError:
|
|
return file_path + node_id_suffix
|
|
|
|
|
|
def _find_nearest(file_path: str, target_filename: str) -> Path | None:
|
|
"""Walk upward from file_path to find the nearest file with target_filename.
|
|
|
|
If file_path points to a directory, starts the search there.
|
|
If it points to a file, starts from its parent directory.
|
|
Stops at REPO_ROOT to avoid escaping the repo.
|
|
"""
|
|
repo_root = REPO_ROOT.resolve()
|
|
path = Path(file_path)
|
|
if path.is_absolute():
|
|
abs_path = path.resolve()
|
|
else:
|
|
abs_path = (repo_root / path).resolve()
|
|
try:
|
|
abs_path.relative_to(repo_root)
|
|
except ValueError:
|
|
return None
|
|
|
|
current = abs_path if abs_path.is_dir() else abs_path.parent
|
|
while True:
|
|
candidate = current / target_filename
|
|
if candidate.exists():
|
|
return candidate
|
|
if current == repo_root or current.parent == current:
|
|
break
|
|
current = current.parent
|
|
|
|
return None
|
|
|
|
|
|
def _parse_cargo_package_name(cargo_toml: Path) -> str | None:
|
|
"""Extract the package name from a Cargo.toml [package] section."""
|
|
try:
|
|
data = tomllib.loads(cargo_toml.read_text())
|
|
return data.get("package", {}).get("name")
|
|
except (tomllib.TOMLDecodeError, OSError):
|
|
return None
|
|
|
|
|
|
def _parse_package_json_name(package_json: Path) -> str | None:
|
|
"""Extract the name field from a package.json."""
|
|
try:
|
|
data = json.loads(package_json.read_text())
|
|
return data.get("name")
|
|
except (json.JSONDecodeError, OSError):
|
|
return None
|
|
|
|
|
|
@dataclass
|
|
class _RustTestFilter:
|
|
"""Filter for targeting a specific Rust test scope."""
|
|
|
|
# Module filter for unit tests (e.g. "utils::throttler"), used with `-- <filter>`
|
|
module: str | None = None
|
|
# Integration test binary name (e.g. "events"), used with `--test <name>`
|
|
integration_test: str | None = None
|
|
|
|
|
|
def _rust_module_filter(file_only: str, crate_toml: Path, node_id: str | None = None) -> _RustTestFilter:
|
|
"""Derive a cargo test filter from a .rs file or directory path.
|
|
|
|
For ``crate/src/utils/throttler.rs``, returns a module filter ``utils::throttler``.
|
|
For ``crate/tests/events.rs``, returns an integration test filter ``events``.
|
|
Returns an empty filter for paths where narrowing doesn't apply (e.g. lib.rs, crate root).
|
|
"""
|
|
abs_path = REPO_ROOT / file_only
|
|
|
|
# Integration test files live under <crate>/tests/
|
|
tests_dir = crate_toml.parent / "tests"
|
|
try:
|
|
rel = abs_path.relative_to(tests_dir)
|
|
stem = PurePosixPath(rel).stem
|
|
if not abs_path.is_dir() and stem != "mod":
|
|
return _RustTestFilter(integration_test=stem)
|
|
except ValueError:
|
|
pass
|
|
|
|
# Unit tests live under <crate>/src/
|
|
src_dir = crate_toml.parent / "src"
|
|
try:
|
|
rel = abs_path.relative_to(src_dir)
|
|
except ValueError:
|
|
return _RustTestFilter()
|
|
|
|
parts = list(rel.parts)
|
|
|
|
# For files: strip .rs extension and skip crate-root entry points
|
|
if not abs_path.is_dir():
|
|
parts[-1] = PurePosixPath(parts[-1]).stem
|
|
if parts[-1] in ("mod", "lib", "main"):
|
|
parts.pop()
|
|
|
|
if not parts:
|
|
return _RustTestFilter()
|
|
|
|
if node_id:
|
|
parts.append(node_id)
|
|
|
|
return _RustTestFilter(module="::".join(parts))
|
|
|
|
|
|
def _find_workspace_toml(crate_toml: Path) -> Path | None:
|
|
"""Walk up from a crate's Cargo.toml to find the workspace root Cargo.toml."""
|
|
parent = crate_toml.parent.parent
|
|
while parent == REPO_ROOT or REPO_ROOT in parent.parents:
|
|
candidate = parent / "Cargo.toml"
|
|
try:
|
|
is_workspace = candidate.exists() and "[workspace]" in candidate.read_text()
|
|
except OSError:
|
|
is_workspace = False
|
|
if is_workspace:
|
|
return candidate
|
|
if parent == parent.parent:
|
|
break
|
|
parent = parent.parent
|
|
return None
|
|
|
|
|
|
def _detect_rust_test(file_only: str, node_id: str | None = None) -> TestRunConfig:
|
|
"""Detect Rust test configuration by finding the nearest Cargo.toml.
|
|
|
|
If the nearest Cargo.toml is a crate inside a workspace, uses the workspace
|
|
root manifest with ``-p <package>`` to target the specific crate.
|
|
Accepts an optional node_id (test function name) to filter to a single test.
|
|
"""
|
|
crate_toml = _find_nearest(file_only, "Cargo.toml")
|
|
if not crate_toml:
|
|
raise click.UsageError(f"No Cargo.toml found for: {file_only}")
|
|
|
|
package_name = _parse_cargo_package_name(crate_toml)
|
|
workspace_toml = _find_workspace_toml(crate_toml)
|
|
|
|
manifest = workspace_toml or crate_toml
|
|
manifest_path = str(manifest.relative_to(REPO_ROOT))
|
|
command = ["cargo", "test", f"--manifest-path={manifest_path}"]
|
|
|
|
if workspace_toml and package_name:
|
|
command.extend(["-p", package_name])
|
|
|
|
test_filter = _rust_module_filter(file_only, crate_toml, node_id)
|
|
if test_filter.integration_test:
|
|
command.extend(["--test", test_filter.integration_test])
|
|
if node_id:
|
|
command.extend(["--", node_id])
|
|
elif test_filter.module:
|
|
command.extend(["--", test_filter.module])
|
|
|
|
# Build description
|
|
filter_label = test_filter.integration_test or test_filter.module
|
|
parts = ["Rust test (cargo test"]
|
|
if workspace_toml and package_name:
|
|
parts.append(f" -p {package_name}")
|
|
parts.append(")")
|
|
if filter_label:
|
|
parts.append(f" ({filter_label})")
|
|
desc = "".join(parts)
|
|
|
|
return TestRunConfig(test_type="rust", command=command, description=desc)
|
|
|
|
|
|
def _detect_go_test(file_only: str) -> TestRunConfig:
|
|
"""Detect Go test configuration by finding the nearest go.mod.
|
|
|
|
Accepts any .go file, go.mod, or directory. Runs ``go test`` from the
|
|
module root directory (where go.mod lives) so Go can resolve packages.
|
|
"""
|
|
go_mod = _find_nearest(file_only, "go.mod")
|
|
if not go_mod:
|
|
raise click.UsageError(f"No go.mod found for: {file_only}")
|
|
|
|
mod_root = go_mod.parent
|
|
mod_root_rel = str(mod_root.relative_to(REPO_ROOT))
|
|
|
|
# Compute the test target relative to the module root
|
|
abs_path = REPO_ROOT / file_only
|
|
if abs_path.is_dir():
|
|
try:
|
|
pkg_rel = str(abs_path.relative_to(mod_root))
|
|
target = f"./{pkg_rel}/..." if pkg_rel != "." else "./..."
|
|
except ValueError:
|
|
target = "./..."
|
|
elif PurePosixPath(file_only).name == "go.mod":
|
|
target = "./..."
|
|
else:
|
|
# Single file — run tests in its package directory
|
|
try:
|
|
pkg_rel = str(abs_path.parent.relative_to(mod_root))
|
|
target = f"./{pkg_rel}/..." if pkg_rel != "." else "./..."
|
|
except ValueError:
|
|
target = "./..."
|
|
|
|
return TestRunConfig(
|
|
test_type="go",
|
|
command=["go", "test", target],
|
|
cwd=mod_root,
|
|
description=f"Go test (in {mod_root_rel}, go test {target})",
|
|
)
|
|
|
|
|
|
def _detect_desktop_vitest(path: str, node_id: str | None = None) -> TestRunConfig:
|
|
"""Detect Vitest configuration for the desktop nested workspace.
|
|
|
|
products/desktop is a standalone pnpm workspace whose packages test with
|
|
Vitest; the root Jest config ignores the whole tree, so run vitest from the
|
|
nearest desktop package (its own config and deps). For the workspace root
|
|
itself, defer to desktop's `pnpm test` (turbo), which is what desktop CI runs.
|
|
"""
|
|
package_json = _find_nearest(path, "package.json")
|
|
pkg_dir = str(package_json.parent.relative_to(REPO_ROOT)) if package_json else None
|
|
if pkg_dir is None or pkg_dir in (_DESKTOP_ROOT, "products", "."):
|
|
return TestRunConfig(
|
|
test_type="vitest",
|
|
command=["pnpm", "--dir", _DESKTOP_ROOT, "test"],
|
|
description="Desktop tests via turbo (nested workspace)",
|
|
)
|
|
|
|
rel = str(PurePosixPath(path).relative_to(pkg_dir))
|
|
command = ["pnpm", "--dir", pkg_dir, "exec", "vitest", "run"]
|
|
if rel != ".":
|
|
command.append(rel)
|
|
if node_id:
|
|
command.extend(["-t", node_id])
|
|
|
|
return TestRunConfig(
|
|
test_type="vitest",
|
|
command=command,
|
|
description=f"Desktop Vitest (in {pkg_dir})",
|
|
)
|
|
|
|
|
|
def _detect_jest_test(file_only: str, file_path: str, node_id: str | None = None) -> TestRunConfig:
|
|
"""Detect Jest test configuration by finding the nearest package.json.
|
|
|
|
Tests under products/ are collected by the root @hanzo/frontend jest config (its
|
|
`roots` include ../products), which carries the path aliases and transforms they
|
|
rely on. The product's own package.json has no jest config, so route them to
|
|
@hanzo/frontend; jest treats the path as a testPathPattern and matches it there.
|
|
"""
|
|
if file_only.startswith(_DESKTOP_ROOT + "/"):
|
|
return _detect_desktop_vitest(file_only, node_id)
|
|
if file_only.startswith("products/"):
|
|
pkg_name: str | None = "@hanzo/frontend"
|
|
else:
|
|
package_json = _find_nearest(file_only, "package.json")
|
|
if not package_json:
|
|
raise click.UsageError(f"No package.json found for: {file_path}")
|
|
pkg_name = _parse_package_json_name(package_json)
|
|
if not pkg_name:
|
|
raise click.UsageError(f"No name field in {package_json.relative_to(REPO_ROOT)}")
|
|
|
|
command = ["pnpm", f"--filter={pkg_name}", "exec", "jest", file_only]
|
|
if node_id:
|
|
command.extend(["--testNamePattern", node_id])
|
|
|
|
return TestRunConfig(
|
|
test_type="jest",
|
|
command=command,
|
|
description=f"Jest test (via {pkg_name})",
|
|
)
|
|
|
|
|
|
def _detect_directory(dir_path: str) -> TestRunConfig:
|
|
"""Detect test type for a directory and run all tests in it."""
|
|
# Go: directory contains or is under a go.mod
|
|
if _find_nearest(dir_path, "go.mod"):
|
|
return _detect_go_test(dir_path)
|
|
|
|
# Rust: directory contains or is under a Cargo.toml
|
|
if _find_nearest(dir_path, "Cargo.toml"):
|
|
return _detect_rust_test(dir_path)
|
|
|
|
# Desktop nested workspace: Vitest, not the root turbo/pytest/Jest routing
|
|
normalized = dir_path.rstrip("/")
|
|
if normalized == _DESKTOP_ROOT or normalized.startswith(_DESKTOP_ROOT + "/"):
|
|
return _detect_desktop_vitest(normalized)
|
|
|
|
# Product root: use Turbo pipeline (only for top-level product dirs)
|
|
if dir_path.startswith("products/"):
|
|
parts = PurePosixPath(dir_path.rstrip("/")).parts
|
|
if len(parts) == 2:
|
|
product_name = parts[1]
|
|
pkg_json = REPO_ROOT / "products" / product_name / "package.json"
|
|
if pkg_json.exists():
|
|
pkg_name = _parse_package_json_name(pkg_json)
|
|
if pkg_name:
|
|
return TestRunConfig(
|
|
test_type="turbo",
|
|
command=["pnpm", "turbo", "run", "backend:test", f"--filter={pkg_name}"],
|
|
description=f"Product tests via Turbo ({pkg_name})",
|
|
)
|
|
|
|
# Playwright: directory is under playwright/
|
|
if dir_path.startswith("playwright/") or dir_path == "playwright":
|
|
return TestRunConfig(
|
|
test_type="playwright",
|
|
command=["pnpm", "exec", "playwright", "test", dir_path],
|
|
description="Playwright E2E tests",
|
|
)
|
|
|
|
# Python: directory is under a known Python root
|
|
if any(dir_path.startswith(root) or dir_path == root.rstrip("/") for root in _PYTHON_ROOTS):
|
|
return TestRunConfig(
|
|
test_type="python",
|
|
command=["pytest", "-s", dir_path],
|
|
description="Python tests (pytest)",
|
|
env=_python_env(),
|
|
)
|
|
|
|
# Jest: directory has a package.json nearby
|
|
if _find_nearest(dir_path, "package.json"):
|
|
return _detect_jest_test(dir_path, dir_path)
|
|
|
|
raise click.UsageError(
|
|
f"Could not detect test type for directory: {dir_path}\n\n"
|
|
"Supported: Python, Jest, Playwright, Rust, Go, Product (Turbo) directories"
|
|
)
|
|
|
|
|
|
def detect_test_type(file_path: str) -> TestRunConfig:
|
|
"""Detect the test type from a file or directory path and return run configuration.
|
|
|
|
Accepts files, directories, and pytest node IDs (path::Class::method).
|
|
Rules are evaluated in priority order; first match wins.
|
|
"""
|
|
# Split node ID (path::selector) — used by Python (pytest) and Rust (cargo test)
|
|
if "::" in file_path:
|
|
file_only, node_id = file_path.split("::", 1)
|
|
else:
|
|
file_only, node_id = file_path, None
|
|
|
|
abs_path = REPO_ROOT / file_only
|
|
|
|
# Directory: detect type from context
|
|
if abs_path.is_dir():
|
|
return _detect_directory(file_only)
|
|
|
|
p = PurePosixPath(file_only)
|
|
ext = p.suffix
|
|
|
|
# 1. Playwright E2E tests
|
|
if p.parts and p.parts[0] == "playwright" and file_only.endswith(".spec.ts"):
|
|
return TestRunConfig(
|
|
test_type="playwright",
|
|
command=["pnpm", "exec", "playwright", "test", file_path],
|
|
description="Playwright E2E test",
|
|
)
|
|
|
|
# 2. Python eval tests (special pytest config)
|
|
if file_only.startswith("ee/scriptai/eval/") and ext == ".py":
|
|
return TestRunConfig(
|
|
test_type="python-eval",
|
|
command=["pytest", "-c", "ee/scriptai/eval/pytest.ini", "-s", file_path],
|
|
description="Python eval test (pytest with eval config)",
|
|
env=_python_env(),
|
|
)
|
|
|
|
# 3. Python tests
|
|
if ext == ".py" and any(file_only.startswith(root) for root in _PYTHON_ROOTS):
|
|
return TestRunConfig(
|
|
test_type="python",
|
|
command=["pytest", "-s", file_path],
|
|
description="Python test (pytest)",
|
|
env=_python_env(),
|
|
)
|
|
|
|
# 4. Jest tests (*.test.ts, *.test.tsx) — finds nearest package.json to determine pnpm filter
|
|
if file_only.endswith((".test.ts", ".test.tsx")):
|
|
return _detect_jest_test(file_only, file_path, node_id)
|
|
|
|
# 5. Rust tests — finds nearest Cargo.toml; supports node IDs (path.rs::test_name)
|
|
if ext == ".rs":
|
|
return _detect_rust_test(file_only, node_id)
|
|
|
|
# 6. Go — any .go file or go.mod; finds nearest go.mod and runs from module root
|
|
if ext == ".go" or PurePosixPath(file_only).name == "go.mod":
|
|
return _detect_go_test(file_only)
|
|
|
|
raise click.UsageError(
|
|
f"Could not detect test type for: {file_path}\n\n"
|
|
"Supported patterns:\n"
|
|
" Files: *.py, *.test.ts(x), *.spec.ts, *.rs, *.go, go.mod\n"
|
|
" Directories: any directory under a supported test root\n"
|
|
" Node IDs: path/to/test.py::TestClass::test_method\n"
|
|
" path/to/file.rs::test_function_name\n"
|
|
" path/to/file.test.ts::test name pattern"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# --changed: find test files changed on the current branch and run them
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _get_changed_files() -> list[str]:
|
|
"""Files changed on the current branch vs master, plus uncommitted changes.
|
|
|
|
``--changed`` is meaningless on master, so guard that here; the diffing itself
|
|
is delegated to the shared change-detection module."""
|
|
branch = subprocess.run(
|
|
["git", "branch", "--show-current"],
|
|
cwd=REPO_ROOT,
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout.strip()
|
|
if branch == "master":
|
|
raise click.UsageError("Cannot use --changed on the master branch.")
|
|
# Drop deleted/renamed-away paths — pytest errors on files that no longer exist.
|
|
return [f for f in changed_files() if (REPO_ROOT / f).exists()]
|
|
|
|
|
|
def _detect_all(test_files: list[str]) -> list[tuple[str, TestRunConfig]]:
|
|
"""Detect test type for each file, skipping files with unknown types."""
|
|
results: list[tuple[str, TestRunConfig]] = []
|
|
for f in test_files:
|
|
try:
|
|
results.append((f, detect_test_type(f)))
|
|
except click.UsageError:
|
|
click.secho(f" Skipping (unknown type): {f}", fg="yellow")
|
|
return results
|
|
|
|
|
|
def _has_mixed_extensions(dir_path: str) -> bool:
|
|
"""Quick check whether a directory contains files with more than one test-relevant extension."""
|
|
abs_dir = REPO_ROOT / dir_path
|
|
extensions: set[str] = set()
|
|
for p in abs_dir.rglob("*"):
|
|
if p.is_file():
|
|
ext = p.suffix
|
|
if ext in (".py", ".ts", ".tsx", ".go", ".rs"):
|
|
extensions.add(ext)
|
|
if len(extensions) > 1:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _check_multi_type(dir_path: str) -> tuple[list[tuple[str, TestRunConfig]], set[str]] | None:
|
|
"""Check if a directory contains test files of multiple types.
|
|
|
|
Uses a cheap extension check to skip the expensive per-file detection in
|
|
the common single-type case. Returns None when only one (or zero) types
|
|
are found.
|
|
"""
|
|
if not _has_mixed_extensions(dir_path):
|
|
return None
|
|
|
|
test_files = _find_test_files(dir_path)
|
|
if not test_files:
|
|
return None
|
|
|
|
detected = _detect_all(test_files)
|
|
types = {config.test_type for _, config in detected}
|
|
if len(types) > 1:
|
|
return detected, types
|
|
return None
|
|
|
|
|
|
def _run_grouped(detected: list[tuple[str, TestRunConfig]], extra_args: list[str]) -> None:
|
|
"""Group pre-detected test files by type and run each group with its appropriate runner."""
|
|
groups: dict[str, list[tuple[str, TestRunConfig]]] = {}
|
|
for f, config in detected:
|
|
groups.setdefault(config.test_type, []).append((f, config))
|
|
|
|
for test_type, entries in groups.items():
|
|
if test_type in ("python", "python-eval"):
|
|
# pytest can take multiple files at once
|
|
cfg = entries[0][1]
|
|
command = cfg.command[:-1] + [f for f, _ in entries]
|
|
click.secho(f"Running {len(entries)} Python test file(s)...", fg="cyan")
|
|
_run(command + extra_args, env=cfg.env_or_none, cwd=cfg.cwd)
|
|
elif test_type == "jest":
|
|
# Sub-group by package so each pnpm --filter is correct
|
|
by_package: dict[str, tuple[TestRunConfig, list[str]]] = {}
|
|
for f, cfg in entries:
|
|
pkg = cfg.command[1] # e.g. "--filter=@hanzo/frontend"
|
|
if pkg not in by_package:
|
|
by_package[pkg] = (cfg, [])
|
|
by_package[pkg][1].append(f)
|
|
for pkg, (cfg, pkg_files) in by_package.items():
|
|
command = cfg.command[:-1] + pkg_files
|
|
click.secho(f"Running {len(pkg_files)} Jest test file(s) ({pkg})...", fg="cyan")
|
|
_run(command + extra_args, env=cfg.env_or_none, cwd=cfg.cwd)
|
|
else:
|
|
for f, cfg in entries:
|
|
click.secho(f"Running: {f}", fg="cyan")
|
|
_run(cfg.command + extra_args, env=cfg.env_or_none, cwd=cfg.cwd)
|
|
|
|
|
|
def _run_changed(extra_args: list[str]) -> None:
|
|
"""Find changed test files and tests for changed source files, then run them."""
|
|
changed = _get_changed_files()
|
|
|
|
directly_changed = {f for f in changed if _is_test_file(f)}
|
|
|
|
discovered: set[str] = set()
|
|
for f in changed:
|
|
if not _is_test_file(f):
|
|
discovered.update(_find_test_files_for_source(f))
|
|
|
|
all_test_files = sorted(directly_changed | discovered)
|
|
|
|
if not all_test_files:
|
|
click.secho("No test files found for changes on this branch.", fg="yellow")
|
|
raise SystemExit(0)
|
|
|
|
if directly_changed:
|
|
click.secho(f"Found {len(directly_changed)} changed test file(s):", fg="cyan")
|
|
for f in sorted(directly_changed):
|
|
click.echo(f" {f}")
|
|
|
|
newly_discovered = discovered - directly_changed
|
|
if newly_discovered:
|
|
click.secho(f"Found {len(newly_discovered)} test file(s) for changed source files:", fg="cyan")
|
|
for f in sorted(newly_discovered):
|
|
click.echo(f" {f}")
|
|
|
|
click.echo()
|
|
_run_grouped(_detect_all(all_test_files), extra_args)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# --watch: re-run tests on file changes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _run_watch(file_path: str, extra_args: list[str]) -> None:
|
|
"""Run tests in watch mode, re-executing on file changes."""
|
|
resolved = _resolve_to_repo_relative(file_path)
|
|
config = detect_test_type(resolved)
|
|
|
|
if config.test_type in ("python", "python-eval"):
|
|
# Use nodemon for Python, matching bin/tests behavior
|
|
watch_dirs = ["./insights", "./common/scriptvm/python", "./ee", "./dags", "./products"]
|
|
|
|
# Build the inner command as a properly shell-escaped string for nodemon --exec
|
|
inner_parts = list(config.command) + list(extra_args)
|
|
env_prefix_parts = [f"{k}={shlex.quote(v)}" for k, v in config.env.items()] if config.env else []
|
|
inner_cmd = " ".join(env_prefix_parts + [shlex.quote(arg) for arg in inner_parts])
|
|
|
|
# Build nodemon as a list to avoid shell=True
|
|
nodemon_cmd: list[str] = ["nodemon"]
|
|
for d in watch_dirs:
|
|
nodemon_cmd.extend(["-w", d])
|
|
nodemon_cmd.extend(["--ext", "py", "--exec", inner_cmd])
|
|
|
|
click.secho(f"Watching: {config.description}", fg="cyan")
|
|
_run(nodemon_cmd)
|
|
|
|
elif config.test_type == "jest":
|
|
# Jest has built-in --watch
|
|
click.secho(f"Watching: {config.description}", fg="cyan")
|
|
_run([*config.command, "--watch", *extra_args], env=config.env_or_none, cwd=config.cwd)
|
|
|
|
else:
|
|
raise click.UsageError(
|
|
f"Watch mode is not supported for {config.test_type} tests.\n"
|
|
"Supported: Python (nodemon) and Jest (--watch)."
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI command
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@click.command(
|
|
name="test",
|
|
help=(
|
|
"Auto-detect test type and run the correct test runner.\n\n"
|
|
"Accepts files, directories, and pytest node IDs. Detects Python "
|
|
"(pytest), Jest, Playwright, Rust (cargo test), Go (go test), and "
|
|
"product directories (Turbo) based on file extension or directory context.\n\n"
|
|
"Extra arguments are passed through to the underlying test runner.\n\n"
|
|
"Examples:\n\n"
|
|
"\b\n"
|
|
" insightscli test insights/api/test/test_user.py # single Python file\n"
|
|
" insightscli test insights/api/test/test_user.py::TestUser::test_create # single Python test\n"
|
|
" insightscli test insights/api/test/test_user.py --watch # re-run on changes\n"
|
|
" insightscli test frontend/src/lib/utils.test.ts # single Jest file\n"
|
|
' insightscli test "frontend/src/lib/utils.test.ts::my test name" # Jest by name pattern\n'
|
|
" insightscli test playwright/e2e/sql-editor.spec.ts # Playwright spec\n"
|
|
" insightscli test cli/src/utils/throttler.rs # single Rust module\n"
|
|
" insightscli test cli/src/utils/throttler.rs::test_create # single Rust test\n"
|
|
" insightscli test --changed # tests for branch changes\n"
|
|
" insightscli test products/alerts/ # all tests in a product"
|
|
),
|
|
context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
|
|
)
|
|
@click.argument("file_path", required=False, type=click.Path())
|
|
@click.option("--changed", is_flag=True, help="Run tests changed on this branch, plus tests for changed source files")
|
|
@click.option("--watch", is_flag=True, help="Re-run tests on file changes (Python and Jest)")
|
|
@click.pass_context
|
|
def test_command(ctx: click.Context, file_path: str | None, changed: bool, watch: bool) -> None:
|
|
"""Auto-detect test type and run the correct test runner."""
|
|
if changed:
|
|
if file_path:
|
|
raise click.UsageError("Cannot combine --changed with a file path.")
|
|
if watch:
|
|
raise click.UsageError("Cannot combine --changed with --watch.")
|
|
_run_changed(list(ctx.args))
|
|
return
|
|
|
|
if not file_path:
|
|
raise click.UsageError(
|
|
"Missing argument FILE_PATH.\n\nUsage: insightscli test [OPTIONS] FILE_PATH\n insightscli test --changed"
|
|
)
|
|
|
|
if watch:
|
|
_run_watch(file_path, list(ctx.args))
|
|
return
|
|
|
|
resolved = _resolve_to_repo_relative(file_path)
|
|
abs_path = REPO_ROOT / resolved
|
|
|
|
# For directories, check if multiple test types are present and run each group.
|
|
if abs_path.is_dir():
|
|
multi_type = _check_multi_type(resolved)
|
|
if multi_type is not None:
|
|
detected, types = multi_type
|
|
click.secho(
|
|
f"Found {len(detected)} test file(s) across {len(types)} type(s)",
|
|
fg="cyan",
|
|
)
|
|
_run_grouped(detected, list(ctx.args))
|
|
return
|
|
|
|
config = detect_test_type(resolved)
|
|
click.secho(f"Detected: {config.description}", fg="cyan")
|
|
_run(config.command + list(ctx.args), env=config.env_or_none, cwd=config.cwd)
|