Skip to content

Commit 7e2ecec

Browse files
DavidsonGomesclaude
andcommitted
test(plugins): hook executions persist correctly per dialect
PG: row in plugin_hook_runs with stdout/stderr/exit_code/duration_ms. Truncate: stdout/stderr >1 MB → truncated=True + [TRUNCATED] marker. Timed-out hook → metadata.timed_out=True. SQLite AC1: .log file created, plugin_hook_runs table stays empty. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 171eac9 commit 7e2ecec

1 file changed

Lines changed: 398 additions & 0 deletions

File tree

Lines changed: 398 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,398 @@
1+
"""Phase 6 — plugin_hook_runs: dialect-aware persistence.
2+
3+
Tests:
4+
PG mode:
5+
- Successful hook run → row in plugin_hook_runs with stdout/stderr/exit_code
6+
- Stdout > 1 MB → row.truncated = True, stdout truncated at 1 MB + marker
7+
- Stderr > 1 MB → row.truncated = True, stderr truncated at 1 MB + marker
8+
- Timed-out hook → row.metadata includes timed_out=True
9+
10+
SQLite mode (AC1 — invariant):
11+
- Successful hook run → .log file created in ADWs/logs/plugins/
12+
- plugin_hook_runs table is NOT queried/written
13+
14+
Markers:
15+
@pytest.mark.postgres — requires DATABASE_URL pointing to PG (Docker)
16+
@pytest.mark.sqlite — SQLite only, no Docker required
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import json
22+
import os
23+
import stat
24+
import subprocess
25+
import sys
26+
import textwrap
27+
from datetime import datetime
28+
from pathlib import Path
29+
from unittest.mock import MagicMock, patch
30+
31+
import pytest
32+
import sqlalchemy as sa
33+
from sqlalchemy import text as sa_text
34+
35+
REPO_ROOT = Path(__file__).resolve().parents[2]
36+
BACKEND_DIR = REPO_ROOT / "dashboard" / "backend"
37+
sys.path.insert(0, str(BACKEND_DIR))
38+
39+
_ALEMBIC_DIR = REPO_ROOT / "dashboard" / "alembic"
40+
41+
# ---------------------------------------------------------------------------
42+
# Helpers
43+
# ---------------------------------------------------------------------------
44+
45+
def _alembic_upgrade(db_url: str) -> subprocess.CompletedProcess:
46+
env = {**os.environ, "DATABASE_URL": db_url}
47+
return subprocess.run(
48+
[sys.executable, "-m", "alembic", "upgrade", "head"],
49+
cwd=str(_ALEMBIC_DIR),
50+
capture_output=True,
51+
text=True,
52+
env=env,
53+
)
54+
55+
56+
def _make_hook_plugin_dir(tmp_path: Path, slug: str, hook_name: str, script_body: str) -> Path:
57+
"""Create a minimal plugin dir with a hooks/<hook_name>.sh script."""
58+
plugin_dir = tmp_path / slug
59+
hooks_dir = plugin_dir / "hooks"
60+
hooks_dir.mkdir(parents=True)
61+
script = hooks_dir / f"{hook_name}.sh"
62+
script.write_text(f"#!/usr/bin/env bash\n{script_body}\n", encoding="utf-8")
63+
script.chmod(script.stat().st_mode | stat.S_IXUSR)
64+
return plugin_dir
65+
66+
67+
def _make_in_memory_sqlite_engine_with_log_table() -> sa.Engine:
68+
"""Return a SQLite in-memory engine with plugin_hook_runs table."""
69+
engine = sa.create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
70+
with engine.begin() as conn:
71+
conn.execute(sa_text("""
72+
CREATE TABLE IF NOT EXISTS plugin_hook_runs (
73+
id INTEGER PRIMARY KEY AUTOINCREMENT,
74+
slug TEXT NOT NULL,
75+
hook_name TEXT NOT NULL,
76+
sha256 TEXT,
77+
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
78+
ended_at DATETIME,
79+
duration_ms INTEGER,
80+
exit_code INTEGER,
81+
stdout TEXT,
82+
stderr TEXT,
83+
truncated INTEGER NOT NULL DEFAULT 0,
84+
metadata TEXT
85+
)
86+
"""))
87+
return engine
88+
89+
90+
def _patch_dialect_pg(engine: sa.Engine):
91+
"""Patch plugin_hook_runner so _persist_hook_run sees PostgreSQL dialect + given engine."""
92+
return [
93+
patch("plugin_hook_runner._get_dialect_name", return_value="postgresql"),
94+
patch("plugin_hook_runner._get_db_engine", return_value=engine),
95+
]
96+
97+
98+
def _patch_dialect_sqlite(engine: sa.Engine):
99+
"""Patch plugin_hook_runner so _persist_hook_run sees SQLite dialect."""
100+
return [
101+
patch("plugin_hook_runner._get_dialect_name", return_value="sqlite"),
102+
patch("plugin_hook_runner._get_db_engine", return_value=engine),
103+
]
104+
105+
106+
# ---------------------------------------------------------------------------
107+
# Fixtures
108+
# ---------------------------------------------------------------------------
109+
110+
@pytest.fixture()
111+
def pg_url():
112+
"""PG DATABASE_URL from env; skip if absent."""
113+
url = os.environ.get("DATABASE_URL", "")
114+
if not url or "postgresql" not in url:
115+
pytest.skip("DATABASE_URL not pointing to PostgreSQL — skipping PG test")
116+
result = _alembic_upgrade(url)
117+
assert result.returncode == 0, f"PG alembic upgrade failed:\n{result.stderr}"
118+
return url
119+
120+
121+
@pytest.fixture()
122+
def pg_engine(pg_url):
123+
return sa.create_engine(
124+
pg_url.replace("postgresql://", "postgresql+psycopg2://", 1)
125+
if pg_url.startswith("postgresql://") and "+psycopg2" not in pg_url
126+
else pg_url
127+
)
128+
129+
130+
@pytest.fixture()
131+
def mem_engine():
132+
"""In-memory SQLite engine with plugin_hook_runs table."""
133+
return _make_in_memory_sqlite_engine_with_log_table()
134+
135+
136+
# ---------------------------------------------------------------------------
137+
# Imports (after sys.path is set)
138+
# ---------------------------------------------------------------------------
139+
140+
from plugin_hook_runner import ( # noqa: E402
141+
_persist_hook_run,
142+
run_lifecycle_hook,
143+
)
144+
145+
# ---------------------------------------------------------------------------
146+
# PG mode tests
147+
# ---------------------------------------------------------------------------
148+
149+
@pytest.mark.postgres
150+
def test_pg_successful_hook_inserts_row(tmp_path, pg_engine):
151+
"""PG: successful hook run → row in plugin_hook_runs with correct fields."""
152+
# Delete any leftover rows from prior test runs
153+
with pg_engine.begin() as conn:
154+
conn.execute(sa_text("DELETE FROM plugin_hook_runs WHERE slug = 'test-phase6'"))
155+
156+
plugin_dir = _make_hook_plugin_dir(tmp_path, "test-phase6", "post-install", "echo hello")
157+
158+
patches = _patch_dialect_pg(pg_engine)
159+
with patches[0], patches[1]:
160+
result = run_lifecycle_hook(plugin_dir, "post-install", "test-phase6", timeout=10)
161+
162+
assert result["ran"] is True
163+
assert result["exit_code"] == 0
164+
165+
with pg_engine.connect() as conn:
166+
row = conn.execute(sa_text(
167+
"SELECT slug, hook_name, exit_code, stdout, stderr, truncated "
168+
"FROM plugin_hook_runs WHERE slug = 'test-phase6' ORDER BY id DESC LIMIT 1"
169+
)).fetchone()
170+
171+
assert row is not None, "Expected a row in plugin_hook_runs"
172+
assert row[0] == "test-phase6"
173+
assert row[1] == "post-install"
174+
assert row[2] == 0
175+
assert "hello" in (row[3] or "")
176+
assert row[5] is False or row[5] == 0 # truncated = False
177+
178+
179+
@pytest.mark.postgres
180+
def test_pg_hook_row_has_duration_ms(tmp_path, pg_engine):
181+
"""PG: row includes a positive duration_ms."""
182+
with pg_engine.begin() as conn:
183+
conn.execute(sa_text("DELETE FROM plugin_hook_runs WHERE slug = 'test-phase6-dur'"))
184+
185+
plugin_dir = _make_hook_plugin_dir(tmp_path, "test-phase6-dur", "post-install", "echo dur")
186+
187+
patches = _patch_dialect_pg(pg_engine)
188+
with patches[0], patches[1]:
189+
run_lifecycle_hook(plugin_dir, "post-install", "test-phase6-dur", timeout=10)
190+
191+
with pg_engine.connect() as conn:
192+
row = conn.execute(sa_text(
193+
"SELECT duration_ms FROM plugin_hook_runs "
194+
"WHERE slug = 'test-phase6-dur' ORDER BY id DESC LIMIT 1"
195+
)).fetchone()
196+
197+
assert row is not None
198+
assert isinstance(row[0], int)
199+
assert row[0] >= 0
200+
201+
202+
@pytest.mark.postgres
203+
def test_pg_truncate_stdout(pg_engine):
204+
"""PG: stdout > 1 MB → row.truncated = True, stdout cut at 1 MB + marker."""
205+
with pg_engine.begin() as conn:
206+
conn.execute(sa_text("DELETE FROM plugin_hook_runs WHERE slug = 'test-trunc-stdout'"))
207+
208+
big_stdout = "x" * (1024 * 1024 + 500)
209+
started = datetime(2026, 1, 1, 12, 0, 0)
210+
ended = datetime(2026, 1, 1, 12, 0, 1)
211+
212+
patches = _patch_dialect_pg(pg_engine)
213+
with patches[0], patches[1]:
214+
_persist_hook_run(
215+
slug="test-trunc-stdout",
216+
hook_name="post-install",
217+
timestamp="20260101T120000Z",
218+
script_sha256="abc123",
219+
started_at=started,
220+
ended_at=ended,
221+
exit_code=0,
222+
stdout=big_stdout,
223+
stderr="",
224+
timed_out=False,
225+
)
226+
227+
with pg_engine.connect() as conn:
228+
row = conn.execute(sa_text(
229+
"SELECT stdout, truncated FROM plugin_hook_runs "
230+
"WHERE slug = 'test-trunc-stdout' ORDER BY id DESC LIMIT 1"
231+
)).fetchone()
232+
233+
assert row is not None
234+
stored_stdout = row[0]
235+
truncated = row[1]
236+
237+
assert truncated is True or truncated == 1, "truncated must be True"
238+
assert len(stored_stdout) <= 1024 * 1024 + len("\n... [TRUNCATED]") + 10
239+
assert stored_stdout.endswith("\n... [TRUNCATED]")
240+
241+
242+
@pytest.mark.postgres
243+
def test_pg_truncate_stderr(pg_engine):
244+
"""PG: stderr > 1 MB → row.truncated = True, stderr cut at 1 MB + marker."""
245+
with pg_engine.begin() as conn:
246+
conn.execute(sa_text("DELETE FROM plugin_hook_runs WHERE slug = 'test-trunc-stderr'"))
247+
248+
big_stderr = "e" * (1024 * 1024 + 100)
249+
started = datetime(2026, 1, 1, 13, 0, 0)
250+
ended = datetime(2026, 1, 1, 13, 0, 1)
251+
252+
patches = _patch_dialect_pg(pg_engine)
253+
with patches[0], patches[1]:
254+
_persist_hook_run(
255+
slug="test-trunc-stderr",
256+
hook_name="post-install",
257+
timestamp="20260101T130000Z",
258+
script_sha256="def456",
259+
started_at=started,
260+
ended_at=ended,
261+
exit_code=0,
262+
stdout="",
263+
stderr=big_stderr,
264+
timed_out=False,
265+
)
266+
267+
with pg_engine.connect() as conn:
268+
row = conn.execute(sa_text(
269+
"SELECT stderr, truncated FROM plugin_hook_runs "
270+
"WHERE slug = 'test-trunc-stderr' ORDER BY id DESC LIMIT 1"
271+
)).fetchone()
272+
273+
assert row is not None
274+
assert row[1] is True or row[1] == 1, "truncated must be True"
275+
assert row[0].endswith("\n... [TRUNCATED]")
276+
277+
278+
@pytest.mark.postgres
279+
def test_pg_timed_out_hook_metadata(tmp_path, pg_engine):
280+
"""PG: timed-out hook sets metadata.timed_out=True."""
281+
with pg_engine.begin() as conn:
282+
conn.execute(sa_text("DELETE FROM plugin_hook_runs WHERE slug = 'test-timeout-meta'"))
283+
284+
started = datetime(2026, 1, 1, 14, 0, 0)
285+
ended = datetime(2026, 1, 1, 14, 0, 5)
286+
287+
patches = _patch_dialect_pg(pg_engine)
288+
with patches[0], patches[1]:
289+
_persist_hook_run(
290+
slug="test-timeout-meta",
291+
hook_name="post-install",
292+
timestamp="20260101T140000Z",
293+
script_sha256="fff000",
294+
started_at=started,
295+
ended_at=ended,
296+
exit_code=None,
297+
stdout="partial",
298+
stderr="",
299+
timed_out=True,
300+
metadata={"timeout_seconds": 60},
301+
)
302+
303+
with pg_engine.connect() as conn:
304+
row = conn.execute(sa_text(
305+
"SELECT metadata FROM plugin_hook_runs "
306+
"WHERE slug = 'test-timeout-meta' ORDER BY id DESC LIMIT 1"
307+
)).fetchone()
308+
309+
assert row is not None
310+
meta = json.loads(row[0])
311+
assert meta.get("timed_out") is True
312+
assert meta.get("timeout_seconds") == 60
313+
314+
315+
# ---------------------------------------------------------------------------
316+
# SQLite mode tests (AC1 — invariant: SQLite behaviour unchanged)
317+
# ---------------------------------------------------------------------------
318+
319+
@pytest.mark.sqlite
320+
def test_sqlite_successful_hook_creates_log_file(tmp_path):
321+
"""SQLite: successful hook run → .log file in ADWs/logs/plugins/."""
322+
log_dir = tmp_path / "ADWs" / "logs" / "plugins"
323+
324+
plugin_dir = _make_hook_plugin_dir(tmp_path, "test-sqlite-hook", "post-install", "echo sqlite-ok")
325+
326+
# Patch PLUGIN_LOGS_DIR so logs land in tmp_path instead of repo root
327+
with patch("plugin_hook_runner.PLUGIN_LOGS_DIR", log_dir):
328+
# Use mem_engine for dialect patching (won't be written to in SQLite mode)
329+
mem_engine = _make_in_memory_sqlite_engine_with_log_table()
330+
patches = _patch_dialect_sqlite(mem_engine)
331+
with patches[0], patches[1]:
332+
result = run_lifecycle_hook(plugin_dir, "post-install", "test-sqlite-hook", timeout=10)
333+
334+
assert result["ran"] is True
335+
assert result["exit_code"] == 0
336+
assert result["log_path"] is not None
337+
338+
# Log file must exist
339+
log_files = list(log_dir.glob("test-sqlite-hook-post-install-*.log"))
340+
assert len(log_files) == 1, f"Expected 1 log file, found: {log_files}"
341+
342+
content = log_files[0].read_text(encoding="utf-8")
343+
assert "plugin: test-sqlite-hook" in content
344+
assert "hook: post-install" in content
345+
assert "sqlite-ok" in content
346+
347+
348+
@pytest.mark.sqlite
349+
def test_sqlite_hook_does_not_write_to_db_table(tmp_path):
350+
"""SQLite: hook run does NOT write to plugin_hook_runs (AC1)."""
351+
log_dir = tmp_path / "ADWs" / "logs" / "plugins"
352+
plugin_dir = _make_hook_plugin_dir(tmp_path, "test-no-db", "post-install", "echo noop")
353+
354+
mem_engine = _make_in_memory_sqlite_engine_with_log_table()
355+
356+
with patch("plugin_hook_runner.PLUGIN_LOGS_DIR", log_dir):
357+
patches = _patch_dialect_sqlite(mem_engine)
358+
with patches[0], patches[1]:
359+
run_lifecycle_hook(plugin_dir, "post-install", "test-no-db", timeout=10)
360+
361+
with mem_engine.connect() as conn:
362+
count = conn.execute(sa_text("SELECT COUNT(*) FROM plugin_hook_runs")).scalar()
363+
364+
assert count == 0, f"SQLite mode must not write to plugin_hook_runs; got {count} rows"
365+
366+
367+
@pytest.mark.sqlite
368+
def test_sqlite_truncation_still_applies_to_log_file(tmp_path):
369+
"""SQLite: stdout > 1 MB is truncated in the log file content."""
370+
log_dir = tmp_path / "ADWs" / "logs" / "plugins"
371+
log_dir.mkdir(parents=True, exist_ok=True)
372+
373+
big_stdout = "x" * (1024 * 1024 + 200)
374+
started = datetime(2026, 1, 1, 10, 0, 0)
375+
ended = datetime(2026, 1, 1, 10, 0, 1)
376+
377+
mem_engine = _make_in_memory_sqlite_engine_with_log_table()
378+
379+
with patch("plugin_hook_runner.PLUGIN_LOGS_DIR", log_dir):
380+
patches = _patch_dialect_sqlite(mem_engine)
381+
with patches[0], patches[1]:
382+
_persist_hook_run(
383+
slug="test-sqlite-trunc",
384+
hook_name="post-install",
385+
timestamp="20260101T100000Z",
386+
script_sha256="aaa111",
387+
started_at=started,
388+
ended_at=ended,
389+
exit_code=0,
390+
stdout=big_stdout,
391+
stderr="",
392+
timed_out=False,
393+
)
394+
395+
log_files = list(log_dir.glob("test-sqlite-trunc-*.log"))
396+
assert len(log_files) == 1
397+
content = log_files[0].read_text(encoding="utf-8")
398+
assert "[TRUNCATED]" in content

0 commit comments

Comments
 (0)