s
This commit is contained in:
@@ -33,6 +33,9 @@ class HubRoutesTests(unittest.TestCase):
|
||||
backend = type("StubBackend", (), {})()
|
||||
backend.mode = "local"
|
||||
backend.ensure_mode = AsyncMock()
|
||||
backend.process_infos = AsyncMock(
|
||||
return_value={"youtube": {"process_status": "not_found", "out_path": None, "err_path": None, "pid": 0}}
|
||||
)
|
||||
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"}])
|
||||
|
||||
@@ -14,7 +14,6 @@ class NekoConfigTests(unittest.TestCase):
|
||||
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)
|
||||
@@ -27,12 +26,14 @@ class NekoConfigTests(unittest.TestCase):
|
||||
self.assertEqual(policies["DefaultGeolocationSetting"], 1)
|
||||
self.assertTrue(policies["VideoCaptureAllowed"])
|
||||
self.assertTrue(policies["AudioCaptureAllowed"])
|
||||
self.assertEqual(policies["DeveloperToolsAvailability"], 1)
|
||||
|
||||
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.assertNotIn("--bwsi", conf)
|
||||
self.assertIn("--disable-software-rasterizer", conf)
|
||||
self.assertIn("autorestart=true", conf)
|
||||
self.assertIn("[program:openbox]", conf)
|
||||
@@ -43,6 +44,7 @@ class NekoConfigTests(unittest.TestCase):
|
||||
self.assertIn("command=/usr/bin/chromium", conf)
|
||||
self.assertNotIn("browser-launch.sh", conf)
|
||||
self.assertIn("--user-data-dir=/home/neko/.config/chromium", conf)
|
||||
self.assertNotIn("--bwsi", conf)
|
||||
self.assertIn("--disable-software-rasterizer", conf)
|
||||
self.assertIn("autorestart=true", conf)
|
||||
self.assertIn("[program:openbox]", conf)
|
||||
|
||||
@@ -33,13 +33,13 @@ class WebDynamicProcessesTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(resolved, "youtube.py")
|
||||
|
||||
def test_process_monitor_entries_overlay_static_youtube_label_for_lane_one(self) -> None:
|
||||
def test_process_monitor_entries_keep_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")
|
||||
self.assertEqual(youtube_row["label"], "YouTube")
|
||||
|
||||
def test_process_monitor_entries_do_not_duplicate_static_pm2_rows(self) -> None:
|
||||
channels = [
|
||||
@@ -85,10 +85,13 @@ class WebDynamicProcessesTests(unittest.TestCase):
|
||||
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.object(
|
||||
web.process_backend,
|
||||
"process_infos",
|
||||
AsyncMock(return_value={"youtube": {"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())
|
||||
payload = asyncio.run(web.process_monitor())
|
||||
finally:
|
||||
web.process_backend._use_pm2 = original_use_pm2
|
||||
|
||||
@@ -97,6 +100,59 @@ class WebDynamicProcessesTests(unittest.TestCase):
|
||||
self.assertEqual(youtube_row["business_status"], "ready")
|
||||
self.assertFalse(youtube_row["is_pushing"])
|
||||
|
||||
def test_process_monitor_family_filter_returns_youtube_rows_only(self) -> None:
|
||||
original_use_pm2 = web.process_backend._use_pm2
|
||||
web.process_backend._use_pm2 = False
|
||||
try:
|
||||
with patch("web.process_monitor_entries", return_value=(
|
||||
{"pm2": "youtube", "script": "youtube.py", "label": "YouTube"},
|
||||
{"pm2": "youtube2__lane1", "script": "youtube.py", "label": "Lane 1"},
|
||||
{"pm2": "tiktok", "script": "tiktok.py", "label": "TikTok"},
|
||||
)):
|
||||
with patch.object(web.process_backend, "ensure_mode", AsyncMock()):
|
||||
with patch.object(
|
||||
web.process_backend,
|
||||
"process_infos",
|
||||
AsyncMock(
|
||||
return_value={
|
||||
"youtube": {"process_status": "stopped", "out_path": None, "err_path": None},
|
||||
"youtube2__lane1": {"process_status": "online", "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(family="youtube", light=True))
|
||||
finally:
|
||||
web.process_backend._use_pm2 = original_use_pm2
|
||||
|
||||
pm2_names = {row["pm2"] for row in payload["entries"]}
|
||||
self.assertIn("youtube", pm2_names)
|
||||
self.assertIn("youtube2__lane1", pm2_names)
|
||||
self.assertTrue(all(name.startswith("youtube") or name.startswith("douyin_youtube") for name in pm2_names))
|
||||
|
||||
def test_restart_kills_old_ffmpeg_before_backend_restart(self) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
async def fake_kill(process: str, base_dir, **_kwargs) -> None: # noqa: ANN001
|
||||
calls.append(f"kill:{process}")
|
||||
|
||||
async def fake_restart(process: str, script: str) -> str:
|
||||
calls.append(f"restart:{process}:{script}")
|
||||
return "ok"
|
||||
|
||||
with patch("web.is_valid_control_process", return_value=True):
|
||||
with patch("web.script_for_pm2_or_pro", return_value="youtube.py"):
|
||||
with patch("web._validate_youtube_runtime_files", return_value=[]):
|
||||
with patch("web._stop_other_hdmi_sink_processes", new=AsyncMock(return_value=[])):
|
||||
with patch("web.force_kill_ffmpeg", new=AsyncMock(side_effect=fake_kill)):
|
||||
with patch.object(web.process_backend, "restart", new=AsyncMock(side_effect=fake_restart)):
|
||||
response = asyncio.run(web.restart("youtube"))
|
||||
|
||||
payload = json.loads(response.body)
|
||||
self.assertEqual(payload["output"], "ok")
|
||||
self.assertEqual(calls, ["kill:youtube", "restart:youtube:youtube.py"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -29,41 +29,56 @@ class WebProcessBackendTests(unittest.TestCase):
|
||||
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:
|
||||
def test_parse_pm2_describe_marks_missing_jlist_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"))
|
||||
backend.process_infos = AsyncMock(return_value={"youtube": {"process_status": "not_found", "out_path": None, "err_path": None, "pid": 0}}) # type: ignore[method-assign]
|
||||
|
||||
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"])
|
||||
self.assertEqual(info["pid"], 0)
|
||||
|
||||
def test_parse_pm2_describe_supports_spaced_log_path_keys(self) -> None:
|
||||
def test_parse_pm2_describe_reads_status_and_log_paths_from_jlist_item(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 │",
|
||||
]
|
||||
backend.process_infos = AsyncMock( # type: ignore[method-assign]
|
||||
return_value={
|
||||
"youtube": {
|
||||
"process_status": "online",
|
||||
"out_path": "/home/live/.pm2/logs/youtube-out.log",
|
||||
"err_path": "/home/live/.pm2/logs/youtube-error.log",
|
||||
"pid": 4567,
|
||||
}
|
||||
}
|
||||
)
|
||||
with patch("src.web_process_backend.run_pm2", new=AsyncMock(return_value=describe)):
|
||||
info = asyncio.run(backend.parse_pm2_describe("youtube"))
|
||||
|
||||
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")
|
||||
self.assertEqual(info["pid"], 4567)
|
||||
|
||||
def test_ensure_mode_resurrects_pm2_when_dump_exists_and_jlist_is_empty(self) -> None:
|
||||
def test_pm2_jlist_items_uses_short_ttl_cache(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
base_dir = Path(tmp)
|
||||
backend = WebProcessBackend(base_dir, base_dir / ".pm2" / "logs")
|
||||
shell_out = AsyncMock(return_value=(0, '[{"name":"youtube","pm2_env":{"status":"online"}}]'))
|
||||
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):
|
||||
first = asyncio.run(backend._pm2_jlist_items())
|
||||
second = asyncio.run(backend._pm2_jlist_items())
|
||||
|
||||
self.assertEqual(first[0]["name"], "youtube")
|
||||
self.assertEqual(second[0]["name"], "youtube")
|
||||
shell_out.assert_awaited_once()
|
||||
|
||||
def test_ensure_pm2_runtime_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"
|
||||
@@ -75,16 +90,16 @@ class WebProcessBackendTests(unittest.TestCase):
|
||||
backend._use_pm2 = True
|
||||
|
||||
shell_out = AsyncMock(return_value=(0, "[]"))
|
||||
resurrect = AsyncMock(return_value="ok")
|
||||
resurrect = AsyncMock(return_value=(0, "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())
|
||||
with patch("src.web_process_backend.run_pm2_result", new=resurrect):
|
||||
asyncio.run(backend.ensure_pm2_runtime())
|
||||
|
||||
resurrect.assert_awaited_once_with("resurrect", timeout=45.0)
|
||||
|
||||
def test_ensure_mode_skips_resurrect_without_saved_dump(self) -> None:
|
||||
def test_ensure_pm2_runtime_skips_resurrect_without_saved_dump(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
base_dir = Path(tmp)
|
||||
pm2_home = base_dir / ".pm2-home"
|
||||
@@ -95,15 +110,52 @@ class WebProcessBackendTests(unittest.TestCase):
|
||||
backend._use_pm2 = True
|
||||
|
||||
shell_out = AsyncMock(return_value=(0, "[]"))
|
||||
resurrect = AsyncMock(return_value="ok")
|
||||
resurrect = AsyncMock(return_value=(0, "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())
|
||||
with patch("src.web_process_backend.run_pm2_result", new=resurrect):
|
||||
asyncio.run(backend.ensure_pm2_runtime())
|
||||
|
||||
resurrect.assert_not_awaited()
|
||||
|
||||
def test_start_does_not_mark_desired_running_when_shell_command_fails(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
base_dir = Path(tmp)
|
||||
script = base_dir / "youtube.py"
|
||||
script.write_text("print('ok')\n", encoding="utf-8")
|
||||
|
||||
backend = WebProcessBackend(base_dir, base_dir / ".pm2" / "logs")
|
||||
backend._use_pm2 = True
|
||||
|
||||
with patch.object(backend, "ensure_mode", AsyncMock()):
|
||||
with patch.object(backend, "_pm2_registered_names", AsyncMock(return_value=set())):
|
||||
with patch.object(backend, "clear_process_logs", AsyncMock()):
|
||||
with patch("src.web_process_backend.build_pm2_start_command", return_value="pm2 start ..."):
|
||||
with patch("src.web_process_backend._shell_out", new=AsyncMock(return_value=(1, "boom"))):
|
||||
with patch.object(backend, "_set_pm2_desired_running", AsyncMock()) as set_desired:
|
||||
with patch.object(backend, "_pm2_save", AsyncMock()):
|
||||
text = asyncio.run(backend.start("youtube", "youtube.py"))
|
||||
|
||||
self.assertEqual(text, "boom")
|
||||
set_desired.assert_not_awaited()
|
||||
|
||||
def test_live_ffmpeg_pids_uses_process_tree_of_target_process(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
base_dir = Path(tmp)
|
||||
backend = WebProcessBackend(base_dir, base_dir / ".pm2" / "logs")
|
||||
backend.ensure_mode = AsyncMock()
|
||||
backend.process_infos = AsyncMock( # type: ignore[method-assign]
|
||||
return_value={"youtube": {"pid": 4321, "process_status": "online"}}
|
||||
)
|
||||
|
||||
with patch("src.web_process_backend.os.getpgid", return_value=6789):
|
||||
with patch("src.web_process_backend._linux_ffmpeg_pids_for_process_tree", return_value=[9876]) as finder:
|
||||
pids = asyncio.run(backend.live_ffmpeg_pids("youtube"))
|
||||
|
||||
self.assertEqual(pids, [9876])
|
||||
finder.assert_called_once_with(4321, root_pgid=6789)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
63
d2ypp2/tests/test_youtube_config_paths.py
Normal file
63
d2ypp2/tests/test_youtube_config_paths.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.youtube_config_paths import resolve_youtube_style_config_paths
|
||||
|
||||
|
||||
class YoutubeConfigPathTests(unittest.TestCase):
|
||||
def test_non_youtube_process_uses_legacy_paths(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
cfg = root / "config"
|
||||
cfg.mkdir(parents=True, exist_ok=True)
|
||||
yt, url = resolve_youtube_style_config_paths(root, "tiktok")
|
||||
self.assertEqual(yt, str(cfg / "youtube.ini"))
|
||||
self.assertEqual(url, str(cfg / "URL_config.ini"))
|
||||
|
||||
def test_youtube_process_uses_legacy_pair_when_only_legacy_exists(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
cfg = root / "config"
|
||||
cfg.mkdir(parents=True, exist_ok=True)
|
||||
(cfg / "youtube.ini").write_text("[youtube]\nkey = legacy\n", encoding="utf-8")
|
||||
(cfg / "URL_config.ini").write_text("https://live.douyin.com/1\n", encoding="utf-8")
|
||||
|
||||
yt, url = resolve_youtube_style_config_paths(root, "youtube")
|
||||
|
||||
self.assertEqual(yt, str(cfg / "youtube.ini"))
|
||||
self.assertEqual(url, str(cfg / "URL_config.ini"))
|
||||
|
||||
def test_youtube_process_falls_back_per_file_when_only_managed_url_exists(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
cfg = root / "config"
|
||||
cfg.mkdir(parents=True, exist_ok=True)
|
||||
(cfg / "youtube.ini").write_text("[youtube]\nkey = legacy\n", encoding="utf-8")
|
||||
managed_url = cfg / "URL_config.youtube.ini"
|
||||
managed_url.write_text("https://live.douyin.com/2\n", encoding="utf-8")
|
||||
|
||||
yt, url = resolve_youtube_style_config_paths(root, "youtube")
|
||||
|
||||
self.assertEqual(yt, str(cfg / "youtube.ini"))
|
||||
self.assertEqual(url, str(managed_url))
|
||||
|
||||
def test_youtube_process_falls_back_per_file_when_only_managed_youtube_ini_exists(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
cfg = root / "config"
|
||||
cfg.mkdir(parents=True, exist_ok=True)
|
||||
managed_yt = cfg / "youtube.youtube.ini"
|
||||
managed_yt.write_text("[youtube]\nkey = managed\n", encoding="utf-8")
|
||||
(cfg / "URL_config.ini").write_text("https://live.douyin.com/3\n", encoding="utf-8")
|
||||
|
||||
yt, url = resolve_youtube_style_config_paths(root, "youtube")
|
||||
|
||||
self.assertEqual(yt, str(managed_yt))
|
||||
self.assertEqual(url, str(cfg / "URL_config.ini"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -9,6 +9,7 @@ from src.youtube_pro_runtime import (
|
||||
managed_url_config_relpath_for_pm2,
|
||||
managed_youtube_ini_relpath_for_pm2,
|
||||
normalize_youtube_pro_channels,
|
||||
validate_youtube_runtime_files,
|
||||
)
|
||||
|
||||
|
||||
@@ -76,6 +77,28 @@ class YoutubeProRuntimeTests(unittest.TestCase):
|
||||
self.assertIn("legacy-key", youtube_ini)
|
||||
self.assertIn("https://live.douyin.com/88888888", url_ini)
|
||||
|
||||
def test_validate_runtime_files_reports_missing_single_lane_inputs(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
config_dir = root / "config"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
errors = validate_youtube_runtime_files(root, config_dir, "youtube", [])
|
||||
|
||||
self.assertEqual(errors, ["youtube: stream key missing", "youtube: source URL missing"])
|
||||
|
||||
def test_validate_runtime_files_accepts_legacy_single_lane_pair(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
config_dir = root / "config"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
(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/123456\n", encoding="utf-8")
|
||||
|
||||
errors = validate_youtube_runtime_files(root, config_dir, "youtube", [])
|
||||
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user