#!/usr/bin/env python3 """Opening a video: load first, show the player once there's something to show. Run against a locally running backend: backend/main.py & .venv/bin/python tests/smoke_open.py Both the listing and the media are served by this script, so "the stream is slow" and "the stream is broken" are conditions the test can actually create rather than wait for. """ import base64 import http.server import json import os import socketserver import sys import threading import time from playwright.sync_api import sync_playwright BASE = os.environ.get("JACUZZI_BASE", "http://127.0.0.1:5000/") SERVER = "https://hottubapp.io" CHANNEL = "xvideos" # One second of black, 64x64, h264 -- a real file, because the point of the # test is that the browser decodes a frame from it. MP4_B64 = ( "AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAOxbW9vdgAAAGxtdmhkAAAAAAAAAAAA" "AAAAAAAD6AAAA+gAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAA" "AABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAtx0cmFrAAAAXHRraGQAAAADAAAA" "AAAAAAAAAAABAAAAAAAAA+gAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAA" "AAAAAAAAAABAAAAAAEAAAABAAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAPoAAAIAAABAAAA" "AAJUbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAoAAAAKABVxAAAAAAALWhkbHIAAAAAAAAAAHZp" "ZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAAB/21pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAA" "ACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAb9zdGJsAAAAv3N0c2QAAAAAAAAA" "AQAAAK9hdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAEAAQABIAAAASAAAAAAAAAABFUxhdmM2" "MS4xOS4xMDEgbGlieDI2NAAAAAAAAAAAAAAAGP//AAAANWF2Y0MBZAAK/+EAGGdkAAqs2UQmwEQA" "AAMABAAAAwBQPEiWWAEABmjr48siwP34+AAAAAAQcGFzcAAAAAEAAAABAAAAFGJ0cnQAAAAAAAAa" "uAAAAAAAAAAYc3R0cwAAAAAAAAABAAAACgAABAAAAAAUc3RzcwAAAAAAAAABAAAAAQAAAGBjdHRz" "AAAAAAAAAAoAAAABAAAIAAAAAAEAABQAAAAAAQAACAAAAAABAAAAAAAAAAEAAAQAAAAAAQAAFAAA" "AAABAAAIAAAAAAEAAAAAAAAAAQAABAAAAAABAAAIAAAAABxzdHNjAAAAAAAAAAEAAAABAAAACgAA" "AAEAAAA8c3RzegAAAAAAAAAAAAAACgAAAtcAAAAOAAAADAAAAAwAAAAMAAAAFAAAAA4AAAAMAAAA" "DAAAABQAAAAUc3RjbwAAAAAAAAABAAAD4QAAAGF1ZHRhAAAAWW1ldGEAAAAAAAAAIWhkbHIAAAAA" "AAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALGlsc3QAAAAkqXRvbwAAABxkYXRhAAAAAQAAAABMYXZm" "NjEuNy4xMDAAAAAIZnJlZQAAA19tZGF0AAACrgYF//+q3EXpvebZSLeWLNgg2SPu73gyNjQgLSBj" "b3JlIDE2NCByMzEwOCAzMWUxOWY5IC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0" "IDIwMDMtMjAyMyAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6" "IGNhYmFjPTEgcmVmPTMgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzoweDExMyBtZT1oZXggc3Vi" "bWU9NyBwc3k9MSBwc3lfcmQ9MS4wMDowLjAwIG1peGVkX3JlZj0xIG1lX3JhbmdlPTE2IGNocm9t" "YV9tZT0xIHRyZWxsaXM9MSA4eDhkY3Q9MSBjcW09MCBkZWFkem9uZT0yMSwxMSBmYXN0X3Bza2lw" "PTEgY2hyb21hX3FwX29mZnNldD0tMiB0aHJlYWRzPTIgbG9va2FoZWFkX3RocmVhZHM9MSBzbGlj" "ZWRfdGhyZWFkcz0wIG5yPTAgZGVjaW1hdGU9MSBpbnRlcmxhY2VkPTAgYmx1cmF5X2NvbXBhdD0w" "IGNvbnN0cmFpbmVkX2ludHJhPTAgYmZyYW1lcz0zIGJfcHlyYW1pZD0yIGJfYWRhcHQ9MSBiX2Jp" "YXM9MCBkaXJlY3Q9MSB3ZWlnaHRiPTEgb3Blbl9nb3A9MCB3ZWlnaHRwPTIga2V5aW50PTI1MCBr" "ZXlpbnRfbWluPTEwIHNjZW5lY3V0PTQwIGludHJhX3JlZnJlc2g9MCByY19sb29rYWhlYWQ9NDAg" "cmM9Y3JmIG1idHJlZT0xIGNyZj0yMy4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0" "ZXA9NCBpcF9yYXRpbz0xLjQwIGFxPTE6MS4wMACAAAAAIWWIhAAR//73iB8yy2+catdyEeesVP1G" "Ixltc+dmuhineQAAAApBmiRsQQ/+qlfeAAAACEGeQniHfwW9AAAACAGeYXRDfwd8AAAACAGeY2pD" "fwd9AAAAEEGaaEmoQWiZTAh3//6pnTUAAAAKQZ6GRREsO/8FvQAAAAgBnqV0Q38HfQAAAAgBnqdq" "Q38HfAAAABBBmqlJqEFsmUwIb//+p4+I" ) def make_server(delay_holder): """Serves the fixture video, optionally after a delay, plus a dead URL.""" class Handler(http.server.BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def log_message(self, *args): pass def do_GET(self): if self.path.startswith("/slow") or self.path.startswith("/fast"): time.sleep(delay_holder["slow"] if self.path.startswith("/slow") else 0) body = base64.b64decode(MP4_B64) self.send_response(200) self.send_header("Content-Type", "video/mp4") self.send_header("Content-Length", str(len(body))) self.send_header("Accept-Ranges", "none") self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() self.wfile.write(body) return self.send_response(404) self.send_header("Content-Length", "0") self.end_headers() def do_HEAD(self): self.send_response(200) self.send_header("Content-Type", "video/mp4") self.send_header("Content-Length", "0") self.end_headers() httpd = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Handler) httpd.daemon_threads = True threading.Thread(target=httpd.serve_forever, daemon=True).start() return httpd SEED = """([server, channel]) => { localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] })); localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } })); localStorage.removeItem('session'); localStorage.setItem('favorites', JSON.stringify([])); }""" STATE = """() => { const player = document.getElementById('custom-player'); const video = player.querySelector('.cp-video'); const card = document.querySelector('#video-grid .video-card'); return { shown: player.classList.contains('open'), preloading: player.classList.contains('is-preloading'), active: !!(App.player.isActive && App.player.isActive()), cardBusy: !!(card && card.classList.contains('is-loading')), src: video ? (video.currentSrc || video.getAttribute('src') || '') : '', readyState: video ? video.readyState : -1, muted: video ? video.muted : null, bodyOverflow: document.body.style.overflow, error: !!(player.querySelector('.cp-error') && !player.querySelector('.cp-error').hidden), }; }""" class Checks: def __init__(self): self.failed = 0 def ok(self, label, condition, detail=""): mark = "PASS" if condition else "FAIL" if not condition: self.failed += 1 print(f" [{mark}] {label}" + (f" -- {detail}" if detail and not condition else "")) def item(video_url, ident="test:0"): """One listing item whose formats are already resolved, so opening it goes straight to playback rather than through a resolve first.""" return { "id": ident, "title": "A video", "url": "https://example.test/watch", "channel": "test", "duration": 1, "thumb": "", "tags": [], "meta": { "url": video_url, "http_headers": {}, "isLive": False, "formats": [{"url": video_url, "ext": "mp4", "height": 240, "protocol": "https", "vcodec": "avc1", "acodec": "none"}], }, } def open_first(page): """Click the first card. Forced, because the hover preview parks a