# Conflicts: # apps/desktop/e2e/archived-hidden-session-recoverable.spec.ts # apps/desktop/e2e/bot-chat-message-agent-friendly-name.spec.ts # apps/desktop/e2e/bot-mailbox-unreadable-ticket.spec.ts # apps/desktop/e2e/bot-mode-roster-localized.spec.ts # apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts # apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts # apps/desktop/e2e/bot-roster-group-row-organisation.spec.ts # apps/desktop/e2e/bot-roster-ignores-infra-dirs.spec.ts # apps/desktop/e2e/bot-roster-timestamp-meta.spec.ts # apps/desktop/e2e/bot-roster-user-sections.spec.ts # apps/desktop/e2e/bot-routines-pane-narrow.spec.ts # apps/desktop/e2e/bot-row-open-recent-session.spec.ts # apps/desktop/e2e/bot-tile-ignores-ambient-composer-model.spec.ts # apps/desktop/e2e/group-composer-auto-grow.spec.ts # apps/desktop/e2e/group-create-gate-remote-roster.spec.ts # apps/desktop/e2e/group-prompt-renamed-primary-handle.spec.ts # apps/desktop/e2e/hosted-room-backend-continuity.spec.ts # apps/desktop/e2e/hosted-room-legacy-store-migration.spec.ts # apps/desktop/e2e/settings-scope-chips-bot-title.spec.ts # apps/desktop/e2e/worktree-branch-status.spec.ts # apps/desktop/electron/backend-probes.test.ts # apps/desktop/electron/connection-apply.test.ts # apps/desktop/electron/desktop-electron-pin.test.ts # apps/desktop/electron/desktop-uninstall.test.ts # apps/desktop/electron/gateway-file-download-transport.test.ts # apps/desktop/electron/gateway-stop-before-update.test.ts # apps/desktop/electron/github-api-auth.test.ts # apps/desktop/electron/registry-primary-profile-scope.test.ts # apps/desktop/electron/update-api-check.test.ts # apps/desktop/electron/update-handoff-marker.test.ts # apps/desktop/electron/venv-blocker-scan.test.ts # apps/desktop/scripts/after-extract.test.mjs # apps/desktop/scripts/local-pack-publish.test.mjs # apps/desktop/scripts/tasks-scroll.test.mjs # apps/desktop/src/app/settings/model-settings.test.tsx # apps/desktop/src/app/updates-overlay.blockers.test.tsx # apps/desktop/src/components/desktop-install-overlay.test.tsx # apps/desktop/src/lib/update-copy.test.ts # scripts/ci/check_os_marker_fakes.py # tests-js/desktop-mac-usage-descriptions.test.ts # tests-js/node-engine-alignment.test.ts # tests/agent/lsp/test_install_and_lint_fixes.py # tests/agent/test_command_token_source.py # tests/agent/test_compression_boundary_hook.py # tests/agent/test_create_openai_client_ssl_verify.py # tests/agent/test_custom_provider_ca_probes.py # tests/agent/test_endpoint_blackhole.py # tests/agent/test_estimator_parity.py # tests/agent/test_in_place_compaction.py # tests/agent/test_moa_loop_mode.py # tests/agent/test_model_metadata.py # tests/agent/test_skill_session_platform_gate.py # tests/agent/test_skill_utils.py # tests/agent/test_ssl_ca_guard.py # tests/computer_use/test_doctor.py # tests/cron/test_codex_execution_paths.py # tests/cron/test_cron_bot_chat_delivery.py # tests/cron/test_cron_script.py # tests/cron/test_media_delivery_parity.py # tests/cron/test_misfire_catchup.py # tests/cron/test_parallel_pool.py # tests/cron/test_recurring_eagain_redispatch.py # tests/gateway/test_choice_picker.py # tests/gateway/test_control_socket_windows_live.py # tests/gateway/test_dingtalk.py # tests/gateway/test_feishu.py # tests/gateway/test_feishu_onboard.py # tests/gateway/test_gateway_shutdown.py # tests/gateway/test_matrix.py # tests/gateway/test_model_command_custom_providers.py # tests/gateway/test_reasoning_command.py # tests/gateway/test_runtime_footer.py # tests/gateway/test_session.py # tests/gateway/test_session_hygiene.py # tests/gateway/test_status.py # tests/gateway/test_teams.py # tests/gateway/test_turn_lease.py # tests/gateway/test_whatsapp_connect.py # tests/hermes_cli/test_approvals_command.py # tests/hermes_cli/test_auth_store_lock_concurrent.py # tests/hermes_cli/test_backup.py # tests/hermes_cli/test_banner_git_state.py # tests/hermes_cli/test_certifi_repair.py # tests/hermes_cli/test_cmd_update.py # tests/hermes_cli/test_compat_manifest_targets.py # tests/hermes_cli/test_computer_use_cli.py # tests/hermes_cli/test_cpr_local_leak.py # tests/hermes_cli/test_dashboard_auth_gate.py # tests/hermes_cli/test_dashboard_procs_kill_grace.py # tests/hermes_cli/test_desktop_lifecycle_windows_live.py # tests/hermes_cli/test_doctor.py # tests/hermes_cli/test_doctor_command_install.py # tests/hermes_cli/test_fleet_config_migration_windows_live.py # tests/hermes_cli/test_gateway.py # tests/hermes_cli/test_gateway_platform_gating.py # tests/hermes_cli/test_gateway_restart_loop.py # tests/hermes_cli/test_gateway_task_probe.py # tests/hermes_cli/test_gateway_wsl.py # tests/hermes_cli/test_gui_command.py # tests/hermes_cli/test_install_cua_driver.py # tests/hermes_cli/test_kanban_db.py # tests/hermes_cli/test_lazy_command_exports.py # tests/hermes_cli/test_lazy_refresh_venv_repair.py # tests/hermes_cli/test_linux_desktop_entry.py # tests/hermes_cli/test_local_runtime.py # tests/hermes_cli/test_local_runtime_updates.py # tests/hermes_cli/test_managed_uv.py # tests/hermes_cli/test_mcp_reload_confirm_gate.py # tests/hermes_cli/test_nous_subscription.py # tests/hermes_cli/test_npm_engine.py # tests/hermes_cli/test_personality_none.py # tests/hermes_cli/test_pet_toggle.py # tests/hermes_cli/test_plan_reconciliation_windows_live.py # tests/hermes_cli/test_plugin_event_bus.py # tests/hermes_cli/test_plugin_manifest_v2.py # tests/hermes_cli/test_plugin_packs.py # tests/hermes_cli/test_plugins_cmd.py # tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py # tests/hermes_cli/test_process_identity.py # tests/hermes_cli/test_profiles.py # tests/hermes_cli/test_profiles_sidebar_cache.py # tests/hermes_cli/test_pty_bridge.py # tests/hermes_cli/test_resolve_turn_limit.py # tests/hermes_cli/test_serve_runtime_inventory.py # tests/hermes_cli/test_session_vacuum_config.py # tests/hermes_cli/test_set_config_value.py # tests/hermes_cli/test_signal_handler_kanban_worker.py # tests/hermes_cli/test_slash_confirm_windows.py # tests/hermes_cli/test_stale_pid_guard.py # tests/hermes_cli/test_startup_fast_guards.py # tests/hermes_cli/test_status.py # tests/hermes_cli/test_telegram_managed_bot.py # tests/hermes_cli/test_tools_config.py # tests/hermes_cli/test_update_apply_shallow_count.py # tests/hermes_cli/test_update_autostash.py # tests/hermes_cli/test_update_concurrent_quarantine.py # tests/hermes_cli/test_update_fetch_failure_classifier.py # tests/hermes_cli/test_update_fleet_probe_resume_token.py # tests/hermes_cli/test_update_handoff_backend_reap.py # tests/hermes_cli/test_update_handoff_desktop_rebuild.py # tests/hermes_cli/test_update_head_moved_gate.py # tests/hermes_cli/test_update_host_obligation.py # tests/hermes_cli/test_update_import_guard.py # tests/hermes_cli/test_update_interrupted_recovery.py # tests/hermes_cli/test_update_inventory.py # tests/hermes_cli/test_update_launchd_unloaded_gateway.py # tests/hermes_cli/test_update_missing_configured_deps.py # tests/hermes_cli/test_update_modified_notice.py # tests/hermes_cli/test_update_multiplex_migration_hook.py # tests/hermes_cli/test_update_no_gateway_restart.py # tests/hermes_cli/test_update_orphan_backend_reap.py # tests/hermes_cli/test_update_parked_branch_guard.py # tests/hermes_cli/test_update_post_pull_syntax_guard.py # tests/hermes_cli/test_update_receipt.py # tests/hermes_cli/test_update_self_lock.py # tests/hermes_cli/test_update_shim_fail_closed.py # tests/hermes_cli/test_update_shim_self_lock.py # tests/hermes_cli/test_update_sqlite_remediation.py # tests/hermes_cli/test_update_stale_dashboard.py # tests/hermes_cli/test_update_stale_virtualenv.py # tests/hermes_cli/test_update_venv_health.py # tests/hermes_cli/test_update_venv_ownership_preflight.py # tests/hermes_cli/test_update_wedged_gateway.py # tests/hermes_cli/test_update_yes_flag.py # tests/hermes_cli/test_update_zip_two_phase.py # tests/hermes_cli/test_urllib_security.py # tests/hermes_cli/test_ux_messages_auth_config.py # tests/hermes_cli/test_ux_messages_startup.py # tests/hermes_cli/test_venv_holder_classifier.py # tests/hermes_cli/test_verify_console_scripts.py # tests/hermes_cli/test_verify_core_dependencies.py # tests/hermes_cli/test_web_server.py # tests/hermes_cli/test_web_server_console_ws.py # tests/hermes_cli/test_web_server_ws_ping.py # tests/hermes_cli/test_web_ui_build.py # tests/hermes_state/test_fts_rebuild_admission.py # tests/hermes_state/test_hermes_state.py # tests/plugins/memory/test_memory_lazy_install.py # tests/plugins/test_google_meet_plugin.py # tests/plugins/test_langfuse_plugin.py # tests/plugins/test_security_guidance_plugin.py # tests/plugins/test_transform_llm_output_hook.py # tests/scripts/desktop_update/test_desktop_update_windows_gateway_flag.py # tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py # tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py # tests/scripts/install/test_install_clone_throttle_fallback.py # tests/scripts/install/test_install_lockfile_churn.py # tests/scripts/install/test_install_no_initial_commit.py # tests/scripts/install/test_install_sh_browser_install.py # tests/scripts/install/test_install_sh_node_prerelease.py # tests/scripts/install/test_install_sh_symlink_stomp.py # tests/scripts/install/test_install_sh_uv_lock_config.py # tests/scripts/install/test_install_unmerged_index.py # tests/scripts/test_contributor_map.py # tests/scripts/test_run_tests_parallel.py # tests/skills/test_competitor_news_monitor_skill.py # tests/skills/test_document_to_action_items_skill.py # tests/skills/test_google_workspace_setup.py # tests/skills/test_google_workspace_setup_deps.py # tests/skills/test_grounded_citations_skill.py # tests/skills/test_ip_as_logo_skill.py # tests/skills/test_live_dashboard_skill.py # tests/skills/test_mcp_oauth_remote_gateway_skill.py # tests/skills/test_office_document_skills.py # tests/skills/test_openclaw_migration.py # tests/skills/test_product_price_monitor_skill.py # tests/skills/test_scrollcraft_skill.py # tests/skills/test_setup_wizard_generator_skill.py # tests/skills/test_weekly_review_planning_skill.py # tests/test_engines_satisfiable.py # tests/test_fast_safe_load.py # tests/test_hermes_bootstrap.py # tests/test_hermes_constants.py # tests/test_hermes_logging.py # tests/test_managed_runtime_resolution.py # tests/test_model_tools_async_bridge.py # tests/test_packaging_build_guard.py # tests/test_packaging_metadata.py # tests/test_yaml_indent_consistency.py # tests/tools/test_approval_timeout_overflow.py # tests/tools/test_base_environment.py # tests/tools/test_bot_mode_dm.py # tests/tools/test_browser_chromium_check.py # tests/tools/test_browser_hardening.py # tests/tools/test_browser_homebrew_paths.py # tests/tools/test_browser_npx_warmup.py # tests/tools/test_browser_orphan_reaper.py # tests/tools/test_browser_real_profile.py # tests/tools/test_browser_use_cli.py # tests/tools/test_clipboard.py # tests/tools/test_code_execution.py # tests/tools/test_code_execution_modes.py # tests/tools/test_code_execution_windows_env.py # tests/tools/test_computer_use.py # tests/tools/test_delegate_liveness_timeout.py # tests/tools/test_execute_code_approval_cluster.py # tests/tools/test_execution_flag_detection.py # tests/tools/test_fal_common.py # tests/tools/test_file_operations.py # tests/tools/test_file_tools.py # tests/tools/test_file_tools_cwd_resolution.py # tests/tools/test_file_tools_live.py # tests/tools/test_lazy_deps.py # tests/tools/test_lazy_deps_durable_target.py # tests/tools/test_lazy_deps_managed.py # tests/tools/test_local_env_blocklist.py # tests/tools/test_local_tempdir.py # tests/tools/test_macos_protected_search.py # tests/tools/test_mcp_npx_cached_bin.py # tests/tools/test_oneshot_completion_linger.py # tests/tools/test_process_registry.py # tests/tools/test_read_file_schema_gating.py # tests/tools/test_skill_improvements.py # tests/tools/test_skills_sync.py # tests/tools/test_termux_api_detection.py # tests/tools/test_tirith_security.py # tests/tools/test_transcription_tools.py # tests/tools/test_tts_streaming.py # tests/tools/test_wake_word.py # tests/tui_gateway/test_compute_host_borrowed_lease.py # tests/tui_gateway/test_compute_host_turn_protocol.py # tests/tui_gateway/test_isolated_orphan_activity.py # tests/tui_gateway/test_protocol.py # tests/tui_gateway/test_slash_worker_profile_home.py # tests/tui_gateway/test_subprocess_encoding.py # tests/tui_gateway/test_tui_gateway_server.py # ui-tui/src/__tests__/terminalParity.test.ts # ui-tui/src/__tests__/termuxComposerLayout.test.ts # ui-tui/src/__tests__/textInputFastEcho.test.ts
501 lines
18 KiB
Python
501 lines
18 KiB
Python
"""
|
|
Tests for custom command-type TTS providers.
|
|
|
|
These tests cover the ``tts.providers.<name>`` registry: built-in
|
|
precedence, command resolution, placeholder rendering, shell-quote
|
|
context handling, timeout / failure cleanup, voice_compatible opt-in,
|
|
and max_text_length lookup.
|
|
|
|
Nothing here talks to a real TTS engine. The shell command itself is
|
|
portable: we write bytes to ``{output_path}`` using ``python -c`` so
|
|
the tests run identically on Linux, macOS, and (with minor quoting
|
|
differences) Windows.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from tools.tts_command_provider import (
|
|
DEFAULT_COMMAND_TTS_MAX_TEXT_LENGTH,
|
|
DEFAULT_COMMAND_TTS_OUTPUT_FORMAT,
|
|
DEFAULT_COMMAND_TTS_TIMEOUT_SECONDS,
|
|
_get_command_tts_timeout,
|
|
_get_named_provider_config,
|
|
_is_command_provider_config,
|
|
_iter_command_providers,
|
|
render_command_template as _render_command_tts_template,
|
|
run_command_provider as _run_command_tts,
|
|
shell_quote_context as _shell_quote_context,
|
|
)
|
|
from tools.tts_tool import (
|
|
BUILTIN_TTS_PROVIDERS,
|
|
_generate_command_tts,
|
|
_get_command_tts_output_format,
|
|
_resolve_command_provider_config,
|
|
_resolve_max_text_length,
|
|
check_tts_requirements,
|
|
text_to_speech_tool,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _python_copy_command(output_placeholder: str = "{output_path}") -> str:
|
|
"""Return a cross-platform shell command that copies {input_path} -> output."""
|
|
interpreter = sys.executable
|
|
return (
|
|
f'"{interpreter}" -c "import shutil, sys; '
|
|
f'shutil.copyfile(sys.argv[1], sys.argv[2])" '
|
|
f'{{input_path}} {output_placeholder}'
|
|
)
|
|
|
|
|
|
def _shell_command(*args: str) -> str:
|
|
"""Return a shell command string for subprocess.Popen(shell=True)."""
|
|
if os.name == "nt":
|
|
return subprocess.list2cmdline(list(args))
|
|
return " ".join(shlex.quote(str(arg)) for arg in args)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _resolve_command_provider_config / built-in precedence
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestResolveCommandProviderConfig:
|
|
def test_builtin_names_are_never_command_providers(self):
|
|
cfg = {
|
|
"providers": {
|
|
"openai": {"type": "command", "command": "echo hi"},
|
|
"edge": {"type": "command", "command": "echo hi"},
|
|
},
|
|
}
|
|
for name in BUILTIN_TTS_PROVIDERS:
|
|
assert _resolve_command_provider_config(name, cfg) is None
|
|
|
|
def test_missing_provider_returns_none(self):
|
|
cfg = {"providers": {}}
|
|
assert _resolve_command_provider_config("nope", cfg) is None
|
|
|
|
|
|
def test_case_insensitive_lookup(self):
|
|
cfg = {"providers": {"piper-cli": {"type": "command", "command": "x"}}}
|
|
assert _resolve_command_provider_config("PIPER-CLI", cfg) is not None
|
|
|
|
def test_native_piper_cannot_be_shadowed_by_command_entry(self):
|
|
"""Regression guard for PR that added native Piper as a built-in.
|
|
A user's ``tts.providers.piper`` must not override the built-in."""
|
|
cfg = {
|
|
"providers": {
|
|
"piper": {"type": "command", "command": "some-script"},
|
|
},
|
|
}
|
|
assert _resolve_command_provider_config("piper", cfg) is None
|
|
|
|
|
|
class TestCommandTtsEnv:
|
|
def test_command_provider_uses_sanitized_child_env(self, monkeypatch):
|
|
"""Salvage of #56332: command TTS must not inherit Hermes secrets."""
|
|
monkeypatch.setenv("AUXILIARY_VISION_API_KEY", "sk-vision")
|
|
monkeypatch.setenv("GATEWAY_RELAY_SECRET", "relay-secret")
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai")
|
|
monkeypatch.setenv("MY_SAFE_TTS_VAR", "keep")
|
|
|
|
captured = {}
|
|
|
|
class _Stream:
|
|
def read(self, size):
|
|
return ""
|
|
|
|
class Proc:
|
|
returncode = 0
|
|
stdout = _Stream()
|
|
stderr = _Stream()
|
|
|
|
def wait(self, timeout=None):
|
|
return 0
|
|
|
|
def fake_popen(command, **kwargs):
|
|
captured["env"] = kwargs["env"]
|
|
return Proc()
|
|
|
|
monkeypatch.setattr("tools.tts_command_provider.subprocess.Popen", fake_popen)
|
|
|
|
result = _run_command_tts("echo hi", timeout=1)
|
|
|
|
assert result.returncode == 0
|
|
env = captured["env"]
|
|
assert "AUXILIARY_VISION_API_KEY" not in env
|
|
assert "GATEWAY_RELAY_SECRET" not in env
|
|
assert "OPENAI_API_KEY" not in env
|
|
assert env["MY_SAFE_TTS_VAR"] == "keep"
|
|
|
|
|
|
class TestGetNamedProviderConfig:
|
|
def test_providers_block_wins(self):
|
|
cfg = {"providers": {"voxcpm": {"command": "new"}},
|
|
"voxcpm": {"command": "legacy"}}
|
|
assert _get_named_provider_config(cfg, "voxcpm") == {"command": "new"}
|
|
|
|
def test_legacy_tts_name_block_still_resolves(self):
|
|
cfg = {"voxcpm": {"type": "command", "command": "legacy"}}
|
|
assert _get_named_provider_config(cfg, "voxcpm") == {
|
|
"type": "command", "command": "legacy"
|
|
}
|
|
|
|
def test_builtin_names_do_not_leak_through_legacy_path(self):
|
|
"""``tts.openai`` must never be mistaken for a command provider."""
|
|
cfg = {"openai": {"command": "oops", "type": "command"}}
|
|
assert _get_named_provider_config(cfg, "openai") == {}
|
|
|
|
|
|
class TestIsCommandProviderConfig:
|
|
|
|
|
|
def test_type_mismatch_is_false(self):
|
|
assert _is_command_provider_config({"type": "native", "command": "x"}) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _iter_command_providers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestIterCommandProviders:
|
|
def test_iterates_only_user_command_providers(self):
|
|
cfg = {
|
|
"providers": {
|
|
"openai": {"type": "command", "command": "shouldnt show up"},
|
|
"piper-cli": {"type": "command", "command": "piper-cli"},
|
|
"voxcpm": {"type": "command", "command": "voxcpm"},
|
|
"broken": {"type": "command", "command": ""},
|
|
},
|
|
}
|
|
names = sorted(name for name, _ in _iter_command_providers(cfg))
|
|
assert names == ["piper-cli", "voxcpm"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# config getters
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestConfigGetters:
|
|
def test_timeout_defaults(self):
|
|
assert _get_command_tts_timeout({}) == float(DEFAULT_COMMAND_TTS_TIMEOUT_SECONDS)
|
|
|
|
|
|
def test_output_format_defaults(self):
|
|
assert _get_command_tts_output_format({}) == DEFAULT_COMMAND_TTS_OUTPUT_FORMAT
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _resolve_max_text_length for command providers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMaxTextLengthForCommandProviders:
|
|
def test_default_for_command_provider(self):
|
|
cfg = {"providers": {"piper-cli": {"type": "command", "command": "x"}}}
|
|
assert _resolve_max_text_length("piper-cli", cfg) == DEFAULT_COMMAND_TTS_MAX_TEXT_LENGTH
|
|
|
|
|
|
def test_override_under_legacy_tts_name_block(self):
|
|
cfg = {"piper-cli": {"type": "command", "command": "x", "max_text_length": 7777}}
|
|
assert _resolve_max_text_length("piper-cli", cfg) == 7777
|
|
|
|
def test_non_command_unknown_provider_still_falls_back(self):
|
|
assert _resolve_max_text_length("unknown", {}) > 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _shell_quote_context / template rendering
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestShellQuoteContext:
|
|
def test_bare_context(self):
|
|
tpl = 'tts {output_path}'
|
|
pos = tpl.index("{output_path}")
|
|
assert _shell_quote_context(tpl, pos) is None
|
|
|
|
|
|
def test_inside_double_quotes(self):
|
|
tpl = 'tts "{output_path}"'
|
|
pos = tpl.index("{output_path}")
|
|
assert _shell_quote_context(tpl, pos) == '"'
|
|
|
|
def test_escaped_double_quote_inside_double(self):
|
|
tpl = r'tts "foo \" {output_path}"'
|
|
pos = tpl.index("{output_path}")
|
|
assert _shell_quote_context(tpl, pos) == '"'
|
|
|
|
|
|
class TestRenderCommandTtsTemplate:
|
|
def test_substitutes_all_placeholders(self):
|
|
placeholders = {
|
|
"input_path": "/tmp/in.txt",
|
|
"text_path": "/tmp/in.txt",
|
|
"output_path": "/tmp/out.mp3",
|
|
"format": "mp3",
|
|
"voice": "af_sky",
|
|
"model": "tiny",
|
|
"speed": "1.0",
|
|
}
|
|
rendered = _render_command_tts_template(
|
|
"tts --voice {voice} --in {input_path} --out {output_path}",
|
|
placeholders,
|
|
)
|
|
assert "af_sky" in rendered
|
|
assert "/tmp/out.mp3" in rendered
|
|
|
|
|
|
def test_literal_braces_survive(self):
|
|
placeholders = {
|
|
"input_path": "/tmp/in.txt", "text_path": "/tmp/in.txt",
|
|
"output_path": "/tmp/out.mp3", "format": "mp3",
|
|
"voice": "", "model": "", "speed": "1.0",
|
|
}
|
|
rendered = _render_command_tts_template(
|
|
"echo '{{not a placeholder}}' && tts --in {input_path}",
|
|
placeholders,
|
|
)
|
|
assert "{not a placeholder}" in rendered
|
|
|
|
def test_injection_is_neutralized(self):
|
|
"""Embedded shell metacharacters in a placeholder value must be quoted."""
|
|
placeholders = {
|
|
"input_path": "/tmp/in.txt", "text_path": "/tmp/in.txt",
|
|
"output_path": "/tmp/out; rm -rf /",
|
|
"format": "mp3",
|
|
"voice": "$(whoami)", "model": "", "speed": "1.0",
|
|
}
|
|
rendered = _render_command_tts_template(
|
|
"tts --voice {voice} --out {output_path}",
|
|
placeholders,
|
|
)
|
|
# The injection payload must not appear unquoted in the rendered
|
|
# command. On POSIX shlex.quote wraps the value in single quotes.
|
|
if os.name != "nt":
|
|
assert "'$(whoami)'" in rendered or "'\\''" in rendered
|
|
assert "; rm -rf /" not in rendered.replace(
|
|
"'/tmp/out; rm -rf /'", "",
|
|
)
|
|
|
|
def test_preserves_shell_quoting_style(self):
|
|
placeholders = {
|
|
"input_path": "/tmp/in.txt", "text_path": "/tmp/in.txt",
|
|
"output_path": "/tmp/out.mp3", "format": "mp3",
|
|
"voice": "bob's voice", "model": "", "speed": "1.0",
|
|
}
|
|
# When the template wraps the placeholder in double quotes we must
|
|
# escape for that context, not collapse to single-quoted form.
|
|
rendered = _render_command_tts_template(
|
|
'tts --voice "{voice}"',
|
|
placeholders,
|
|
)
|
|
assert '"bob\'s voice"' in rendered
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _run_command_tts idle/progress timeout behavior
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestRunCommandTts:
|
|
|
|
|
|
def test_silent_after_progress_still_times_out_with_stderr(self, tmp_path):
|
|
script = tmp_path / "progress_then_hang.py"
|
|
script.write_text(
|
|
"\n".join([
|
|
"import sys, time",
|
|
"print('starting tier 1', file=sys.stderr, flush=True)",
|
|
"time.sleep(1.0)",
|
|
]),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
with pytest.raises(subprocess.TimeoutExpired) as excinfo:
|
|
_run_command_tts(
|
|
_shell_command(sys.executable, "-u", str(script)),
|
|
timeout=0.2,
|
|
)
|
|
|
|
assert "starting tier 1" in (excinfo.value.stderr or "")
|
|
assert isinstance(excinfo.value.__cause__, subprocess.TimeoutExpired)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# End-to-end: _generate_command_tts
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestGenerateCommandTts:
|
|
def test_writes_output_file(self, tmp_path):
|
|
out = tmp_path / "clip.mp3"
|
|
config = {"command": _python_copy_command()}
|
|
result = _generate_command_tts(
|
|
"hello world",
|
|
str(out),
|
|
"py-copy",
|
|
config,
|
|
{},
|
|
)
|
|
assert result == str(out)
|
|
assert out.exists()
|
|
# The command copied the input text file over to output, so it
|
|
# contains the original UTF-8 text.
|
|
assert out.read_text(encoding="utf-8") == "hello world"
|
|
|
|
|
|
@pytest.mark.platforms("posix") # POSIX-only timeout semantics
|
|
def test_timeout_raises_runtime(self, tmp_path):
|
|
config = {
|
|
"command": f'"{sys.executable}" -c "import time; time.sleep(10)"',
|
|
"timeout": 1,
|
|
}
|
|
with pytest.raises(RuntimeError, match="timed out"):
|
|
_generate_command_tts(
|
|
"hello",
|
|
str(tmp_path / "x.mp3"),
|
|
"slow",
|
|
config,
|
|
{},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# text_to_speech_tool integration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestTextToSpeechToolWithCommandProvider:
|
|
def test_command_provider_dispatches_end_to_end(self, tmp_path):
|
|
cfg = {
|
|
"tts": {
|
|
"provider": "py-copy",
|
|
"providers": {
|
|
"py-copy": {
|
|
"type": "command",
|
|
"command": _python_copy_command(),
|
|
"output_format": "mp3",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
out = tmp_path / "clip.mp3"
|
|
|
|
# Patch the config loader used by the tool so we don't touch disk.
|
|
def fake_load():
|
|
return cfg["tts"]
|
|
|
|
with patch("tools.tts_tool._load_tts_config", fake_load):
|
|
result = text_to_speech_tool(text="hi", output_path=str(out))
|
|
data = json.loads(result)
|
|
assert data["success"] is True, data
|
|
assert data["provider"] == "py-copy"
|
|
assert data["voice_compatible"] is False
|
|
assert Path(data["file_path"]).exists()
|
|
|
|
def test_voice_compatible_opt_in_toggles_flag(self, tmp_path):
|
|
"""voice_compatible=true is reflected in the response when the
|
|
file is already .ogg (no ffmpeg needed)."""
|
|
cfg = {
|
|
"provider": "py-copy-ogg",
|
|
"providers": {
|
|
"py-copy-ogg": {
|
|
"type": "command",
|
|
"command": _python_copy_command(),
|
|
"output_format": "ogg",
|
|
"voice_compatible": True,
|
|
},
|
|
},
|
|
}
|
|
out = tmp_path / "clip.ogg"
|
|
|
|
with patch("tools.tts_tool._load_tts_config", return_value=cfg):
|
|
result = text_to_speech_tool(text="hi", output_path=str(out))
|
|
data = json.loads(result)
|
|
assert data["success"] is True
|
|
assert data["voice_compatible"] is True
|
|
assert data["media_tag"].startswith("[[audio_as_voice]]")
|
|
|
|
def test_missing_command_falls_through_to_builtin(self, tmp_path):
|
|
"""A provider entry with an empty command is not a command
|
|
provider; the tool should not raise a "command not configured"
|
|
error but fall through to the built-in resolution path."""
|
|
cfg = {
|
|
"provider": "broken",
|
|
"providers": {
|
|
"broken": {"type": "command", "command": " "},
|
|
},
|
|
}
|
|
with patch("tools.tts_tool._load_tts_config", return_value=cfg):
|
|
result = text_to_speech_tool(text="hi", output_path=str(tmp_path / "x.mp3"))
|
|
data = json.loads(result)
|
|
# The response should not carry the command-provider error text.
|
|
err = (data.get("error") or "").lower()
|
|
assert "tts.providers.broken.command is not configured" not in err
|
|
|
|
|
|
class TestCheckTtsRequirements:
|
|
def test_configured_command_provider_satisfies_requirement(self):
|
|
cfg = {
|
|
"provider": "x",
|
|
"providers": {"x": {"type": "command", "command": "echo x"}},
|
|
}
|
|
with patch("tools.tts_tool._load_tts_config", return_value=cfg):
|
|
assert check_tts_requirements() is True
|
|
|
|
|
|
class TestCommandTtsEnvPassthrough:
|
|
def test_env_passthrough_restores_named_keys(self, monkeypatch):
|
|
"""A provider's env_passthrough allowlist re-adds its own API key
|
|
without unscrubbing everything else."""
|
|
monkeypatch.setenv("MY_TTS_API_KEY", "sk-provider")
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai")
|
|
|
|
captured = {}
|
|
|
|
class _Stream:
|
|
def read(self, size):
|
|
return ""
|
|
|
|
class Proc:
|
|
returncode = 0
|
|
stdout = _Stream()
|
|
stderr = _Stream()
|
|
|
|
def wait(self, timeout=None):
|
|
return 0
|
|
|
|
def fake_popen(command, **kwargs):
|
|
captured["env"] = kwargs["env"]
|
|
return Proc()
|
|
|
|
monkeypatch.setattr("tools.tts_command_provider.subprocess.Popen", fake_popen)
|
|
|
|
result = _run_command_tts(
|
|
"echo hi", timeout=1, env_passthrough=["MY_TTS_API_KEY"]
|
|
)
|
|
|
|
assert result.returncode == 0
|
|
env = captured["env"]
|
|
assert env["MY_TTS_API_KEY"] == "sk-provider"
|
|
assert "OPENAI_API_KEY" not in env
|
|
|
|
def test_allowlist_parsed_from_provider_config(self):
|
|
from tools.tts_command_provider import command_env_passthrough as _command_provider_env_passthrough
|
|
|
|
assert _command_provider_env_passthrough(
|
|
{"env_passthrough": ["A_KEY", " B_KEY ", ""]}
|
|
) == ["A_KEY", "B_KEY"]
|
|
assert _command_provider_env_passthrough({}) == []
|
|
assert _command_provider_env_passthrough({"env_passthrough": "A_KEY"}) == []
|