Files
jacuzzi/tests/smoke_info.py
Simon 4b03789ef2 Hand over the whole item from the info panel
The panel is where you go to see exactly what the server said about a
video, and every time that is worth reporting somewhere it has to be
retyped from the screen. Copy JSON hands over the object instead: the
listing item, plus the extractor's payload once that has resolved.

What it copies is built where the rows are built, so the button and the
panel can't disagree about what "this video" means -- and it is the values
rather than the rendering, so a duration of 0 stays 0 instead of becoming
the panel's dash.

navigator.clipboard needs a secure context, which the app has on https and
does not on a plain-http LAN address, so the old execCommand path sits
behind it.

The test drives the real clipboard rather than the function, and the
awkward parts of a real item -- nested http_headers, a tag array, a zero,
an empty string -- are in the fixture for that reason. It and two others
also pick up smoke_grid's lean Chromium flags, without which they get
themselves killed when run alongside everything else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-09-21 15:47:58 +00:00

152 lines
6.1 KiB
Python

#!/usr/bin/env python3
"""Info panel smoke tests: what the panel shows, and what Copy JSON hands over.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_info.py
The listing is served by this script so the expected JSON is known exactly --
including the awkward parts of a real item (nested http_headers, a tag array,
a zero, an empty string).
"""
import json
import os
import sys
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"
ITEM = {
"id": "test:copy-me",
"title": "Summer Col and Damion Dayski",
"url": "https://example.test/proxy/test/post/copy-me.html",
"channel": "test",
"duration": 0,
"isLive": False,
"views": 0,
"uploader": "",
"thumb": "https://cdn.example.test/thumb.png",
"tags": ["sex", "bigtits", "bigass", "hardcore"],
"preview": "https://example.test/proxy/test/post/copy-me.html",
"http_headers": {"Referer": "https://example.test/"},
}
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([]));
}"""
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 main():
c = Checks()
with sync_playwright() as p:
browser = p.chromium.launch(args=[
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
# Lean flags, matching smoke_grid: this runs alongside the app's own
# server and a default Chromium spikes hard enough at startup to get
# itself killed on a constrained box.
"--renderer-process-limit=1",
"--js-flags=--max-old-space-size=512",
])
context = browser.new_context(viewport={"width": 1400, "height": 1000})
# The real clipboard, so the button's own path is what runs.
context.grant_permissions(["clipboard-read", "clipboard-write"])
page = context.new_page()
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
page.route("**/api/videos", lambda route: route.fulfill(
status=200, content_type="application/json",
body=json.dumps({"items": [ITEM], "pageInfo": {"hasNextPage": False}})))
page.route("**/api/resolve*", lambda route: route.fulfill(
status=200, content_type="application/json",
body=json.dumps({"formats": [], "http_headers": {}, "isLive": False, "url": None})))
page.goto(BASE, wait_until="domcontentloaded")
page.evaluate(SEED, [SERVER, CHANNEL])
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=60000)
page.wait_for_timeout(1500)
print("\nthe panel opens with a copy button")
page.evaluate("""() => {
const v = App.state.loadedVideos.find((x) => x.id === 'test:copy-me');
App.ui.openInfo(v);
}""")
page.wait_for_timeout(500)
c.ok("the panel is open",
page.evaluate("() => document.getElementById('info-modal').classList.contains('open')"))
c.ok("the button is visible", page.is_visible("#info-copy"))
c.ok("and reads Copy JSON",
page.inner_text("#info-copy").strip() == "Copy JSON",
page.inner_text("#info-copy"))
print("\nclicking it puts the item on the clipboard")
page.click("#info-copy")
page.wait_for_timeout(600)
clipboard = page.evaluate("() => navigator.clipboard.readText()")
try:
copied = json.loads(clipboard)
except ValueError:
copied = None
c.ok("what landed there is JSON", copied is not None, clipboard[:120])
if copied:
missing = [k for k, v in ITEM.items() if k not in copied]
c.ok("with every field the item has", not missing, str(missing))
c.ok("values intact, nested ones included",
copied.get("http_headers") == ITEM["http_headers"]
and copied.get("tags") == ITEM["tags"], str(copied.get("http_headers")))
c.ok("a zero is a zero, not a dash",
copied.get("duration") == 0 and copied.get("views") == 0, str(copied.get("duration")))
c.ok("and an empty string stays empty",
copied.get("uploader") == "", repr(copied.get("uploader")))
c.ok("it is pretty-printed, not one line", "\n" in clipboard)
c.ok("the button says so", page.inner_text("#info-copy").strip() == "Copied",
page.inner_text("#info-copy"))
print("\nand it goes back to normal")
page.wait_for_timeout(2000)
c.ok("the label resets", page.inner_text("#info-copy").strip() == "Copy JSON",
page.inner_text("#info-copy"))
print("\nthe resolved payload rides along once it lands")
page.evaluate("""() => {
const v = App.state.loadedVideos.find((x) => x.id === 'test:copy-me');
App.ui.showInfo(v, { info: { extractorField: 'yes', formats: [] } });
}""")
page.wait_for_timeout(300)
page.click("#info-copy")
page.wait_for_timeout(600)
second = json.loads(page.evaluate("() => navigator.clipboard.readText()"))
c.ok("the extractor's fields are in there too",
(second.get("extractor") or {}).get("extractorField") == "yes",
str(list(second.keys())))
c.ok("and the item's own are still there", second.get("id") == ITEM["id"])
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
browser.close()
print(f"\n{'OK' if not c.failed else 'FAILED'}: {c.failed} check(s) failed")
return 1 if c.failed else 0
sys.exit(main())