← Files Voice Notify for CodexARCHIVED FILE
tests/test_voice_notify.py
15.2 KB · Oct 2, 2026 · 00:29 UTC
from __future__ import annotations
import copy
import importlib.util
import json
import os
import pathlib
import shutil
import subprocess
import tempfile
import types
import unittest
from unittest import mock
ROOT = pathlib.Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location(
"play_notify", ROOT / "hooks" / "play_notify.py"
)
assert SPEC and SPEC.loader
play_notify = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(play_notify)
CONFIG_SPEC = importlib.util.spec_from_file_location(
"voice_notify_config", ROOT / "scripts" / "voice_notify_config.py"
)
assert CONFIG_SPEC and CONFIG_SPEC.loader
voice_notify_config = importlib.util.module_from_spec(CONFIG_SPEC)
CONFIG_SPEC.loader.exec_module(voice_notify_config)
VALIDATOR_SPEC = importlib.util.spec_from_file_location(
"validate_package", ROOT / "scripts" / "validate_package.py"
)
assert VALIDATOR_SPEC and VALIDATOR_SPEC.loader
validate_package = importlib.util.module_from_spec(VALIDATOR_SPEC)
VALIDATOR_SPEC.loader.exec_module(validate_package)
class DiscoveryEvalTests(unittest.TestCase):
def setUp(self) -> None:
self.discovery = json.loads(
(ROOT / "evals" / "cases.json").read_text(encoding="utf-8")
)["discovery"]
def test_bilingual_discovery_golden_set_is_valid(self) -> None:
validate_package.validate_discovery_evals(self.discovery)
def test_discovery_validator_rejects_count_language_and_selection_drift(self) -> None:
mutations = []
missing_direct = copy.deepcopy(self.discovery)
missing_direct["direct"].pop()
mutations.append(missing_direct)
missing_korean = copy.deepcopy(self.discovery)
for case in missing_korean["indirect"]:
case["language"] = "en"
mutations.append(missing_korean)
wrong_negative = copy.deepcopy(self.discovery)
wrong_negative["negative"][0]["expected_plugin"] = True
mutations.append(wrong_negative)
for mutation in mutations:
with self.subTest(mutation=mutation):
with self.assertRaises(AssertionError):
validate_package.validate_discovery_evals(mutation)
class VoiceNotifyTests(unittest.TestCase):
def test_defaults_are_female_korean_and_enabled(self) -> None:
settings = play_notify.normalize_settings(None)
self.assertTrue(settings["enabled"])
self.assertEqual(settings["voice"], "female")
self.assertEqual(settings["language"], "ko")
self.assertFalse(settings["events"]["PreToolUse"])
self.assertFalse(settings["events"]["PostToolUse"])
for event_name in play_notify.EVENT_FILES:
if event_name not in {"PreToolUse", "PostToolUse"}:
self.assertTrue(settings["events"][event_name])
def test_invalid_settings_are_bounded(self) -> None:
settings = play_notify.normalize_settings(
{"voice": "../escape", "language": "xx", "min_interval_ms": 999999}
)
self.assertEqual(settings["voice"], "female")
self.assertEqual(settings["language"], "ko")
self.assertEqual(settings["min_interval_ms"], 10000)
def test_russian_and_simplified_chinese_are_allowed(self) -> None:
self.assertEqual(
play_notify.normalize_settings({"language": "ru"})["language"],
"ru",
)
self.assertEqual(
play_notify.normalize_settings({"language": "zh-CN"})["language"],
"zh-CN",
)
parser = voice_notify_config.build_parser()
self.assertEqual(parser.parse_args(["set", "--language", "ru"]).language, "ru")
self.assertEqual(
parser.parse_args(["setup", "--language", "zh-CN"]).language,
"zh-CN",
)
def test_powershell_canonicalizes_chinese_locale_casing(self) -> None:
settings_script = (ROOT / "scripts" / "voice_notify_config.ps1").read_text(
encoding="utf-8"
)
hook_script = (ROOT / "hooks" / "play_notify.ps1").read_text(
encoding="utf-8"
)
for script in (settings_script, hook_script):
self.assertIn("function ConvertTo-CanonicalLanguage", script)
self.assertIn('"zh-CN" { return "zh-CN" }', script)
self.assertIn("$CanonicalLanguage = ConvertTo-CanonicalLanguage", script)
self.assertIn(
"$Language = ConvertTo-CanonicalLanguage $Language",
settings_script,
)
def test_powershell_hook_reads_stdin_with_a_byte_bound(self) -> None:
hook_script = (ROOT / "hooks" / "play_notify.ps1").read_text(
encoding="utf-8"
)
self.assertNotIn("[Console]::In.ReadToEnd()", hook_script)
self.assertIn("function Read-BoundedStandardInput", hook_script)
self.assertIn("[Console]::OpenStandardInput()", hook_script)
self.assertIn("Read-BoundedStandardInput 1048576", hook_script)
self.assertIn("([long]$MaxBytes + 1L)", hook_script)
self.assertIn("[Text.UTF8Encoding]::new($false, $true)", hook_script)
def test_powershell_hook_rejects_oversize_input_without_waiting_for_eof(self) -> None:
powershell = shutil.which("powershell.exe") or shutil.which("pwsh")
if powershell is None:
self.skipTest("PowerShell is unavailable on this platform")
command = [
powershell,
"-NoLogo",
"-NoProfile",
"-NonInteractive",
]
if pathlib.Path(powershell).name.lower() == "powershell.exe":
command.extend(["-ExecutionPolicy", "Bypass"])
command.extend(["-File", str(ROOT / "hooks" / "play_notify.ps1")])
process = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env={**os.environ, "PLUGIN_ROOT": str(ROOT)},
)
assert process.stdin is not None
try:
try:
process.stdin.write(b"x" * (1048576 + 1))
process.stdin.flush()
except BrokenPipeError:
# Early pipe closure is also a valid fail-closed rejection.
pass
return_code = process.wait(timeout=20)
self.assertEqual(return_code, 0)
finally:
try:
process.stdin.close()
except BrokenPipeError:
pass
if process.poll() is None:
process.kill()
process.wait()
def test_malformed_config_falls_back_without_error(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = pathlib.Path(directory) / "settings.json"
path.write_text("{broken", encoding="utf-8")
self.assertEqual(play_notify.load_settings(path)["voice"], "female")
def test_select_audio_uses_whitelisted_path(self) -> None:
settings = play_notify.normalize_settings({"voice": "male", "language": "en"})
selected = play_notify.select_audio_path(ROOT, "Stop", settings)
self.assertEqual(selected, ROOT / "assets" / "audio" / "male" / "en" / "stop.wav")
def test_disabled_event_is_silent(self) -> None:
settings = play_notify.normalize_settings(
{"events": {"PreToolUse": False}}
)
self.assertIsNone(play_notify.select_audio_path(ROOT, "PreToolUse", settings))
def test_tool_events_are_silent_by_default(self) -> None:
settings = play_notify.normalize_settings(None)
self.assertIsNone(play_notify.select_audio_path(ROOT, "PreToolUse", settings))
self.assertIsNone(play_notify.select_audio_path(ROOT, "PostToolUse", settings))
def test_tool_events_can_be_enabled_explicitly(self) -> None:
settings = play_notify.normalize_settings(
{"events": {"PreToolUse": True, "PostToolUse": True}}
)
self.assertIsNotNone(play_notify.select_audio_path(ROOT, "PreToolUse", settings))
self.assertIsNotNone(play_notify.select_audio_path(ROOT, "PostToolUse", settings))
def test_unknown_event_is_silent(self) -> None:
settings = play_notify.normalize_settings(None)
self.assertIsNone(play_notify.select_audio_path(ROOT, "Unknown", settings))
def test_no_play_mode_does_not_spawn(self) -> None:
with mock.patch.dict(os.environ, {"CODEX_VOICE_NOTIFY_NO_PLAY": "1"}):
with mock.patch.object(play_notify.subprocess, "Popen") as popen:
play_notify.dispatch_playback(pathlib.Path("/tmp/example.wav"), 450)
popen.assert_not_called()
def test_hook_payload_is_not_forwarded_to_player(self) -> None:
payload = {
"hook_event_name": "Stop",
"prompt": "PRIVATE_SENTINEL",
"tool_input": {"secret": "PRIVATE_SENTINEL"},
}
with tempfile.TemporaryDirectory() as directory:
config = pathlib.Path(directory) / "settings.json"
config.write_text(json.dumps({"enabled": False}), encoding="utf-8")
with mock.patch.dict(
os.environ,
{
"PLUGIN_ROOT": str(ROOT),
"CODEX_VOICE_NOTIFY_CONFIG": str(config),
},
):
with mock.patch("sys.stdin.buffer.read", return_value=json.dumps(payload).encode()):
self.assertEqual(play_notify.main(), 0)
class VoiceNotifySetupTests(unittest.TestCase):
def setup_args(self, **overrides: object) -> types.SimpleNamespace:
values = {
"voice": "female",
"language": "ko",
"skip_audio_test": False,
"open_hooks": True,
"codex_command": None,
"dry_run": True,
}
values.update(overrides)
return types.SimpleNamespace(**values)
def test_parse_codex_version(self) -> None:
self.assertEqual(
voice_notify_config.parse_codex_version("codex-cli 0.145.0"),
(0, 145, 0),
)
self.assertEqual(
voice_notify_config.parse_codex_version("OpenAI Codex (v0.145.0)"),
(0, 145, 0),
)
self.assertIsNone(
voice_notify_config.parse_codex_version(
"Microsoft Windows [Version 10.0.26200.0]"
)
)
self.assertIsNone(
voice_notify_config.parse_codex_version("codex-cli 0.145.0-alpha.1")
)
self.assertIsNone(voice_notify_config.parse_codex_version("unknown"))
def test_failed_version_command_is_not_accepted(self) -> None:
completed = mock.Mock(returncode=1, stdout="codex-cli 0.145.0")
with mock.patch.object(
voice_notify_config,
"find_codex_command",
return_value=pathlib.Path("/usr/local/bin/codex"),
):
with mock.patch.object(
voice_notify_config.subprocess, "run", return_value=completed
):
command, version, output = voice_notify_config.inspect_codex()
self.assertEqual(command, pathlib.Path("/usr/local/bin/codex"))
self.assertIsNone(version)
self.assertEqual(output, "codex-cli 0.145.0")
def test_setup_requires_codex_with_hooks_support(self) -> None:
settings = play_notify.normalize_settings(None)
with mock.patch.object(
voice_notify_config,
"inspect_codex",
return_value=(pathlib.Path("/usr/local/bin/codex"), (0, 116, 0), "0.116.0"),
):
with mock.patch.object(voice_notify_config, "write_settings") as write:
with mock.patch.object(
voice_notify_config, "open_hook_trust_terminal"
) as open_terminal:
status = voice_notify_config.run_setup(self.setup_args(), settings)
self.assertEqual(status, 3)
write.assert_not_called()
open_terminal.assert_not_called()
def test_setup_dry_run_reaches_hook_step(self) -> None:
settings = play_notify.normalize_settings(None)
with mock.patch.object(
voice_notify_config,
"inspect_codex",
return_value=(pathlib.Path("/usr/local/bin/codex"), (0, 145, 0), "0.145.0"),
):
status = voice_notify_config.run_setup(self.setup_args(), settings)
self.assertEqual(status, 0)
def test_setup_saves_selected_preferences(self) -> None:
settings = play_notify.normalize_settings(None)
with tempfile.TemporaryDirectory() as directory:
destination = pathlib.Path(directory) / "settings.json"
with mock.patch.object(
voice_notify_config.play_notify,
"user_settings_path",
return_value=destination,
):
with mock.patch.object(
voice_notify_config,
"inspect_codex",
return_value=(
pathlib.Path("/usr/local/bin/codex"),
(0, 145, 0),
"0.145.0",
),
):
status = voice_notify_config.run_setup(
self.setup_args(
voice="male",
language="ja",
skip_audio_test=True,
open_hooks=False,
dry_run=False,
),
settings,
)
saved = json.loads(destination.read_text(encoding="utf-8"))
self.assertEqual(status, 0)
self.assertTrue(saved["enabled"])
self.assertEqual(saved["voice"], "male")
self.assertEqual(saved["language"], "ja")
def test_macos_hook_terminal_command_keeps_human_review(self) -> None:
completed = mock.Mock(returncode=0)
codex_path = pathlib.PurePosixPath("/Applications/Codex CLI/codex")
working_directory = pathlib.PurePosixPath("/tmp/user's example")
with mock.patch.object(voice_notify_config.sys, "platform", "darwin"):
with mock.patch.object(
voice_notify_config.pathlib.Path, "is_file", return_value=True
):
with mock.patch.object(
voice_notify_config.subprocess, "run", return_value=completed
) as run:
opened = voice_notify_config.open_hook_trust_terminal(
codex_path,
working_directory,
)
self.assertTrue(opened)
command = run.call_args.args[0]
self.assertEqual(command[0].replace("\\", "/"), "/usr/bin/osascript")
self.assertEqual(command[1], "-e")
self.assertIn("/hooks", command[2])
self.assertIn('tell application "Terminal"', command[2])
self.assertIn("activate", command[2])
self.assertIn("do script", command[2])
expected_exec = "exec %s --no-alt-screen -C %s" % (
voice_notify_config.shlex.quote(str(codex_path)),
voice_notify_config.shlex.quote(str(working_directory)),
)
self.assertIn(
voice_notify_config._apple_script_string(expected_exec),
command[2],
)
self.assertNotIn("--dangerously-bypass-hook-trust", command[2])
if __name__ == "__main__":
unittest.main()
SHA-256: 5acdec2e8bfda0cf8f040ddc140cdde0c86536ceadf7b88a2282be4474b1208f