Files
k-dense-ai--scientific-agen…/skills/autoskill/tests/test_e2e.py
T
2026-07-13 12:12:01 +08:00

328 lines
12 KiB
Python

"""End-to-end tests for the autoskill pipeline.
Exercises the full chain — fetch → redact → cluster → match → synthesize → write
— with real internal modules. External dependencies (screenpipe, LLM, embeddings)
are replaced with deterministic fakes so the suite runs offline in under a second.
"""
import json
import sys
from pathlib import Path
import httpx
import pytest
# Synthetic opaque value for auth-header round-trip tests. Not a credential.
_AUTH = "fake-test-value-1234"
from backends import LocalBackend
from run import run, main
# ---------- fixtures ----------
SKILL_TEMPLATE = "---\nname: {name}\ndescription: {description}\n---\n# {name}\n"
def _write_skill(root: Path, name: str, description: str) -> None:
d = root / name
d.mkdir(parents=True, exist_ok=True)
(d / "SKILL.md").write_text(SKILL_TEMPLATE.format(name=name, description=description))
def _seed_skills_dir(root: Path) -> None:
"""A tiny but representative slice of the real skills/ layout."""
_write_skill(root, "literature-review",
"Systematic literature reviews across PubMed arXiv bioRxiv with citations.")
_write_skill(root, "citation-management",
"Find papers and format citations from Google Scholar and PubMed.")
_write_skill(root, "scientific-writing",
"Write research manuscripts with IMRAD structure and citations.")
_write_skill(root, "scientific-schematics",
"Create scientific diagrams figures and schematics for publications.")
_write_skill(root, "latex-posters",
"Create academic conference posters in LaTeX.")
_write_skill(root, "rdkit",
"Cheminformatics with RDKit molecules drug discovery.")
# ---------- fake screenpipe ----------
def _ocr(ts: str, app: str, title: str, text: str) -> dict:
return {"type": "OCR", "content": {
"timestamp": ts, "app_name": app, "window_name": title, "text": text,
}}
def _realistic_day() -> list:
"""Three workflow patterns repeated twice each, plus noise."""
events = []
# Pattern 1: literature work (Chrome + Zotero) — reuse expected
for hr in (9, 14):
for m in range(0, 20):
events.append(_ocr(f"2026-04-17T{hr:02d}:{m:02d}:00Z",
"Chrome", "PubMed", "searching papers"))
# Pattern 2: slides + schematics (Keynote + Preview) — novel expected
for hr in (11, 16):
for m in range(0, 20):
events.append(_ocr(f"2026-04-17T{hr:02d}:{m:02d}:00Z",
"Keynote", "deck", "slides for keynote talk"))
# Pattern 3: manuscript + figures (VSCode + Preview) — compose expected
for hr in (12, 17):
for m in range(0, 20):
events.append(_ocr(f"2026-04-17T{hr:02d}:{m:02d}:00Z",
"VSCode", "paper.tex", "writing manuscript with figures"))
# Pattern 4 (lonely): should be dropped (only one session)
for m in range(0, 10):
events.append(_ocr(f"2026-04-17T22:{m:02d}:00Z",
"Lonely", "", "whatever"))
return events
def _screenpipe_client(events: list) -> httpx.Client:
# Paginate in chunks of 20 to exercise fetch_window's pagination loop.
page_size = 20
def handler(request):
params = dict(request.url.params)
offset = int(params.get("offset", "0"))
chunk = events[offset:offset + page_size]
return httpx.Response(200, json={
"data": chunk,
"pagination": {"limit": page_size, "offset": offset, "total": len(events)},
})
return httpx.Client(transport=httpx.MockTransport(handler), base_url="http://screenpipe.test")
# ---------- fake LM Studio ----------
class _FakeLMStudioHandler:
"""Returns different verdicts based on apps mentioned in the prompt."""
def __init__(self):
self.prompts = []
def __call__(self, request):
body = json.loads(request.read())
prompt = body["messages"][0]["content"]
self.prompts.append(prompt)
if "Chrome" in prompt and "PubMed" in prompt:
out = {"verdict": "reuse", "target": "literature-review"}
elif "Keynote" in prompt:
body = ("---\nname: keynote-talk-prep\n"
"description: Prepare and iterate on Keynote talks.\n---\n"
"# keynote-talk-prep\n")
out = {"verdict": "novel", "name": "keynote-talk-prep", "skill_body": body}
elif "VSCode" in prompt:
body = ("---\nname: manuscript-with-figures\n"
"description: Chain scientific-writing + scientific-schematics.\n---\n"
"# manuscript-with-figures\n\n"
"Invoke scientific-writing then scientific-schematics.\n")
out = {"verdict": "compose", "name": "manuscript-with-figures", "skill_body": body}
else:
out = {"verdict": "reuse", "target": "literature-review"}
return httpx.Response(200, json={
"choices": [{"message": {"content": json.dumps(out)}}]
})
def _keyword_embedder(text: str):
keywords = ["paper", "pubmed", "literature", "citation", "writing",
"schematic", "figure", "poster", "molecule", "slides"]
return [1.0 if k in text.lower() else 0.0 for k in keywords]
def _base_config():
return {
"cluster": {"min_session_minutes": 0, "idle_gap_minutes": 10, "min_cluster_size": 2},
}
# ---------- tests ----------
def test_full_pipeline_produces_report_and_drafts(tmp_path):
skills_dir = tmp_path / "skills"
_seed_skills_dir(skills_dir)
fake_lmstudio = _FakeLMStudioHandler()
lm_client = httpx.Client(
transport=httpx.MockTransport(fake_lmstudio),
base_url="http://localhost:1234/v1",
)
backend = LocalBackend(endpoint="http://localhost:1234/v1",
model="Gemma-4-31B-it", client=lm_client)
out = run(
_base_config(),
start_time="2026-04-17T00:00:00Z", end_time="2026-04-17T23:59:59Z",
out_dir=tmp_path / "_proposed",
screenpipe_client=_screenpipe_client(_realistic_day()),
backend=backend,
embedder=_keyword_embedder,
skills_dir=skills_dir,
now=lambda: "2026-04-17T18-00-00",
)
# Report exists and mentions all three surviving clusters.
report = (out / "report.md").read_text()
assert "Chrome" in report
assert "Keynote" in report
assert "VSCode" in report
assert "reuse" in report
assert "novel" in report
assert "compose" in report
# Lonely cluster was dropped by min_cluster_size.
assert "Lonely" not in report
# Novel draft landed under new-skills/.
novel_skill = out / "new-skills" / "keynote-talk-prep" / "SKILL.md"
assert novel_skill.exists()
assert "keynote-talk-prep" in novel_skill.read_text()
# Compose draft landed under composition-recipes/.
compose_skill = out / "composition-recipes" / "manuscript-with-figures" / "SKILL.md"
assert compose_skill.exists()
body = compose_skill.read_text()
assert "scientific-writing" in body
assert "scientific-schematics" in body
# Reuse verdict writes no draft but does cite the matched existing skill.
assert not (out / "new-skills" / "literature-review").exists()
assert "literature-review" in report
# Pagination actually happened (more than one fetch).
# 120 events / 20 per page = 6 pages → ≥6 backend-independent calls.
# (Can't assert directly without reaching into the transport; but the test
# would fail above if pagination were broken since clusters would be empty.)
def test_pipeline_redaction_prevents_secrets_leaving_the_host(tmp_path):
skills_dir = tmp_path / "skills"
_seed_skills_dir(skills_dir)
# Plant secrets in every OCR event.
toxic = [
_ocr(f"2026-04-17T{hr:02d}:{m:02d}:00Z", "Chrome", "Gmail",
"email alice@example.com password sk-abcdefghijklmnopqrstuvwxyz012345")
for hr in (9, 14) for m in range(0, 20)
]
fake_lmstudio = _FakeLMStudioHandler()
lm_client = httpx.Client(
transport=httpx.MockTransport(fake_lmstudio),
base_url="http://localhost:1234/v1",
)
backend = LocalBackend(endpoint="http://localhost:1234/v1",
model="Gemma-4-31B-it", client=lm_client)
run(
_base_config(),
start_time="s", end_time="e",
out_dir=tmp_path / "_proposed",
screenpipe_client=_screenpipe_client(toxic),
backend=backend, embedder=_keyword_embedder, skills_dir=skills_dir,
now=lambda: "ts",
)
assert fake_lmstudio.prompts, "backend should have been called"
for prompt in fake_lmstudio.prompts:
assert "alice@example.com" not in prompt
assert "sk-abcdefghijklmnopqrstuvwxyz012345" not in prompt
def test_auth_token_threads_through_run_into_fetch_window(tmp_path):
"""Integration: config's screenpipe.token reaches the outgoing HTTP request."""
skills_dir = tmp_path / "skills"
_seed_skills_dir(skills_dir)
seen_auth = []
def handler(request):
seen_auth.append(request.headers.get("authorization"))
return httpx.Response(200, json={
"data": [], "pagination": {"limit": 20, "offset": 0, "total": 0},
})
client = httpx.Client(transport=httpx.MockTransport(handler),
base_url="http://screenpipe.test")
run(
_base_config(),
start_time="2026-04-17T00:00:00Z", end_time="2026-04-17T23:59:59Z",
out_dir=tmp_path / "_proposed",
screenpipe_client=client,
backend=None, embedder=_keyword_embedder, skills_dir=skills_dir,
screenpipe_token=_AUTH,
now=lambda: "ts",
)
assert seen_auth, "screenpipe should have been called"
assert all(h == f"Bearer {_AUTH}" for h in seen_auth), seen_auth
def test_cli_dry_run_writes_plan_without_backend_or_embedding_model(tmp_path, monkeypatch):
"""Exercises scripts/run.py's main() via argv with --dry-run.
--dry-run is the only path that avoids loading sentence-transformers and
instantiating a backend, so we can smoke-test the CLI offline.
"""
skills_dir = tmp_path / "skills"
_seed_skills_dir(skills_dir)
# Minimal config.yaml pointing at our fake screenpipe (via localhost base_url
# that we'll monkeypatch httpx.Client to respect).
config = tmp_path / "config.yaml"
config.write_text(
"backend: local\n"
"local:\n"
" endpoint: http://localhost:1234/v1\n"
" model: Gemma-4-31B-it\n"
"screenpipe:\n"
" url: http://screenpipe.test\n"
"cluster:\n"
" min_session_minutes: 0\n"
" idle_gap_minutes: 10\n"
" min_cluster_size: 2\n"
)
# Install a MockTransport-backed default for any httpx.Client the CLI builds.
events = _realistic_day()
def handler(request):
params = dict(request.url.params)
offset = int(params.get("offset", "0"))
chunk = events[offset:offset + 20]
return httpx.Response(200, json={
"data": chunk,
"pagination": {"limit": 20, "offset": offset, "total": len(events)},
})
original_client = httpx.Client
monkeypatch.setattr(httpx, "Client", lambda *a, **kw: original_client(
*a, **{**kw, "transport": httpx.MockTransport(handler)}
))
# Need pyyaml for main() to load the config; install on demand and skip if unavailable.
yaml = pytest.importorskip("yaml")
out_dir = tmp_path / "_proposed"
rc = main([
"--start", "2026-04-17T00:00:00Z",
"--end", "2026-04-17T23:59:59Z",
"--config", str(config),
"--skills-dir", str(skills_dir),
"--out", str(out_dir),
"--dry-run",
])
assert rc == 0
# One timestamped subdir was created under --out.
subdirs = [p for p in out_dir.iterdir() if p.is_dir()]
assert len(subdirs) == 1
plan = (subdirs[0] / "plan.md").read_text()
assert "Cluster" in plan
assert "Chrome" in plan