0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
305 lines
12 KiB
Python
305 lines
12 KiB
Python
"""Integration tests for headroom learn — using real session data.
|
|
|
|
These tests run against actual conversation data on the machine.
|
|
They verify the full pipeline: scan → analyze → recommend → write.
|
|
Tests are skipped if the required data directories don't exist.
|
|
The LLM-based analyzer tests require ANTHROPIC_API_KEY.
|
|
|
|
Key behaviors tested:
|
|
- Empty recommendations don't create files
|
|
- Real Codex/Claude Code sessions can be scanned
|
|
- Skip logic: no file writes when nothing meaningful is found
|
|
- Idempotency: running twice produces same output
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from headroom.learn.analyzer import SessionAnalyzer
|
|
from headroom.learn.models import (
|
|
ProjectInfo,
|
|
Recommendation,
|
|
RecommendationTarget,
|
|
)
|
|
from headroom.learn.scanner import _greedy_path_decode
|
|
from headroom.learn.writer import ClaudeCodeWriter, CodexWriter
|
|
|
|
# =============================================================================
|
|
# Writer Tests (no LLM needed)
|
|
# =============================================================================
|
|
|
|
|
|
class TestSkipWriteLogic:
|
|
"""Verify files are NOT written when there's nothing to write."""
|
|
|
|
def test_empty_recommendations_no_write(self, tmp_path):
|
|
"""Zero recommendations = zero files written."""
|
|
proj = ProjectInfo(
|
|
name="clean-project",
|
|
project_path=tmp_path / "proj",
|
|
data_path=tmp_path / "data",
|
|
)
|
|
(tmp_path / "proj").mkdir()
|
|
(tmp_path / "data" / "memory").mkdir(parents=True)
|
|
|
|
writer = ClaudeCodeWriter()
|
|
result = writer.write([], proj, dry_run=False)
|
|
|
|
assert result.files_written == []
|
|
assert not (tmp_path / "proj" / "CLAUDE.md").exists()
|
|
assert not (tmp_path / "data" / "memory" / "MEMORY.md").exists()
|
|
|
|
def test_codex_empty_no_write(self, tmp_path):
|
|
"""Codex writer also skips on empty recommendations."""
|
|
proj = ProjectInfo(name="clean", project_path=tmp_path, data_path=tmp_path)
|
|
writer = CodexWriter()
|
|
result = writer.write([], proj, dry_run=False)
|
|
assert result.files_written == []
|
|
assert not (tmp_path / "AGENTS.md").exists()
|
|
|
|
|
|
class TestIdempotency:
|
|
"""Running learn twice should produce the same output, not duplicate."""
|
|
|
|
def test_double_write_replaces_not_appends(self, tmp_path):
|
|
proj = ProjectInfo(name="test", project_path=tmp_path, data_path=tmp_path / "data")
|
|
(tmp_path / "data" / "memory").mkdir(parents=True)
|
|
|
|
recs = [
|
|
Recommendation(
|
|
target=RecommendationTarget.CONTEXT_FILE,
|
|
section="Environment",
|
|
content="- Use `uv run python`",
|
|
confidence=0.9,
|
|
evidence_count=10,
|
|
)
|
|
]
|
|
|
|
writer = ClaudeCodeWriter()
|
|
# First write
|
|
writer.write(recs, proj, dry_run=False)
|
|
first_content = (tmp_path / "CLAUDE.local.md").read_text()
|
|
|
|
# Second write (same recs)
|
|
writer.write(recs, proj, dry_run=False)
|
|
second_content = (tmp_path / "CLAUDE.local.md").read_text()
|
|
|
|
# Content should be identical (replaced, not appended)
|
|
assert first_content == second_content
|
|
assert second_content.count("uv run python") == 1
|
|
|
|
|
|
class TestFalsePositiveFiltering:
|
|
"""Verify that common false positives don't generate recommendations."""
|
|
|
|
def test_sed_output_not_error(self):
|
|
"""sed printing file content with 'Error:' in it isn't a real error."""
|
|
from headroom.learn.scanner import is_error_content
|
|
|
|
# Normal code output that happens to contain "error" in identifiers
|
|
assert not is_error_content("def handle_error(e):\n print('ok')")
|
|
|
|
def test_real_error_detected(self):
|
|
"""Actual errors should be detected."""
|
|
from headroom.learn.scanner import is_error_content
|
|
|
|
assert is_error_content("ModuleNotFoundError: No module named 'flask'")
|
|
assert is_error_content(
|
|
"FileNotFoundError: [Errno 2] No such file or directory: '/bad/path'"
|
|
)
|
|
assert is_error_content("bash: unknown_cmd: command not found")
|
|
|
|
|
|
# =============================================================================
|
|
# Real-World Integration Tests (skipped if data not present)
|
|
# =============================================================================
|
|
|
|
|
|
CLAUDE_DIR = Path.home() / ".claude" / "projects"
|
|
CODEX_DIR = Path.home() / ".codex" / "sessions"
|
|
HAS_API_KEY = bool(os.environ.get("ANTHROPIC_API_KEY"))
|
|
HAS_CODEX_DATA = CODEX_DIR.exists() and (
|
|
any(CODEX_DIR.rglob("*.json")) or any(CODEX_DIR.rglob("*.jsonl"))
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not CLAUDE_DIR.exists(), reason="No Claude Code data")
|
|
class TestClaudeCodeIntegration:
|
|
"""Integration tests against real Claude Code session data."""
|
|
|
|
def test_scanner_discovers_projects(self):
|
|
from headroom.learn.scanner import ClaudeCodeScanner
|
|
|
|
scanner = ClaudeCodeScanner()
|
|
projects = scanner.discover_projects()
|
|
if not projects:
|
|
pytest.skip("No Claude Code projects found")
|
|
assert len(projects) > 0
|
|
for p in projects:
|
|
assert p.name
|
|
assert p.data_path.exists()
|
|
|
|
def test_scanner_extracts_events(self):
|
|
"""Scanner should extract events including user messages."""
|
|
from headroom.learn.scanner import ClaudeCodeScanner
|
|
|
|
scanner = ClaudeCodeScanner()
|
|
projects = scanner.discover_projects()
|
|
if not projects:
|
|
pytest.skip("No Claude Code projects found")
|
|
best = max(projects, key=lambda p: len(list(p.data_path.glob("*.jsonl"))))
|
|
sessions = scanner.scan_project(best)
|
|
if not sessions:
|
|
pytest.skip("No Claude Code sessions found")
|
|
assert len(sessions) > 0
|
|
|
|
# At least some sessions should have events
|
|
sessions_with_events = [s for s in sessions if s.events]
|
|
assert len(sessions_with_events) > 0
|
|
|
|
@pytest.mark.skipif(not HAS_API_KEY, reason="No ANTHROPIC_API_KEY")
|
|
def test_full_pipeline_produces_output(self):
|
|
"""Scan → analyze on real data produces valid output."""
|
|
from headroom.learn.scanner import ClaudeCodeScanner
|
|
|
|
scanner = ClaudeCodeScanner()
|
|
projects = scanner.discover_projects()
|
|
if not projects:
|
|
pytest.skip("No Claude Code projects found")
|
|
best = max(projects, key=lambda p: len(list(p.data_path.glob("*.jsonl"))))
|
|
sessions = scanner.scan_project(best)
|
|
if not sessions:
|
|
pytest.skip("No Claude Code sessions found")
|
|
assert len(sessions) > 0
|
|
|
|
analyzer = SessionAnalyzer()
|
|
result = analyzer.analyze(best, sessions)
|
|
|
|
assert result.total_calls > 0
|
|
assert result.total_sessions > 0
|
|
assert 0 <= result.failure_rate <= 0.5
|
|
|
|
def test_dry_run_writes_nothing(self):
|
|
"""Dry run should never create files."""
|
|
from headroom.learn.scanner import ClaudeCodeScanner
|
|
|
|
scanner = ClaudeCodeScanner()
|
|
projects = scanner.discover_projects()
|
|
if not projects:
|
|
pytest.skip("No Claude Code projects found")
|
|
best = max(projects, key=lambda p: len(list(p.data_path.glob("*.jsonl"))))
|
|
|
|
# Use mock LLM to avoid needing API key
|
|
mock_response = {
|
|
"context_file_rules": [
|
|
{
|
|
"section": "Test",
|
|
"content": "- test rule",
|
|
"estimated_tokens_saved": 100,
|
|
"evidence_count": 2,
|
|
}
|
|
],
|
|
"memory_file_rules": [],
|
|
}
|
|
with patch("headroom.learn.analyzer._call_llm", return_value=mock_response):
|
|
sessions = scanner.scan_project(best)
|
|
result = SessionAnalyzer(model="gpt-4o").analyze(best, sessions)
|
|
recs = result.recommendations
|
|
|
|
writer = ClaudeCodeWriter()
|
|
write_result = writer.write(recs, best, dry_run=True)
|
|
|
|
assert write_result.dry_run is True
|
|
for fp in write_result.files_written:
|
|
assert "CLAUDE" in fp.name or "MEMORY" in fp.name
|
|
|
|
|
|
class TestDecodeProjectPath:
|
|
"""Unit tests for _greedy_path_decode — covers dot-in-path bug (GitHub.nosync)."""
|
|
|
|
def test_dot_in_directory_name(self, tmp_path):
|
|
"""Paths with dots (e.g. GitHub.nosync) must decode correctly.
|
|
|
|
Claude Code encodes '/' and '.' both as '-', so 'GitHub.nosync'
|
|
becomes 'GitHub-nosync' in the directory name.
|
|
_greedy_path_decode must reconstruct it by trying '.' as a join.
|
|
"""
|
|
# base = tmp_path, remaining parts = ["GitHub", "nosync", "myproject"]
|
|
# which came from encoding "GitHub.nosync/myproject" as "GitHub-nosync-myproject"
|
|
(tmp_path / "GitHub.nosync" / "myproject").mkdir(parents=True)
|
|
result = _greedy_path_decode(tmp_path, ["GitHub", "nosync", "myproject"])
|
|
assert result == tmp_path / "GitHub.nosync" / "myproject"
|
|
|
|
def test_hyphen_in_directory_name(self, tmp_path):
|
|
"""Paths with literal hyphens decode correctly (existing behavior preserved)."""
|
|
(tmp_path / "my-project").mkdir()
|
|
result = _greedy_path_decode(tmp_path, ["my", "project"])
|
|
assert result == tmp_path / "my-project"
|
|
|
|
def test_simple_path_no_ambiguity(self, tmp_path):
|
|
"""Plain paths with no special chars still decode correctly."""
|
|
(tmp_path / "myproject").mkdir()
|
|
result = _greedy_path_decode(tmp_path, ["myproject"])
|
|
assert result == tmp_path / "myproject"
|
|
|
|
def test_dot_preferred_over_slash_when_slash_missing(self, tmp_path):
|
|
"""When GitHub/nosync doesn't exist but GitHub.nosync does, use dot."""
|
|
# Only create the dot version, not the slash version
|
|
(tmp_path / "GitHub.nosync").mkdir()
|
|
result = _greedy_path_decode(tmp_path, ["GitHub", "nosync"])
|
|
assert result == tmp_path / "GitHub.nosync"
|
|
|
|
|
|
@pytest.mark.skipif(not HAS_CODEX_DATA, reason="No Codex data")
|
|
class TestCodexIntegration:
|
|
"""Integration tests against real Codex session data."""
|
|
|
|
def test_scanner_discovers_sessions(self):
|
|
from headroom.learn.scanner import CodexScanner
|
|
|
|
scanner = CodexScanner()
|
|
projects = scanner.discover_projects()
|
|
if not projects:
|
|
pytest.skip("No Codex projects found")
|
|
assert len(projects) == 1 # Codex returns one "project"
|
|
|
|
def test_full_pipeline(self):
|
|
"""Full pipeline on real Codex data."""
|
|
from headroom.learn.scanner import CodexScanner
|
|
|
|
scanner = CodexScanner()
|
|
projects = scanner.discover_projects()
|
|
if not projects:
|
|
pytest.skip("No Codex projects found")
|
|
sessions = scanner.scan_project(projects[0])
|
|
|
|
assert len(sessions) > 0
|
|
|
|
# Codex has only Bash tool (shell)
|
|
all_tools = {tc.name for s in sessions for tc in s.tool_calls}
|
|
assert "Bash" in all_tools
|
|
|
|
def test_codex_writer_targets_agents_md(self, tmp_path):
|
|
"""Codex writer should target AGENTS.md, not CLAUDE.md."""
|
|
proj = ProjectInfo(name="codex-test", project_path=tmp_path, data_path=tmp_path)
|
|
recs = [
|
|
Recommendation(
|
|
target=RecommendationTarget.CONTEXT_FILE,
|
|
section="Commands",
|
|
content="- Use npm run test",
|
|
confidence=0.9,
|
|
evidence_count=5,
|
|
)
|
|
]
|
|
|
|
writer = CodexWriter()
|
|
result = writer.write(recs, proj, dry_run=True)
|
|
for fp in result.files_written:
|
|
assert fp.name in ("AGENTS.md", "instructions.md")
|
|
assert "CLAUDE.md" not in fp.name
|