255 lines
16 KiB
Python
255 lines
16 KiB
Python
import json
|
|
import os
|
|
from pathlib import Path
|
|
import plistlib
|
|
import selectors
|
|
import shutil
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import uuid
|
|
|
|
from streaming import StreamingServer
|
|
|
|
|
|
def check_cli_instances(root, app, fixtures):
|
|
cli = root / "terebi"
|
|
shutil.copy2(app_source.parent / "terebi", cli)
|
|
extractor = shutil.which("yt-dlp")
|
|
runtime = shutil.which("deno") or shutil.which("node")
|
|
canonical = "https://www.youtube.com/watch?v=aqz-KE-bpKQ"
|
|
|
|
config = root / "xdg/terebi/config.toml"
|
|
environment = dict(os.environ, XDG_CONFIG_HOME=str(config.parent.parent))
|
|
|
|
def invoke(arguments, expected_code=0):
|
|
result = subprocess.run([str(cli), *arguments], capture_output=True, text=True, timeout=20,
|
|
env=environment)
|
|
if result.returncode != expected_code:
|
|
raise RuntimeError(f"CLI {arguments} returned {result.returncode}, expected {expected_code}; {result.stdout}{result.stderr}")
|
|
return result
|
|
|
|
def wait_states(count, ready, message):
|
|
deadline = time.monotonic() + 30
|
|
states = {}
|
|
while time.monotonic() < deadline:
|
|
states = {int(path.stem.split("-")[1]): json.loads(path.read_text())
|
|
for path in root.glob("state-*.json")}
|
|
complete = len(states) == count and all(
|
|
set(state["pids"]) == set(states) and state["windows"] == 1 for state in states.values())
|
|
if any(state["error"] for state in states.values()):
|
|
raise RuntimeError(f"{message}: native playback error: {states}")
|
|
if complete and ready(states):
|
|
return states
|
|
time.sleep(0.02) # Infrastructure deadline; assertions use native events/PTS, not wall dates.
|
|
raise RuntimeError(f"{message}: native state deadline: {states}")
|
|
def settings(state):
|
|
return (state["showBufferInfo"], state["automaticQuality"], state["snapToEdges"])
|
|
|
|
def input_request(pid, count, ready, message, events=None, media=None):
|
|
request = {"id": uuid.uuid4().hex, "events": events or []}
|
|
if media is not None:
|
|
request["media"] = str(media)
|
|
path = root / f"keys-{pid}.json"
|
|
pending = path.with_suffix(".tmp")
|
|
pending.write_text(json.dumps(request))
|
|
pending.replace(path)
|
|
return wait_states(count, lambda states: states[pid]["requestID"] == request["id"] and ready(states[pid]),
|
|
message)
|
|
|
|
with StreamingServer(fixtures) as server:
|
|
if extractor and runtime:
|
|
metadata = root / "youtube.json"
|
|
metadata.write_text(json.dumps({
|
|
"id": "aqz-KE-bpKQ", "title": "Independent YouTube fixture", "duration": 120,
|
|
"is_live": False, "webpage_url": canonical, "extractor": "youtube",
|
|
"extractor_key": "Youtube", "formats": [{
|
|
"format_id": "180", "height": 180, "width": 320,
|
|
"url": server.base_url + "/media/cache.mp4", "protocol": "http", "ext": "mp4",
|
|
"vcodec": "mpeg4", "acodec": "aac", "dynamic_range": "SDR",
|
|
"http_headers": {"X-Terebi-Fixture": "combined"}}]}))
|
|
wrapper = root / "yt-dlp"
|
|
wrapper.write_text("#!/bin/bash\nset -eu\nargs=(\"$@\")\nexec " + shlex.quote(extractor)
|
|
+ " --load-info-json " + shlex.quote(str(metadata))
|
|
+ ' "${args[@]:0:${#args[@]}-2}"\n')
|
|
wrapper.chmod(0o755)
|
|
runtime_settings = {"directory": str(root), "path": str(root) + os.pathsep + os.environ.get("PATH", "")}
|
|
(app / "Contents/Resources/cli-check.json").write_text(json.dumps(runtime_settings))
|
|
movie = fixtures / "cache.mp4"
|
|
audio = root / "audio with spaces.wav"
|
|
shutil.copy2(fixtures / "audio.wav", audio)
|
|
|
|
invoke([str(movie)])
|
|
first = wait_states(1, lambda states: all(state["media"] == movie.name and state["status"] == 3
|
|
and state["position"] > 0 and state["hasVideo"] for state in states.values()),
|
|
"cold default CLI opens one actual player window")
|
|
first_pid = next(iter(first))
|
|
if settings(first[first_pid]) != (False, False, True) or config.parent.exists():
|
|
raise RuntimeError(f"missing config must use defaults without creating a file: {first}")
|
|
invoke([])
|
|
wait_states(1, lambda states: states[first_pid]["media"] == movie.name,
|
|
"warm default CLI reuses the existing process without replacing media")
|
|
input_request(first_pid, 1, lambda state: state["status"] == 2, "default Space pauses once",
|
|
events=[{"key": " ", "code": 49}])
|
|
input_request(first_pid, 1, lambda state: state["status"] == 2, "held Space cannot oscillate",
|
|
events=[{"key": " ", "code": 49, "repeat": True}])
|
|
input_request(first_pid, 1, lambda state: state["status"] == 3, "same Space resumes",
|
|
events=[{"key": " ", "code": 49}])
|
|
input_request(first_pid, 1, lambda state: state["status"] == 3, "rapid pause/play preserves latest intent",
|
|
events=[{"key": " ", "code": 49}, {"key": " ", "code": 49}])
|
|
input_request(first_pid, 1, lambda state: state["compact"], "P enters the same compact window",
|
|
events=[{"key": "p", "code": 35}])
|
|
input_request(first_pid, 1, lambda state: state["compact"], "held P cannot exit compact mode",
|
|
events=[{"key": "p", "code": 35, "repeat": True}])
|
|
input_request(first_pid, 1, lambda state: not state["compact"], "same P restores normal mode",
|
|
events=[{"key": "p", "code": 35}])
|
|
|
|
config.parent.mkdir(parents=True)
|
|
config_text = ('automatic_quality = true\n[ui]\n"buffer_info" = true # optional display\n'
|
|
'compact_snap_to_edges = false\n[keys]\nplay = ["cmd+8", "cmd+x", "x"]\n'
|
|
'pause = ["cmd+8", "cmd+x", "x"]\nshow_buffer_info = "b"\nhide_buffer_info = "b"\n')
|
|
config.write_text(config_text)
|
|
invoke(["--new-instance"])
|
|
second = wait_states(2, lambda states: any(state["media"] == "" and state["status"] == 0
|
|
and settings(state) == (True, True, False)
|
|
for pid, state in states.items() if pid != first_pid),
|
|
"empty independent CLI creates exactly one extra player")
|
|
empty_pid = next(pid for pid in second if pid != first_pid)
|
|
if settings(second[first_pid]) != (False, False, True) or config.read_text() != config_text:
|
|
raise RuntimeError(f"config loading changed an existing session or wrote the file: {second}")
|
|
input_request(empty_pid, 2, lambda state: state["status"] == 3, "configured player loads real video",
|
|
media=movie)
|
|
input_request(empty_pid, 2, lambda state: state["status"] == 2, "configured X pauses actual playback",
|
|
events=[{"key": "x"}])
|
|
input_request(empty_pid, 2, lambda state: state["status"] == 2, "old K binding is removed",
|
|
events=[{"key": "k", "code": 40}])
|
|
input_request(empty_pid, 2, lambda state: state["status"] == 2 and state["editorText"] == "x",
|
|
"typing X reaches the editor without controlling playback",
|
|
events=[{"key": "x", "editing": True}])
|
|
input_request(empty_pid, 2, lambda state: state["status"] == 2, "configured Cmd+8 leaves text editing alone",
|
|
events=[{"key": "8", "code": 28, "flags": 1 << 20, "editing": True}])
|
|
input_request(empty_pid, 2, lambda state: state["status"] == 3, "configured Cmd+X resumes actual playback",
|
|
events=[{"key": "x", "flags": 1 << 20}])
|
|
input_request(empty_pid, 2, lambda state: state["status"] == 3, "held modified playback chord does not toggle",
|
|
events=[{"key": "x", "flags": 1 << 20, "repeat": True}])
|
|
input_request(empty_pid, 2, lambda state: state["status"] == 2, "same Cmd+X pauses actual playback",
|
|
events=[{"key": "x", "flags": 1 << 20}])
|
|
input_request(empty_pid, 2, lambda state: state["status"] == 3, "configured native menu Cmd+8 resumes",
|
|
events=[{"key": "8", "code": 28, "flags": 1 << 20, "menu": True}])
|
|
input_request(empty_pid, 2, lambda state: state["status"] == 2, "native Pause item applies current state",
|
|
events=[{"select": "Pause"}])
|
|
input_request(empty_pid, 2, lambda state: state["status"] == 3, "native Play item resumes the same player",
|
|
events=[{"select": "Play"}])
|
|
input_request(empty_pid, 2, lambda state: not state["showBufferInfo"], "paired B hides buffer info",
|
|
events=[{"key": "b", "code": 11}])
|
|
input_request(empty_pid, 2, lambda state: state["showBufferInfo"], "same B shows buffer info",
|
|
events=[{"key": "b", "code": 11}])
|
|
input_request(empty_pid, 2, lambda state: state["status"] == 0, "configured close-file menu restores empty player",
|
|
events=[{"key": "w", "code": 13, "flags": (1 << 20) | (1 << 17), "menu": True}])
|
|
invoke(["--new-instance", str(audio)])
|
|
third = wait_states(3, lambda states: any(state["media"] == audio.name and state["status"] == 3
|
|
and state["hasAudio"] and not state["hasVideo"]
|
|
for pid, state in states.items() if pid not in second),
|
|
"independent local media reaches only the freshly launched process")
|
|
if third[first_pid]["media"] != movie.name or third[first_pid]["status"] != 3 or third[empty_pid]["media"]:
|
|
raise RuntimeError(f"independent launch disturbed an existing player: {third}")
|
|
config.write_text("[ui]\nbuffer_info = [\n")
|
|
failure = invoke(["--new-instance"], expected_code=78)
|
|
if str(config) not in failure.stderr:
|
|
raise RuntimeError(f"configuration failure omitted its path: {failure.stderr}")
|
|
invoke(["--no-config"])
|
|
unchanged = wait_states(3, lambda _: True, "bypass reuses an existing process without resetting its settings")
|
|
if any(settings(unchanged[pid]) != settings(third[pid]) for pid in third):
|
|
raise RuntimeError(f"warm bypass changed an existing session: {third}; {unchanged}")
|
|
invoke(["--no-config", "--new-instance", str(movie)])
|
|
bypass = wait_states(4, lambda states: any(state["media"] == movie.name and state["status"] == 3
|
|
and settings(state) == (False, False, True)
|
|
for pid, state in states.items() if pid not in third),
|
|
"fresh bypass skips invalid config and plays with built-in settings")
|
|
invoke(["--no-config", "--new-instance", "--help"])
|
|
wait_states(4, lambda _: True, "help skips invalid config without starting another process")
|
|
config.write_text(config_text)
|
|
count = 4
|
|
if extractor and runtime:
|
|
invoke(["--new-instance", "--url", canonical])
|
|
fourth = wait_states(5, lambda states: any(
|
|
state["media"] == "Independent YouTube fixture" and state["status"] == 3
|
|
and state["position"] > 0 and state["hasAudio"] and state["hasVideo"]
|
|
and settings(state) == (True, True, False)
|
|
for pid, state in states.items() if pid not in bypass),
|
|
"independent YouTube playback reaches only one new registered process")
|
|
if fourth[first_pid]["media"] != movie.name or fourth[first_pid]["status"] != 3 or fourth[empty_pid]["media"]:
|
|
raise RuntimeError(f"independent YouTube launch disturbed existing playback: {fourth}")
|
|
count = 5
|
|
else:
|
|
print("SKIP independent YouTube playback: yt-dlp and a supported JS runtime are required")
|
|
|
|
before = wait_states(count, lambda _: True, "all independent processes remain registered")
|
|
invoke(["--new-instance", "--help"])
|
|
wait_states(count, lambda _: True, "help never creates an extra application")
|
|
replacement = root / "replacement.mp4"
|
|
shutil.copy2(fixtures / "av.mp4", replacement)
|
|
invoke([str(replacement)])
|
|
after = wait_states(count, lambda states: sum(state["media"] == replacement.name
|
|
for state in states.values()) == 1,
|
|
"default media delivery still reuses exactly one existing process")
|
|
changed = [pid for pid in before if before[pid]["media"] != after[pid]["media"]]
|
|
if len(changed) != 1:
|
|
raise RuntimeError(f"default media open changed multiple players: {before}; {after}")
|
|
if config.read_text() != config_text:
|
|
raise RuntimeError("native app startup modified config.toml")
|
|
print("PASS actual CLI instances: native startup config/defaults/invalid-file error/bypass/read-only settings, cold/default reuse, independent empty/local/YouTube, one window per PID, targeted media, original playback retained, help, warm replacement")
|
|
|
|
|
|
|
|
app_source, library, fixtures = map(lambda path: Path(path).resolve(), sys.argv[1:])
|
|
with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
|
|
root = Path(directory)
|
|
app = root / "terebi.app"
|
|
shutil.copytree(app_source, app)
|
|
identifier = "local.personal.terebi.startup-check." + uuid.uuid4().hex
|
|
info_path = app / "Contents/Info.plist"
|
|
info = plistlib.loads(info_path.read_bytes())
|
|
info["CFBundleIdentifier"] = identifier
|
|
info_path.write_bytes(plistlib.dumps(info))
|
|
shutil.copy2(library, app / "Contents/Frameworks/libterebi-player.dylib")
|
|
pipe = root / "result.fifo"
|
|
os.mkfifo(pipe)
|
|
descriptor = os.open(pipe, os.O_RDONLY | os.O_NONBLOCK)
|
|
launch = None
|
|
try:
|
|
with selectors.DefaultSelector() as selector:
|
|
selector.register(descriptor, selectors.EVENT_READ)
|
|
launch = subprocess.Popen(
|
|
["open", "-g", "-n", "-W", str(app), "--args", str(pipe), str(fixtures)],
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
)
|
|
if not selector.select(timeout=30):
|
|
raise RuntimeError("packaged app did not report its startup state")
|
|
result = json.loads(os.read(descriptor, 4096))
|
|
stdout, stderr = launch.communicate(timeout=10)
|
|
if launch.returncode != 0 or not result["passed"] or result["bundleIdentifier"] != identifier:
|
|
raise RuntimeError(f"packaged startup failed: {result}; {stdout}{stderr}")
|
|
print("PASS packaged bootstrap: registered app PID/identity, in-process player load, actual playback and Now Playing")
|
|
check_cli_instances(root, app, fixtures)
|
|
finally:
|
|
os.close(descriptor)
|
|
subprocess.run(
|
|
["xcrun", "swift", "-e",
|
|
'import AppKit; for app in NSWorkspace.shared.runningApplications { '
|
|
'if app.bundleIdentifier == CommandLine.arguments[1] { app.forceTerminate() } }',
|
|
identifier],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
|
)
|
|
if launch is not None and launch.poll() is None:
|
|
launch.terminate()
|
|
launch.communicate(timeout=5)
|
|
subprocess.run(
|
|
["/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister", "-u", str(app)],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
|
)
|
|
for domain in [identifier, identifier + ".history"]:
|
|
subprocess.run(["defaults", "delete", domain], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
|