diff --git a/solstone/apps/transcripts/tests/test_segment_routes.py b/solstone/apps/transcripts/tests/test_segment_routes.py index f407e48ad..82f75b3bb 100644 --- a/solstone/apps/transcripts/tests/test_segment_routes.py +++ b/solstone/apps/transcripts/tests/test_segment_routes.py @@ -42,6 +42,10 @@ from solstone.think.importers import health_schema FIXTURE_DAY = "20260304" FIXTURE_STREAM = "default" FIXTURE_SEGMENT = "090000_300" +_requires_ffmpeg = pytest.mark.skipif( + shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None, + reason="ffmpeg/ffprobe not installed", +) def _apple_health_card_stream() -> str: @@ -1557,6 +1561,8 @@ def test_segment_content_falls_back_to_segment_window_duration(client, journal_c assert duration > 0 +@pytest.mark.integration +@_requires_ffmpeg def test_moov_at_tail_m4a_fixture_has_tail_moov_and_true_duration(tmp_path): media_path = tmp_path / "tail-moov.m4a" @@ -1570,6 +1576,7 @@ def test_moov_at_tail_m4a_fixture_has_tail_moov_and_true_duration(tmp_path): assert b"mvhd" not in head +@_requires_ffmpeg def test_segment_content_returns_finite_duration_for_moov_at_tail_audio( client, journal_copy, tmp_path ): @@ -1589,6 +1596,7 @@ def test_segment_content_returns_finite_duration_for_moov_at_tail_audio( assert duration == pytest.approx(true_duration, abs=1.0) +@_requires_ffmpeg def test_segment_content_does_not_probe_served_m4a( client, journal_copy, tmp_path, monkeypatch ): diff --git a/solstone/apps/transcripts/tests/test_serve_file.py b/solstone/apps/transcripts/tests/test_serve_file.py index a7775dd27..131ecbf08 100644 --- a/solstone/apps/transcripts/tests/test_serve_file.py +++ b/solstone/apps/transcripts/tests/test_serve_file.py @@ -43,6 +43,8 @@ def client(tmp_path, monkeypatch): segment_dir = journal / "chronicle" / DAY / STREAM / SEGMENT segment_dir.mkdir(parents=True) shutil.copyfile(FIXTURE_MEDIA, segment_dir / MEDIA_FILE) + if shutil.which("ffmpeg") is None: + pytest.skip("ffmpeg not installed") build_moov_at_tail_m4a(segment_dir / AUDIO_FILE, 3.0) (segment_dir / IMAGE_FILE).write_bytes(b"\xff\xd8\xff\xe0jpeg") monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) diff --git a/tests/fixtures/audio/aac_multi_track.m4a b/tests/fixtures/audio/aac_multi_track.m4a new file mode 100644 index 0000000000000000000000000000000000000000..66c51db6f62f49d509594f4fc2810ca4706705ca GIT binary patch literal 8334 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/audio/aac_single_track.m4a b/tests/fixtures/audio/aac_single_track.m4a new file mode 100644 index 0000000000000000000000000000000000000000..633b78d9f686f827c2c920215abf2f0adefd1e38 GIT binary patch literal 4389 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 literal 0 HcmV?d00001 diff --git a/tests/test_pdf_worker.py b/tests/test_pdf_worker.py index 669ba19f1..c6799a41e 100644 --- a/tests/test_pdf_worker.py +++ b/tests/test_pdf_worker.py @@ -268,7 +268,7 @@ def test_garbage_and_zero_byte_inputs_exit_corrupt(tmp_path): assert "detail" in payload -def test_truncation_behavior_is_pinned_to_pdfium_observations(tmp_path): +def test_deep_truncation_maps_to_corrupt_exit(tmp_path): clean = tmp_path / "clean.pdf" deep = tmp_path / "deep.pdf" drop_startxref = tmp_path / "drop-startxref.pdf" @@ -279,6 +279,15 @@ def test_truncation_behavior_is_pinned_to_pdfium_observations(tmp_path): assert deep_result.returncode == 4 assert _single_json(deep_result)["error"] == "corrupt" + +@pytest.mark.integration +def test_mild_truncation_is_silently_repaired_by_pdfium(tmp_path): + clean = tmp_path / "clean.pdf" + deep = tmp_path / "deep.pdf" + drop_startxref = tmp_path / "drop-startxref.pdf" + drop_eof = tmp_path / "drop-eof.pdf" + write_truncation_fixtures(clean, deep, drop_startxref, drop_eof) + clean_payload = _single_json(_run_worker("extract", str(clean))) for mild_truncation in (drop_startxref, drop_eof): # Documented PDFium limit: these tail-only truncations are repaired diff --git a/tests/test_supervisor.py b/tests/test_supervisor.py index 97d9f9b8d..1f94f7b0a 100644 --- a/tests/test_supervisor.py +++ b/tests/test_supervisor.py @@ -7,7 +7,7 @@ import io import json import logging import os -import signal +import signal # noqa: F401 - retained with legacy supervisor test imports import socket import subprocess import sys @@ -2977,45 +2977,30 @@ def test_collect_task_status_snapshots_active_under_lock(): def test_enforce_deadlines_terminates_stopped_task(caplog, monkeypatch): mod = importlib.import_module("solstone.think.supervisor") - proc = subprocess.Popen(["sh", "-c", "kill -STOP $$; sleep 60"]) - try: - child = psutil.Process(proc.pid) - for _ in range(30): - if child.status() == psutil.STATUS_STOPPED: - break - time.sleep(0.1) - else: - pytest.fail("subprocess did not enter stopped state") - - queue = mod.TaskQueue(on_queue_change=None) - managed = _TaskManagedStub(cmd=["sleep"], start_time=time.time()) - managed.process.pid = proc.pid - queue._caps["sleep"] = 60 - queue._active["ref-1"] = managed - terminate = MagicMock() - monkeypatch.setattr(mod, "_start_termination_thread", terminate) - caplog.set_level(logging.WARNING) - - queue.enforce_deadlines(time.time()) - terminate.assert_not_called() - - queue.enforce_deadlines(time.time()) - - terminate.assert_called_once_with( - "ref-1", managed, timeout=2.0, reason="stopped" - ) - assert "stopped" in caplog.text - finally: - try: - os.kill(proc.pid, signal.SIGCONT) - except ProcessLookupError: - pass - try: - proc.terminate() - proc.wait(timeout=5) - except subprocess.TimeoutExpired: - proc.kill() - proc.wait(timeout=5) + + class StoppedProcess: + def __init__(self, pid): + self.pid = pid + + def status(self): + return mod.psutil.STATUS_STOPPED + + monkeypatch.setattr(mod.psutil, "Process", StoppedProcess) + terminate = MagicMock() + monkeypatch.setattr(mod, "_start_termination_thread", terminate) + queue = mod.TaskQueue(on_queue_change=None) + managed = _TaskManagedStub(cmd=["sol", "import"], start_time=100.0) + queue.set_cap("import", 300) + queue._active["ref-1"] = managed + caplog.set_level(logging.WARNING) + + queue.enforce_deadlines(110.0) + terminate.assert_not_called() + + queue.enforce_deadlines(110.0) + + terminate.assert_called_once_with("ref-1", managed, timeout=2.0, reason="stopped") + assert "stopped" in caplog.text def test_enforce_deadlines_does_not_probe_status_under_lock(monkeypatch): diff --git a/tests/test_talents_process.py b/tests/test_talents_process.py index b210f82e6..037ea0957 100644 --- a/tests/test_talents_process.py +++ b/tests/test_talents_process.py @@ -3,135 +3,54 @@ from __future__ import annotations -import json -import os -import select +import asyncio +import io import signal -import subprocess -import sys -import textwrap -import time import pytest +from solstone.think import talents -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX signal semantics") -def test_talent_main_sigterm_exits_without_cancelled_traceback(tmp_path): - journal = tmp_path / "journal" - journal.mkdir() - (journal / "config").mkdir() - (journal / "config" / "journal.json").write_text( - json.dumps( - {"providers": {"active": {"provider": "test", "model": "test-model"}}} - ), - encoding="utf-8", - ) - - talent_dir = tmp_path / "talent" - talent_dir.mkdir() - (talent_dir / "test_cogitate.md").write_text( - '{\n "type": "cogitate",\n "title": "Test Cogitate"\n}\n\nTest prompt\n', - encoding="utf-8", - ) - - sitecustomize = tmp_path / "sitecustomize.py" - sitecustomize.write_text( - textwrap.dedent( - f""" - import asyncio - import pathlib - import sys - import types - - import solstone.think.providers as providers - import solstone.think.talent as talent - - talent.TALENT_DIR = pathlib.Path({str(talent_dir)!r}) - - fake_provider = types.ModuleType("solstone_test_provider") - - async def run_cogitate(config, on_event=None): - sys.stderr.write("provider-awaiting\\n") - sys.stderr.flush() - await asyncio.sleep(30) - - fake_provider.run_cogitate = run_cogitate - providers.PROVIDER_REGISTRY["test"] = "solstone_test_provider" - providers.PROVIDER_METADATA["test"] = {{ - "label": "Test", - "env_key": "", - }} - sys.modules["solstone_test_provider"] = fake_provider - """ - ).lstrip(), - encoding="utf-8", - ) - - env = os.environ.copy() - env["SOLSTONE_JOURNAL"] = str(journal) - env["SOL_SKIP_SUPERVISOR_CHECK"] = "1" - env["PYTHONUNBUFFERED"] = "1" - env["PYTHONPATH"] = ( - str(tmp_path) - if not env.get("PYTHONPATH") - else str(tmp_path) + os.pathsep + env["PYTHONPATH"] - ) - - proc = subprocess.Popen( - [sys.executable, "-m", "solstone.think.talents"], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env=env, - ) - - request = { - "name": "test_cogitate", - "prompt": "hello", - } - assert proc.stdin is not None - proc.stdin.write(json.dumps(request).encode("utf-8") + b"\n") - proc.stdin.flush() - - assert proc.stderr is not None - stderr_seen = bytearray() - deadline = time.monotonic() + 5 - while b"provider-awaiting" not in stderr_seen and time.monotonic() < deadline: - remaining = max(0, deadline - time.monotonic()) - readable, _, _ = select.select([proc.stderr], [], [], remaining) - if not readable: - break - line = proc.stderr.readline() - if not line: - break - stderr_seen.extend(line) - - if b"provider-awaiting" not in stderr_seen: - try: - stdout, stderr_rest = proc.communicate(timeout=1) - except subprocess.TimeoutExpired: - proc.kill() - stdout, stderr_rest = proc.communicate() - pytest.fail( - "talent process did not reach provider await\n" - f"stdout={stdout.decode(errors='replace')}\n" - f"stderr={(bytes(stderr_seen) + stderr_rest).decode(errors='replace')}" - ) - - proc.send_signal(signal.SIGTERM) - - try: - stdout, stderr_rest = proc.communicate(timeout=5) - except subprocess.TimeoutExpired: - proc.kill() - stdout, stderr_rest = proc.communicate() - pytest.fail( - "talent process did not exit after SIGTERM\n" - f"stdout={stdout.decode(errors='replace')}\n" - f"stderr={(bytes(stderr_seen) + stderr_rest).decode(errors='replace')}" - ) - - stderr = bytes(stderr_seen) + stderr_rest - assert proc.returncode == 0, stderr.decode(errors="replace") - assert b"Traceback" not in stderr - assert b"CancelledError" not in stderr + +def test_talent_main_cancellation_exits_without_cancelled_traceback( + monkeypatch, capsys +): + def fake_run(coro): + coro.close() + raise asyncio.CancelledError + + monkeypatch.setattr(talents.asyncio, "run", fake_run) + + with pytest.raises(SystemExit) as exc: + talents.main() + + assert exc.value.code == 0 + captured = capsys.readouterr() + assert "Traceback" not in captured.err + assert "CancelledError" not in captured.err + + +def test_talent_main_async_registers_sigterm_cancel_handler(monkeypatch): + monkeypatch.setattr(talents.sys, "argv", ["talents"]) + monkeypatch.setattr(talents, "require_solstone", lambda: None) + monkeypatch.setattr(talents.sys, "stdin", io.StringIO("")) + recorded = [] + + async def runner(): + loop = asyncio.get_running_loop() + + def record_signal_handler(sig, cb, *_args): + recorded.append((sig, cb)) + + def ignore_signal_handler(_sig): + return None + + monkeypatch.setattr(loop, "add_signal_handler", record_signal_handler) + monkeypatch.setattr(loop, "remove_signal_handler", ignore_signal_handler) + await talents.main_async() + + asyncio.run(runner()) + + sigterm_cbs = [cb for sig, cb in recorded if sig == signal.SIGTERM] + assert sigterm_cbs + assert all(getattr(cb, "__name__", "") == "cancel" for cb in sigterm_cbs) diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index e198dea2d..e446aec17 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -5,6 +5,7 @@ import json import shutil +import subprocess import tempfile from datetime import datetime from pathlib import Path @@ -256,100 +257,68 @@ class TestLoadAudio: assert result.dtype == np.float32 assert len(result) == sample_rate - @pytest.mark.skipif(not shutil.which("ffmpeg"), reason="ffmpeg not installed") def test_m4a_returns_numpy_array(self): """M4A files should return a numpy array with audio content.""" - import subprocess - - with tempfile.TemporaryDirectory() as tmpdir: - # Create source FLAC - flac_path = Path(tmpdir) / "source.flac" - sample_rate = 16000 - duration = 0.5 - t = np.linspace(0, duration, int(sample_rate * duration), dtype=np.float32) - data = 0.5 * np.sin(2 * np.pi * 440 * t) - sf.write(flac_path, data, sample_rate, format="FLAC") + # Generated with: + # ffmpeg -y -f lavfi \ + # -i "sine=frequency=440:duration=0.5:sample_rate=16000" \ + # -c:a aac -b:a 64k \ + # tests/fixtures/audio/aac_single_track.m4a + m4a_path = Path(__file__).parent / "fixtures" / "audio" / "aac_single_track.m4a" - # Convert to M4A - m4a_path = Path(tmpdir) / "test.m4a" - result = subprocess.run( - [ - "ffmpeg", - "-y", - "-i", - str(flac_path), - "-c:a", - "aac", - "-b:a", - "64k", - str(m4a_path), - ], - capture_output=True, - ) - assert result.returncode == 0 + audio = load_audio(m4a_path) - # Test loading returns numpy array - audio = load_audio(m4a_path) - assert isinstance(audio, np.ndarray) - assert audio.dtype == np.float32 - assert len(audio) > 0 + assert isinstance(audio, np.ndarray) + assert audio.dtype == np.float32 + assert len(audio) > 0 - @pytest.mark.skipif(not shutil.which("ffmpeg"), reason="ffmpeg not installed") def test_multi_track_m4a_mixes_streams(self): """load_audio should mix multiple M4A audio streams together.""" - import subprocess + # Generated with: + # ffmpeg -y -f lavfi -i "anullsrc=r=16000:cl=mono" \ + # -f lavfi -i "sine=frequency=440:duration=1:sample_rate=16000,volume=4" \ + # -map 0:a -map 1:a -c:a aac -b:a 64k -t 1 \ + # tests/fixtures/audio/aac_multi_track.m4a + m4a_path = Path(__file__).parent / "fixtures" / "audio" / "aac_multi_track.m4a" - with tempfile.TemporaryDirectory() as tmpdir: - # Create two mono FLAC files to combine into multi-track M4A - track0_path = Path(tmpdir) / "track0.flac" - track1_path = Path(tmpdir) / "track1.flac" - m4a_path = Path(tmpdir) / "test.m4a" + audio = load_audio(m4a_path) - # Track 0: silence (system audio - no content) - # Track 1: 440Hz sine wave (microphone - has voice) - sample_rate = 16000 - duration = 1.0 # 1 second - t = np.linspace(0, duration, int(sample_rate * duration), dtype=np.float32) - - track0_data = np.zeros_like(t) # Silence - track1_data = 0.5 * np.sin(2 * np.pi * 440 * t) # 440Hz tone - - sf.write(track0_path, track0_data, sample_rate, format="FLAC") - sf.write(track1_path, track1_data, sample_rate, format="FLAC") - - # Use ffmpeg to create multi-track M4A - result = subprocess.run( - [ - "ffmpeg", - "-y", - "-i", - str(track0_path), - "-i", - str(track1_path), - "-map", - "0:a", - "-map", - "1:a", - "-c:a", - "aac", - "-b:a", - "64k", - str(m4a_path), - ], - capture_output=True, - text=True, - ) - assert result.returncode == 0, f"ffmpeg failed: {result.stderr}" + assert isinstance(audio, np.ndarray) + assert audio.dtype == np.float32 - audio = load_audio(m4a_path) + # The mixed audio should have content from track 1 (the sine wave) + # AAC compression affects amplitude, so use loose threshold + rms = np.sqrt(np.mean(audio**2)) + assert rms > 0.1, f"Mixed audio should contain signal, got RMS={rms}" - assert isinstance(audio, np.ndarray) - assert audio.dtype == np.float32 + @pytest.mark.integration + @pytest.mark.skipif(not shutil.which("ffmpeg"), reason="ffmpeg not installed") + def test_m4a_ffmpeg_round_trip_decodes(self, tmp_path): + m4a_path = tmp_path / "round-trip.m4a" + result = subprocess.run( + [ + "ffmpeg", + "-y", + "-f", + "lavfi", + "-i", + "sine=frequency=440:duration=0.5:sample_rate=16000", + "-c:a", + "aac", + "-b:a", + "64k", + str(m4a_path), + ], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + + audio = load_audio(m4a_path) - # The mixed audio should have content from track 1 (the sine wave) - # AAC compression affects amplitude, so use loose threshold - rms = np.sqrt(np.mean(audio**2)) - assert rms > 0.1, f"Mixed audio should contain signal, got RMS={rms}" + assert isinstance(audio, np.ndarray) + assert audio.dtype == np.float32 + assert len(audio) > 0 @pytest.mark.parametrize("suffix", sorted(AUDIO_EXTENSIONS - {".m4a"})) def test_load_audio_decodes_ext(self, tmp_path, suffix):