This commit is contained in:
eric
2026-05-16 19:24:30 -05:00
parent 19beec12a5
commit 75a0ca4e31
260 changed files with 51345 additions and 1 deletions

1
d2ypp2/tests/__init__.py Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from src.android_control import enrich_device_row
from src.redroid_control import load_redroid_env, redroid_instance_name, redroid_instance_port
class AndroidRedroidTests(unittest.TestCase):
def test_enrich_device_row_marks_usb_physical(self) -> None:
row = enrich_device_row({"serial": "ABC123", "state": "device", "model": "Pixel"})
self.assertEqual(row["transport"], "usb")
self.assertEqual(row["kind"], "physical")
def test_enrich_device_row_marks_redroid(self) -> None:
row = enrich_device_row(
{
"serial": "127.0.0.1:5555",
"state": "device",
"model": "redroid",
"product": "redroid",
}
)
self.assertEqual(row["transport"], "tcp")
self.assertEqual(row["kind"], "redroid")
@patch("src.redroid_control.platform.machine", return_value="x86_64")
def test_redroid_env_prefers_x86_alt_image(self, _machine) -> None:
env = load_redroid_env()
self.assertIn("redroid:", env["REDROID_IMAGE"])
self.assertEqual(env["REDROID_IMAGE"], env["REDROID_IMAGE_X86_ALT"])
@patch("src.redroid_control.platform.machine", return_value="aarch64")
def test_redroid_env_prefers_arm_image_and_guest_gpu_bootarg(self, _machine) -> None:
env = load_redroid_env()
self.assertEqual(env["REDROID_IMAGE"], "fahaddz/redroid:13-arm-pi5")
self.assertIn("androidboot.redroid_gpu_mode=guest", env["REDROID_BOOT_ARGS"])
def test_redroid_default_instance_layout(self) -> None:
self.assertEqual(redroid_instance_name(1), "redroid13-1")
self.assertEqual(redroid_instance_port(1), 5555)
self.assertEqual(redroid_instance_port(3), 5557)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,137 @@
from __future__ import annotations
import errno
import shutil
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from src.config_governance import (
analyze_content,
backup_managed_file,
optimize_content,
save_managed_file,
)
from src.control_plane import ManagedRoot
class ConfigGovernanceTests(unittest.TestCase):
def test_url_config_detects_duplicates(self) -> None:
sample = "\n".join(
[
"https://live.douyin.com/123456,主播A",
"https://live.douyin.com/123456,主播A重复",
"# https://live.douyin.com/999999",
"https://www.tiktok.com/@demo/live",
]
)
result = analyze_content("app-config", "URL_config.ini", sample, source="draft")
codes = {item["code"] for item in result["issues"]}
self.assertIn("duplicate_urls", codes)
self.assertTrue(any(item["id"] == "dedupe_urls" for item in result["actions"]))
def test_dedupe_urls_keeps_single_active_copy(self) -> None:
sample = "\n".join(
[
"# keep comment",
"https://live.douyin.com/123456",
"https://live.douyin.com/123456",
"https://live.douyin.com/654321",
]
)
result = optimize_content("app-config", "URL_config.ini", "dedupe_urls", sample)
content = result["content"]
self.assertEqual(content.count("https://live.douyin.com/123456"), 1)
self.assertIn("# keep comment", content)
self.assertIn("https://live.douyin.com/654321", content)
def test_youtube_preset_preserves_key_and_enables_balanced_defaults(self) -> None:
sample = "[youtube]\nkey = demo-key\nrtmps = 否\nfast_audio = 0\n"
result = optimize_content("app-config", "youtube.ini", "youtube_balanced_preset", sample)
content = result["content"]
self.assertIn("key = demo-key", content)
self.assertIn("rtmps = 是", content)
self.assertIn("bitrate = 4500", content)
self.assertIn("fast_audio = 1", content)
def test_backup_managed_file_reclaims_history_when_disk_full(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
root_path = tmp_path / "config"
root_path.mkdir(parents=True, exist_ok=True)
target = root_path / "youtube.demo.ini"
target.write_text("[youtube]\nkey = old\n", encoding="utf-8")
root = ManagedRoot(
root_id="app-config",
label="App Config",
description="test",
path=root_path,
explicit_names=("youtube.demo.ini",),
)
hist_dir = tmp_path / "backup_config" / "__web__" / "app-config" / "youtube.demo.ini.history"
hist_dir.mkdir(parents=True, exist_ok=True)
(hist_dir / "stale-1.ini").write_text("1", encoding="utf-8")
(hist_dir / "stale-2.ini").write_text("2", encoding="utf-8")
calls = {"n": 0}
real_copy2 = shutil.copy2
def flaky_copy2(src, dst, *args, **kwargs):
calls["n"] += 1
if calls["n"] == 1:
raise OSError(errno.ENOSPC, "No space left on device")
return real_copy2(src, dst, *args, **kwargs)
with mock.patch("src.config_governance.resolve_managed_file", return_value=(root, Path("youtube.demo.ini"), target)):
with mock.patch("src.config_governance.WEB_BACKUP_DIR", tmp_path / "backup_config" / "__web__"):
with mock.patch("src.config_governance.shutil.copy2", side_effect=flaky_copy2):
backup = backup_managed_file("app-config", "youtube.demo.ini", reason="save")
self.assertIsNotNone(backup)
self.assertGreaterEqual(calls["n"], 2)
remaining = sorted(p.name for p in hist_dir.iterdir() if p.is_file())
self.assertEqual(len(remaining), 1)
self.assertTrue(remaining[0].endswith(".ini"))
def test_save_managed_file_retries_after_enospc(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
root_path = tmp_path / "config"
root_path.mkdir(parents=True, exist_ok=True)
target = root_path / "youtube.demo.ini"
target.write_text("[youtube]\nkey = old\n", encoding="utf-8")
root = ManagedRoot(
root_id="app-config",
label="App Config",
description="test",
path=root_path,
explicit_names=("youtube.demo.ini",),
)
hist_dir = tmp_path / "backup_config" / "__web__" / "app-config" / "youtube.demo.ini.history"
hist_dir.mkdir(parents=True, exist_ok=True)
(hist_dir / "stale.ini").write_text("stale", encoding="utf-8")
calls = {"n": 0}
real_write_text = Path.write_text
def flaky_write_text(self, *args, **kwargs):
if self == target:
calls["n"] += 1
if calls["n"] == 1:
raise OSError(errno.ENOSPC, "No space left on device")
return real_write_text(self, *args, **kwargs)
with mock.patch("src.config_governance.resolve_managed_file", return_value=(root, Path("youtube.demo.ini"), target)):
with mock.patch("src.config_governance.WEB_BACKUP_DIR", tmp_path / "backup_config" / "__web__"):
with mock.patch("pathlib.Path.write_text", new=flaky_write_text):
result = save_managed_file("app-config", "youtube.demo.ini", "[youtube]\nkey = new\n")
self.assertEqual(result["message"], "Saved successfully")
self.assertEqual(target.read_text(encoding="utf-8"), "[youtube]\nkey = new\n")
self.assertGreaterEqual(calls["n"], 2)
self.assertFalse(hist_dir.exists())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
import unittest
from src.control_plane import SERVICE_SPECS, stack_summary
class ControlPlaneServiceTests(unittest.TestCase):
def test_service_registry_includes_required_modules(self) -> None:
ids = {spec.service_id for spec in SERVICE_SPECS}
self.assertTrue(
{
"mitmproxy",
"vscode_server",
"uptime_kuma",
"pulseaudio",
"kmscube",
"openclaw",
}.issubset(ids)
)
def test_stack_summary_exposes_new_quick_links(self) -> None:
links = stack_summary()["quick_links"]
for key in (
"mitmproxy",
"vscode_server",
"uptime_kuma",
"pulseaudio_tcp",
"openclaw",
):
self.assertIn(key, links)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,40 @@
from __future__ import annotations
import unittest
from src.hdmi_control import _parse_v4l2_devices, _recommend_capture_device
class HdmiControlTests(unittest.TestCase):
def test_parse_v4l2_devices_extracts_nodes(self) -> None:
text = (
"USB Video:\n"
"\t/dev/video0\n"
"\t/dev/video1\n"
"\n"
"bcm2835-codec:\n"
"\t/dev/video10\n"
)
items = _parse_v4l2_devices(text)
self.assertEqual(items[0]["name"], "USB Video")
self.assertEqual(items[0]["nodes"], ["/dev/video0", "/dev/video1"])
self.assertTrue(items[0]["is_capture_candidate"])
def test_parse_v4l2_devices_handles_empty(self) -> None:
self.assertEqual(_parse_v4l2_devices(""), [])
def test_recommend_capture_device_skips_codec_only_nodes(self) -> None:
text = (
"bcm2835-codec:\n"
"\t/dev/video10\n"
"\n"
"USB Video:\n"
"\t/dev/video0\n"
)
items = _parse_v4l2_devices(text)
self.assertFalse(items[0]["is_capture_candidate"])
self.assertEqual(_recommend_capture_device(["/dev/video0", "/dev/video10"], items), "/dev/video0")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,58 @@
from __future__ import annotations
import asyncio
import tempfile
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, patch
from src.hub_routes import configure_hub, fallback_live_event_lines, hub_dashboard
class HubRoutesTests(unittest.TestCase):
def test_fallback_live_event_lines_reads_local_process_logs(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
out_path = Path(tmp) / "youtube-out.log"
err_path = Path(tmp) / "youtube-error.log"
out_path.write_text("booting\nstream ready\n", encoding="utf-8")
err_path.write_text("warning line\ntraceback line\n", encoding="utf-8")
rows = [
{
"label": "YouTube",
"pm2": "youtube",
"out_path": str(out_path),
"err_path": str(err_path),
}
]
lines = fallback_live_event_lines(rows, max_lines=4)
self.assertEqual(len(lines), 4)
self.assertTrue(lines[0].startswith("[YouTube stderr]"))
self.assertTrue(lines[-1].endswith("stream ready"))
def test_hub_dashboard_marks_local_declared_process_as_configured(self) -> None:
backend = type("StubBackend", (), {})()
backend.mode = "local"
backend.ensure_mode = AsyncMock()
backend.parse_pm2_describe = AsyncMock(return_value={"process_status": "not_found", "out_path": None, "err_path": None})
backend.live_ffmpeg_pids = AsyncMock(return_value=[])
configure_hub(backend, [{"pm2": "youtube", "label": "YouTube", "script": "youtube.py"}])
with patch("src.hub_routes.system_snapshot", return_value={}):
with patch("src.hub_routes.query_services", new=AsyncMock(return_value=[])):
with patch("src.hub_routes.adb_available", return_value=False):
with patch("src.hub_routes.stack_summary", return_value={}):
with patch("src.hub_routes.stack_stream_probes", new=AsyncMock(return_value={})):
with patch("src.hub_routes.recent_pm2_log_lines", return_value=[]):
with patch("src.hub_routes.network_iface_stats", return_value={}):
with patch("src.hub_routes.load_hardware_profile", new=AsyncMock(return_value={})):
with patch("src.hub_routes.redroid_runtime_summary", new=AsyncMock(return_value={})):
with patch("src.hub_routes.hdmi_capture_summary", new=AsyncMock(return_value={})):
payload = asyncio.run(hub_dashboard())
row = payload["live"]["processes"][0]
self.assertEqual(row["process_status"], "configured")
self.assertEqual(row["business_status"], "ready")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,87 @@
from __future__ import annotations
import json
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class NekoConfigTests(unittest.TestCase):
def test_browser_launch_cleanup_script_still_prunes_profile_bloat(self) -> None:
script = (ROOT / "services" / "neko" / "browser-launch.sh").read_text(encoding="utf-8")
self.assertIn("arch=\"$(uname -m", script)
self.assertIn("--disable-software-rasterizer", script)
self.assertIn("--use-gl=swiftshader", script)
self.assertIn("--bwsi", script)
self.assertIn("NEKO_BROWSER_RENDER_MODE", script)
self.assertIn('prune_path "$profile_dir/BrowserMetrics"', script)
self.assertIn('prune_path "$profile_dir/Crash Reports"', script)
self.assertIn('prune_path "$profile_dir/SingletonLock"', script)
self.assertIn('prune_path "$profile_dir/Default/Code Cache"', script)
def test_browser_policies_default_to_allowed_permissions(self) -> None:
policies = json.loads((ROOT / "services" / "neko" / "policies" / "policies.json").read_text(encoding="utf-8"))
self.assertEqual(policies["DefaultNotificationsSetting"], 1)
self.assertEqual(policies["DefaultGeolocationSetting"], 1)
self.assertTrue(policies["VideoCaptureAllowed"])
self.assertTrue(policies["AudioCaptureAllowed"])
def test_google_chrome_supervisor_uses_direct_stable_command(self) -> None:
conf = (ROOT / "services" / "neko" / "google-chrome.conf").read_text(encoding="utf-8")
self.assertIn("command=/usr/bin/google-chrome", conf)
self.assertNotIn("browser-launch.sh", conf)
self.assertIn("--user-data-dir=/home/neko/.config/google-chrome", conf)
self.assertIn("--disable-software-rasterizer", conf)
self.assertIn("autorestart=true", conf)
self.assertIn("[program:openbox]", conf)
self.assertIn("/usr/bin/openbox --config-file /etc/neko/openbox.xml", conf)
def test_chromium_supervisor_uses_direct_stable_command(self) -> None:
conf = (ROOT / "services" / "neko" / "chromium.conf").read_text(encoding="utf-8")
self.assertIn("command=/usr/bin/chromium", conf)
self.assertNotIn("browser-launch.sh", conf)
self.assertIn("--user-data-dir=/home/neko/.config/chromium", conf)
self.assertIn("--disable-software-rasterizer", conf)
self.assertIn("autorestart=true", conf)
self.assertIn("[program:openbox]", conf)
def test_neko_compose_mounts_only_selected_supervisor_conf(self) -> None:
compose = (ROOT / "services" / "neko" / "docker-compose.yml").read_text(encoding="utf-8")
self.assertIn("./${NEKO_BROWSER_SUPERVISOR_CONF}:/etc/neko/supervisord/browser.conf:ro", compose)
self.assertIn("container_name: live-neko-1", compose)
self.assertIn("container_name: live-neko-2", compose)
self.assertIn("container_name: live-neko-3", compose)
self.assertIn("${NEKO_HTTP_PORT_1}:8080", compose)
self.assertIn("${NEKO_HTTP_PORT_2}:8080", compose)
self.assertIn("${NEKO_HTTP_PORT_3}:8080", compose)
self.assertNotIn("./chromium.conf:/etc/neko/supervisord/chromium.conf:ro", compose)
self.assertNotIn("./google-chrome.conf:/etc/neko/supervisord/google-chrome.conf:ro", compose)
self.assertNotIn("./browser-launch.sh:/etc/neko/browser-launch.sh:ro", compose)
def test_neko_scripts_use_family_specific_profile_dirs(self) -> None:
service_ctl = (ROOT / "scripts" / "linux" / "service_ctl.sh").read_text(encoding="utf-8")
common = (ROOT / "scripts" / "linux" / "lib" / "common.sh").read_text(encoding="utf-8")
self.assertIn("resolve_neko_profile_dir()", service_ctl)
self.assertIn('${family}-profile-v2', service_ctl)
self.assertIn("resolve_neko_profile_dir()", common)
self.assertIn('${family}-profile-v2', common)
self.assertIn("resolve_neko_profile_dir_for_instance()", service_ctl)
self.assertIn("NEKO_HTTP_PORT_2", service_ctl)
self.assertIn("NEKO_WEBRTC_UDP_RANGE_3", service_ctl)
self.assertIn("resolve_neko_profile_dir_for_instance()", common)
self.assertIn("NEKO_HTTP_PORT_2", common)
self.assertIn("NEKO_WEBRTC_UDP_RANGE_3", common)
def test_local_browser_prefers_chrome_on_x86_and_chromium_elsewhere(self) -> None:
script = (ROOT / "scripts" / "linux" / "launch_browser.sh").read_text(encoding="utf-8")
self.assertIn("x86_64|amd64", script)
x86_branch = script.split("x86_64|amd64)", 1)[1].split(";;", 1)[0]
other_branch = script.split("*)", 1)[1].split(";;", 1)[0]
self.assertLess(x86_branch.find("google-chrome"), x86_branch.find("chromium"))
self.assertLess(other_branch.find("chromium"), other_branch.find("google-chrome"))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
import unittest
from src.process_runtime_status import (
coerce_declared_process_status,
normalize_log_text,
summarize_process_activity,
tail_log_text,
)
class ProcessRuntimeStatusTests(unittest.TestCase):
def test_normalize_and_tail_log_text_handles_carriage_returns(self) -> None:
raw = "line1\rline2\r\nline3\nline4"
self.assertEqual(normalize_log_text(raw), "line1\nline2\nline3\nline4")
self.assertEqual(tail_log_text(raw, 2), "line3\nline4\n")
def test_summarize_process_activity_marks_streaming_when_ffmpeg_is_active(self) -> None:
state = summarize_process_activity(
"youtube",
"online",
"监测 1 | 格式 YOUTUBE",
"",
log_age_seconds=3.0,
ffmpeg_pids=[1234],
)
self.assertEqual(state["business_status"], "streaming")
self.assertTrue(state["is_pushing"])
def test_summarize_process_activity_marks_waiting_source(self) -> None:
state = summarize_process_activity(
"youtube",
"online",
"等待直播...\n检测直播间中...",
"",
log_age_seconds=4.0,
ffmpeg_pids=[],
)
self.assertEqual(state["business_status"], "waiting_source")
self.assertFalse(state["is_pushing"])
def test_summarize_process_activity_marks_misconfigured(self) -> None:
state = summarize_process_activity(
"youtube",
"online",
"",
"请在 config/youtube.ini 中配置 key=你的stream_key",
log_age_seconds=2.0,
ffmpeg_pids=[],
)
self.assertEqual(state["business_status"], "misconfigured")
def test_summarize_process_activity_marks_stale_when_heartbeat_is_old(self) -> None:
state = summarize_process_activity(
"youtube",
"online",
"监测 1 | 格式 YOUTUBE",
"",
log_age_seconds=360.0,
ffmpeg_pids=[],
)
self.assertEqual(state["business_status"], "stale")
def test_summarize_process_activity_marks_configured_process_as_ready(self) -> None:
state = summarize_process_activity(
"youtube",
"configured",
"",
"",
log_age_seconds=None,
ffmpeg_pids=[],
)
self.assertEqual(state["business_status"], "ready")
self.assertEqual(state["business_note"], "Process is configured but not running")
def test_coerce_declared_process_status_marks_local_missing_process_as_configured(self) -> None:
self.assertEqual(coerce_declared_process_status("local", "not_found", True), "configured")
self.assertEqual(coerce_declared_process_status("pm2", "not_found", True), "not_found")
self.assertEqual(coerce_declared_process_status("local", "online", True), "online")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,23 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from src.runtime_bootstrap import compute_nofile_target, load_msg_push_exports
class RuntimeBootstrapTests(unittest.TestCase):
def test_compute_nofile_target_respects_minimum_and_hard_limit(self) -> None:
self.assertEqual(compute_nofile_target(1024, 524288), 65536)
self.assertEqual(compute_nofile_target(131072, 65535), 65535)
def test_load_msg_push_exports_falls_back_to_noop_when_backup_missing(self) -> None:
with patch("builtins.__import__", side_effect=ModuleNotFoundError("backup")):
funcs = load_msg_push_exports()
self.assertEqual(len(funcs), 7)
result = funcs[0]("demo")
self.assertTrue(result["disabled"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,102 @@
from __future__ import annotations
import ast
import asyncio
import json
import unittest
from unittest.mock import AsyncMock, patch
import web
class WebDynamicProcessesTests(unittest.TestCase):
def test_process_monitor_entries_include_saved_youtube_pro_channels(self) -> None:
channels = [{"id": "lane1", "name": "Lane 1", "pm2Name": "youtube2__lane1"}]
with patch("web.get_youtube_pro_channels", return_value=channels):
entries = web.process_monitor_entries()
pm2_names = {row["pm2"] for row in entries}
self.assertIn("youtube2__lane1", pm2_names)
def test_process_monitor_entries_default_pm2_name_when_missing(self) -> None:
channels = [{"id": "lane2", "name": "Lane 2"}]
with patch("web.get_youtube_pro_channels", return_value=channels):
entries = web.process_monitor_entries()
pm2_names = {row["pm2"] for row in entries}
self.assertIn("youtube2__lane2", pm2_names)
def test_script_for_pm2_or_pro_accepts_custom_saved_pm2_name(self) -> None:
channels = [{"id": "lane3", "name": "Lane 3", "pm2Name": "custom-lane-three"}]
with patch("web.get_youtube_pro_channels", return_value=channels):
resolved = web.script_for_pm2_or_pro("custom-lane-three")
self.assertEqual(resolved, "youtube.py")
def test_process_monitor_entries_overlay_static_youtube_label_for_lane_one(self) -> None:
channels = [{"id": "lane1", "name": "Lane 1", "pm2Name": "youtube"}]
with patch("web.get_youtube_pro_channels", return_value=channels):
entries = web.process_monitor_entries()
youtube_row = next(row for row in entries if row["pm2"] == "youtube")
self.assertEqual(youtube_row["label"], "YouTube Pro \u00b7 Lane 1")
def test_process_monitor_entries_do_not_duplicate_static_pm2_rows(self) -> None:
channels = [
{"id": "lane1", "name": "Lane 1", "pm2Name": "youtube"},
{"id": "lane2", "name": "Lane 2", "pm2Name": "youtube2__lane2"},
]
with patch("web.get_youtube_pro_channels", return_value=channels):
entries = web.process_monitor_entries()
self.assertEqual(sum(1 for row in entries if row["pm2"] == "youtube"), 1)
self.assertEqual(sum(1 for row in entries if row["pm2"] == "youtube2__lane2"), 1)
def test_web_module_does_not_repeat_process_entry_function_defs(self) -> None:
tree = ast.parse((web.BASE_DIR / "web.py").read_text(encoding="utf-8"))
defs: dict[str, list[int]] = {}
for node in tree.body:
if isinstance(node, ast.FunctionDef):
defs.setdefault(node.name, []).append(node.lineno)
self.assertEqual(len(defs.get("youtube_pro_process_entries", [])), 1)
self.assertEqual(len(defs.get("process_monitor_entries", [])), 1)
def test_status_marks_local_saved_process_as_configured_when_not_running(self) -> None:
original_use_pm2 = web.process_backend._use_pm2
web.process_backend._use_pm2 = False
try:
with patch.object(web.process_backend, "full_status_raw", AsyncMock(return_value="local table")):
with patch("web.get_process_info", AsyncMock(return_value={"process_status": "not_found", "out_path": None, "err_path": None})):
with patch("web.read_log_path_normalized", AsyncMock(return_value="")):
with patch.object(web.process_backend, "live_ffmpeg_pids", AsyncMock(return_value=[])):
response = asyncio.run(web.status("youtube"))
finally:
web.process_backend._use_pm2 = original_use_pm2
payload = json.loads(response.body)
self.assertEqual(payload["process_status"], "configured")
self.assertEqual(payload["business_status"], "ready")
self.assertIn("已在控制台配置", payload["recent_log"])
self.assertTrue(payload["raw_status"].startswith("进程「youtube」已在控制台配置"))
def test_process_monitor_includes_runtime_snapshot_for_local_configured_process(self) -> None:
original_use_pm2 = web.process_backend._use_pm2
web.process_backend._use_pm2 = False
try:
with patch.object(web.process_backend, "ensure_mode", AsyncMock()):
with patch("web.get_process_info", AsyncMock(return_value={"process_status": "not_found", "out_path": None, "err_path": None})):
with patch("web.read_log_path_normalized", AsyncMock(return_value="")):
with patch.object(web.process_backend, "live_ffmpeg_pids", AsyncMock(return_value=[])):
payload = asyncio.run(web.process_monitor())
finally:
web.process_backend._use_pm2 = original_use_pm2
youtube_row = next(row for row in payload["entries"] if row["pm2"] == "youtube")
self.assertEqual(youtube_row["process_status"], "configured")
self.assertEqual(youtube_row["business_status"], "ready")
self.assertFalse(youtube_row["is_pushing"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,109 @@
from __future__ import annotations
import asyncio
import tempfile
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, patch
from src.web_process_backend import WebProcessBackend, build_pm2_start_command
class WebProcessBackendTests(unittest.TestCase):
def test_build_pm2_start_command_for_python_script_uses_repo_context(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
base_dir = Path(tmp)
script = base_dir / "youtube.py"
venv_python = base_dir / ".venv" / "bin" / "python"
venv_python.parent.mkdir(parents=True, exist_ok=True)
venv_python.write_text("#!/usr/bin/env python3\n", encoding="utf-8")
script.write_text("print('ok')\n", encoding="utf-8")
cmd = build_pm2_start_command(base_dir, "youtube2__lane1", "youtube.py")
self.assertIsNotNone(cmd)
assert cmd is not None
self.assertIn("LIVE_PM2_PROCESS_NAME=youtube2__lane1", cmd)
self.assertIn("--name youtube2__lane1", cmd)
self.assertIn("--cwd", cmd)
self.assertIn(str(script), cmd)
self.assertIn(str(venv_python), cmd)
def test_parse_pm2_describe_marks_unknown_unregistered_process_as_not_found(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
base_dir = Path(tmp)
backend = WebProcessBackend(base_dir, base_dir / ".pm2" / "logs")
backend._use_pm2 = True
backend.ensure_mode = AsyncMock()
backend._pm2_registered_names = AsyncMock(return_value=set())
warn = "[PM2][WARN] Current process list is not synchronized with saved list. App youtube differs."
with patch("src.web_process_backend.run_pm2", new=AsyncMock(return_value=warn)):
info = asyncio.run(backend.parse_pm2_describe("youtube"))
self.assertEqual(info["process_status"], "not_found")
self.assertIsNone(info["out_path"])
self.assertIsNone(info["err_path"])
def test_parse_pm2_describe_supports_spaced_log_path_keys(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
base_dir = Path(tmp)
backend = WebProcessBackend(base_dir, base_dir / ".pm2" / "logs")
backend._use_pm2 = True
backend.ensure_mode = AsyncMock()
backend._pm2_registered_names = AsyncMock(return_value={"youtube"})
describe = "\n".join(
[
"│ status │ online │",
"│ out log path │ /home/live/.pm2/logs/youtube-out.log │",
"│ error log path │ /home/live/.pm2/logs/youtube-error.log │",
]
)
with patch("src.web_process_backend.run_pm2", new=AsyncMock(return_value=describe)):
info = asyncio.run(backend.parse_pm2_describe("youtube"))
self.assertEqual(info["process_status"], "online")
self.assertEqual(info["out_path"], "/home/live/.pm2/logs/youtube-out.log")
self.assertEqual(info["err_path"], "/home/live/.pm2/logs/youtube-error.log")
def test_ensure_mode_resurrects_pm2_when_dump_exists_and_jlist_is_empty(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
base_dir = Path(tmp)
pm2_home = base_dir / ".pm2-home"
pm2_home.mkdir(parents=True, exist_ok=True)
(pm2_home / "pm2.pid").write_text("1234\n", encoding="utf-8")
(pm2_home / "dump.pm2").write_text('[{"name":"youtube"}]\n', encoding="utf-8")
backend = WebProcessBackend(base_dir, base_dir / ".pm2" / "logs")
backend._use_pm2 = True
shell_out = AsyncMock(return_value=(0, "[]"))
resurrect = AsyncMock(return_value="ok")
with patch.dict("os.environ", {"PM2_HOME": str(pm2_home)}, clear=False):
with patch("src.web_process_backend.resolve_pm2_bin", return_value="/usr/bin/pm2"):
with patch("src.web_process_backend._shell_out", new=shell_out):
with patch("src.web_process_backend.run_pm2", new=resurrect):
asyncio.run(backend.ensure_mode())
resurrect.assert_awaited_once_with("resurrect", timeout=45.0)
def test_ensure_mode_skips_resurrect_without_saved_dump(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
base_dir = Path(tmp)
pm2_home = base_dir / ".pm2-home"
pm2_home.mkdir(parents=True, exist_ok=True)
(pm2_home / "pm2.pid").write_text("1234\n", encoding="utf-8")
backend = WebProcessBackend(base_dir, base_dir / ".pm2" / "logs")
backend._use_pm2 = True
shell_out = AsyncMock(return_value=(0, "[]"))
resurrect = AsyncMock(return_value="ok")
with patch.dict("os.environ", {"PM2_HOME": str(pm2_home)}, clear=False):
with patch("src.web_process_backend.resolve_pm2_bin", return_value="/usr/bin/pm2"):
with patch("src.web_process_backend._shell_out", new=shell_out):
with patch("src.web_process_backend.run_pm2", new=resurrect):
asyncio.run(backend.ensure_mode())
resurrect.assert_not_awaited()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,81 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from src.youtube_pro_runtime import (
ensure_youtube_pro_channel_runtime_files,
managed_url_config_relpath_for_pm2,
managed_youtube_ini_relpath_for_pm2,
normalize_youtube_pro_channels,
)
class YoutubeProRuntimeTests(unittest.TestCase):
def test_normalize_channels_assigns_default_pm2_and_rejects_duplicate_names(self) -> None:
rows = normalize_youtube_pro_channels([{"id": "lane2", "name": "Lane 2"}])
self.assertEqual(rows[0]["pm2Name"], "youtube2__lane2")
with self.assertRaises(ValueError):
normalize_youtube_pro_channels(
[
{"id": "lane1", "pm2Name": "custom"},
{"id": "lane2", "pm2Name": "custom"},
]
)
def test_materialize_runtime_files_from_saved_channel_payload(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
config_dir = Path(tmp)
handled, errors = ensure_youtube_pro_channel_runtime_files(
config_dir,
"youtube2__lane2",
[
{
"id": "lane2",
"name": "Lane 2",
"pm2Name": "youtube2__lane2",
"streamKey": "abcd-efgh-ijkl-mnop",
"urlLines": "https://live.douyin.com/123456789\n",
}
],
)
self.assertTrue(handled)
self.assertEqual(errors, [])
youtube_ini = (config_dir / managed_youtube_ini_relpath_for_pm2("youtube2__lane2")).read_text(
encoding="utf-8"
)
url_ini = (config_dir / managed_url_config_relpath_for_pm2("youtube2__lane2")).read_text(
encoding="utf-8"
)
self.assertIn("key = abcd-efgh-ijkl-mnop", youtube_ini)
self.assertIn("https://live.douyin.com/123456789", url_ini)
def test_materialize_runtime_files_uses_existing_seed_when_channel_fields_blank(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
config_dir = Path(tmp)
(config_dir / "youtube.ini").write_text("[youtube]\nkey = legacy-key\nrtmps = 是\n", encoding="utf-8")
(config_dir / "URL_config.ini").write_text("https://live.douyin.com/88888888\n", encoding="utf-8")
handled, errors = ensure_youtube_pro_channel_runtime_files(
config_dir,
"youtube2__lane3",
[{"id": "lane3", "pm2Name": "youtube2__lane3"}],
)
self.assertTrue(handled)
self.assertEqual(errors, [])
youtube_ini = (config_dir / managed_youtube_ini_relpath_for_pm2("youtube2__lane3")).read_text(
encoding="utf-8"
)
url_ini = (config_dir / managed_url_config_relpath_for_pm2("youtube2__lane3")).read_text(
encoding="utf-8"
)
self.assertIn("legacy-key", youtube_ini)
self.assertIn("https://live.douyin.com/88888888", url_ini)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,96 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from douyin_youtube_ffplay import _load_youtube_push_settings, _resolve_youtube_push_config_file
from src.youtube_ini_sync import write_youtube_ini_stream_key
class YoutubePushConfigTests(unittest.TestCase):
def test_resolve_youtube_push_config_prefers_managed_pm2_file(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
cfg_dir = root / "config"
cfg_dir.mkdir(parents=True, exist_ok=True)
(cfg_dir / "youtube.ini").write_text("[youtube]\nkey = legacy\n", encoding="utf-8")
managed = cfg_dir / "youtube.youtube.ini"
managed.write_text("[youtube]\nkey = managed\n", encoding="utf-8")
with patch.dict("os.environ", {"LIVE_PM2_PROCESS_NAME": "youtube"}, clear=False):
resolved = _resolve_youtube_push_config_file(base_dir=str(root))
self.assertEqual(resolved, str(managed))
def test_resolve_youtube_push_config_falls_back_to_legacy_file(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
cfg_dir = root / "config"
cfg_dir.mkdir(parents=True, exist_ok=True)
legacy = cfg_dir / "youtube.ini"
legacy.write_text("[youtube]\nkey = legacy\n", encoding="utf-8")
with patch.dict("os.environ", {"LIVE_PM2_PROCESS_NAME": "youtube"}, clear=False):
resolved = _resolve_youtube_push_config_file(base_dir=str(root))
self.assertEqual(resolved, str(legacy))
def test_write_youtube_ini_stream_key_updates_default_managed_file_when_present(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
cfg_dir = root / "config"
cfg_dir.mkdir(parents=True, exist_ok=True)
(cfg_dir / "youtube.ini").write_text("[youtube]\nkey = old\n", encoding="utf-8")
(cfg_dir / "youtube.youtube.ini").write_text("[youtube]\nkey = old2\n", encoding="utf-8")
writes: list[tuple[str, str, str]] = []
def fake_save(root_id: str, relative_path: str, content: str) -> dict[str, str]:
writes.append((root_id, relative_path, content))
return {"root_id": root_id, "path": relative_path, "message": "Saved successfully"}
with patch("src.youtube_ini_sync.save_managed_file", side_effect=fake_save):
msg = write_youtube_ini_stream_key(root, "demo-key")
self.assertIn("youtube.ini", msg)
self.assertIn("youtube.youtube.ini", msg)
self.assertEqual([w[1] for w in writes], ["youtube.ini", "youtube.youtube.ini"])
self.assertTrue(all("demo-key" in w[2] for w in writes))
def test_load_youtube_push_settings_accepts_bom_prefixed_ini(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
cfg = Path(tmp) / "youtube.youtube.ini"
cfg.write_text("\ufeff[youtube]\nkey = bom-key\nbitrate = 5500\nfast_audio = 1\n", encoding="utf-8")
parsed = _load_youtube_push_settings(str(cfg))
self.assertEqual(parsed["stream_key"], "bom-key")
self.assertEqual(parsed["b_v"], "5500k")
self.assertEqual(parsed["maxrate"], "6600k")
self.assertEqual(parsed["bufsize"], "11000k")
self.assertTrue(parsed["fast_audio"])
def test_write_youtube_ini_stream_key_reads_existing_bom_prefixed_ini(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
cfg_dir = root / "config"
cfg_dir.mkdir(parents=True, exist_ok=True)
(cfg_dir / "youtube.youtube.ini").write_text("\ufeff[youtube]\nkey = old\n", encoding="utf-8")
writes: list[tuple[str, str, str]] = []
def fake_save(root_id: str, relative_path: str, content: str) -> dict[str, str]:
writes.append((root_id, relative_path, content))
return {"root_id": root_id, "path": relative_path, "message": "Saved successfully"}
with patch("src.youtube_ini_sync.save_managed_file", side_effect=fake_save):
msg = write_youtube_ini_stream_key(root, "fresh-key")
self.assertIn("youtube.youtube.ini", msg)
self.assertTrue(any(path == "youtube.youtube.ini" and "fresh-key" in content for _, path, content in writes))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
import unittest
from src.youtube_runtime import normalize_youtube_video_save_type, resolve_live_video_encoder
class YoutubeRuntimeTests(unittest.TestCase):
def test_youtube_process_defaults_ts_setting_to_push_mode(self) -> None:
self.assertEqual(normalize_youtube_video_save_type("ts", "youtube"), "YOUTUBE")
self.assertEqual(normalize_youtube_video_save_type("", "youtube2__lane1"), "YOUTUBE")
def test_explicit_local_record_format_is_preserved(self) -> None:
self.assertEqual(normalize_youtube_video_save_type("mp4", "youtube"), "MP4")
self.assertEqual(normalize_youtube_video_save_type("mkv", "douyin_youtube"), "MKV")
def test_non_youtube_process_falls_back_to_ts(self) -> None:
self.assertEqual(normalize_youtube_video_save_type("ts", "tiktok"), "TS")
self.assertEqual(normalize_youtube_video_save_type("unknown", "tiktok"), "TS")
def test_arm_falls_back_from_vaapi_to_v4l2m2m(self) -> None:
enc, note = resolve_live_video_encoder(
"h264_vaapi",
"h264_v4l2m2m\nh264_vaapi\n",
"aarch64",
has_mpp_service=False,
has_render_node=True,
has_v4l2_device=True,
)
self.assertEqual(enc, "h264_v4l2m2m")
self.assertIn("fallback", note)
def test_arm_auto_selects_v4l2m2m_when_env_missing(self) -> None:
enc, note = resolve_live_video_encoder(
"",
"h264_v4l2m2m\n",
"aarch64",
has_mpp_service=False,
has_render_node=False,
has_v4l2_device=True,
)
self.assertEqual(enc, "h264_v4l2m2m")
self.assertEqual(note, "")
def test_x86_requires_render_node_for_vaapi(self) -> None:
enc, note = resolve_live_video_encoder(
"h264_vaapi",
"h264_vaapi\n",
"x86_64",
has_mpp_service=False,
has_render_node=False,
)
self.assertEqual(enc, "")
self.assertIn("renderD", note)
def test_arm_rejects_v4l2m2m_without_video_nodes(self) -> None:
enc, note = resolve_live_video_encoder(
"h264_v4l2m2m",
"h264_v4l2m2m\n",
"aarch64",
has_mpp_service=False,
has_render_node=False,
has_v4l2_device=False,
)
self.assertEqual(enc, "")
self.assertIn("/dev/video", note)
if __name__ == "__main__":
unittest.main()