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
207 lines
9.2 KiB
Python
207 lines
9.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Channel picker smoke tests.
|
|
|
|
Run against a locally running backend:
|
|
|
|
backend/main.py &
|
|
.venv/bin/python tests/smoke_channels.py
|
|
|
|
The picker replaced a <select>, so what's checked here is what the <select>
|
|
couldn't do -- show what a channel *is* (favicon, description, tags, group) and
|
|
let it be searched -- plus the two things it could: switching the channel, and
|
|
feeding the command palette its list.
|
|
"""
|
|
import sys
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
BASE = "http://127.0.0.1:5000/"
|
|
SERVER = "https://hottub.spacemoehre.de"
|
|
CHANNEL = "xvideos"
|
|
|
|
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([]));
|
|
}"""
|
|
|
|
ROWS = """() => {
|
|
const list = document.getElementById('channel-picker-list');
|
|
const rows = Array.from(list.querySelectorAll('.channel-row'));
|
|
return {
|
|
sections: Array.from(list.querySelectorAll('.channel-section')).map((s) => s.textContent),
|
|
count: rows.length,
|
|
withNote: rows.filter((r) => !!r.querySelector('.channel-row-note')).length,
|
|
withTags: rows.filter((r) => !!r.querySelector('.channel-tag')).length,
|
|
withIconEl: rows.filter((r) => !!r.querySelector('img.channel-favicon')).length,
|
|
iconsLoaded: rows.filter((r) => {
|
|
const img = r.querySelector('img.channel-favicon');
|
|
return img && img.naturalWidth > 0;
|
|
}).length,
|
|
current: rows.filter((r) => r.classList.contains('is-current'))
|
|
.map((r) => r.dataset.channelId),
|
|
groupRows: rows.filter((r) => r.dataset.channelId.startsWith('group:')).length,
|
|
emptyHidden: document.getElementById('channel-picker-empty').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 boot(page):
|
|
page.goto(BASE, wait_until="domcontentloaded")
|
|
page.evaluate(SEED, [SERVER, CHANNEL])
|
|
page.goto(BASE, wait_until="load")
|
|
try:
|
|
page.wait_for_selector(".video-card", timeout=90000)
|
|
except Exception:
|
|
page.goto(BASE, wait_until="load")
|
|
page.wait_for_selector(".video-card", timeout=90000)
|
|
page.wait_for_timeout(2500)
|
|
|
|
|
|
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",
|
|
])
|
|
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
|
crashes = []
|
|
page.on("pageerror", lambda e: crashes.append(str(e)))
|
|
boot(page)
|
|
|
|
print("\nthe menu says which channel is being read")
|
|
page.evaluate("() => App.ui.toggleDrawer('menu')")
|
|
page.wait_for_timeout(2500)
|
|
trigger = page.evaluate("""() => ({
|
|
name: document.getElementById('channel-trigger-name').textContent,
|
|
note: document.getElementById('channel-trigger-note').textContent,
|
|
iconLoaded: document.getElementById('channel-trigger-icon').naturalWidth > 0,
|
|
})""")
|
|
c.ok("the trigger names the current channel", trigger["name"] == "XVideos", str(trigger))
|
|
c.ok("and says something about it", len(trigger["note"]) > 10, str(trigger))
|
|
c.ok("and shows its favicon", trigger["iconLoaded"], str(trigger))
|
|
|
|
print("\nopening it lists the channels with what the server knows")
|
|
page.click("#channel-picker-btn")
|
|
page.wait_for_timeout(3000)
|
|
rows = page.evaluate(ROWS)
|
|
c.ok("every channel is listed", rows["count"] > 50, str(rows["count"]))
|
|
c.ok("grouped under the server's own headings", len(rows["sections"]) > 1,
|
|
str(rows["sections"][:4]))
|
|
c.ok("each group can be browsed whole", rows["groupRows"] > 1, str(rows["groupRows"]))
|
|
c.ok("rows carry a description", rows["withNote"] > rows["count"] // 2, str(rows))
|
|
c.ok("rows carry tags", rows["withTags"] > rows["count"] // 2, str(rows))
|
|
c.ok("rows carry a favicon", rows["withIconEl"] > rows["count"] // 2, str(rows))
|
|
c.ok("and the favicons actually load", rows["iconsLoaded"] > 10,
|
|
f"{rows['iconsLoaded']} of {rows['withIconEl']} loaded")
|
|
c.ok("the current channel is marked", rows["current"] == [CHANNEL], str(rows["current"]))
|
|
|
|
print("\nsearching narrows it")
|
|
page.fill("#channel-search", "hentai")
|
|
page.wait_for_timeout(600)
|
|
found = page.evaluate(ROWS)
|
|
c.ok("fewer rows than before", 0 < found["count"] < rows["count"], str(found["count"]))
|
|
# A row can match on its own text or on the group it sits under -- the
|
|
# heading is as much a fact about the channel as its description.
|
|
c.ok("every row left standing matches what was typed",
|
|
page.evaluate("""() => {
|
|
let heading = '';
|
|
return Array.from(document.getElementById('channel-picker-list').children)
|
|
.every((node) => {
|
|
if (node.classList.contains('channel-section')) {
|
|
heading = node.textContent.toLowerCase();
|
|
return true;
|
|
}
|
|
const text = (node.textContent + ' ' + node.dataset.channelId + ' ' + heading);
|
|
return text.toLowerCase().includes('hentai');
|
|
});
|
|
}"""))
|
|
# Description and tags are searched too, not just the name.
|
|
page.fill("#channel-search", "leaks")
|
|
page.wait_for_timeout(600)
|
|
by_tag = page.evaluate(ROWS)
|
|
c.ok("a tag finds channels whose name doesn't say it", by_tag["count"] > 0,
|
|
str(by_tag["count"]))
|
|
|
|
page.fill("#channel-search", "zzzznothing")
|
|
page.wait_for_timeout(600)
|
|
nothing = page.evaluate(ROWS)
|
|
c.ok("a search with no hits says so",
|
|
nothing["count"] == 0 and not nothing["emptyHidden"], str(nothing))
|
|
|
|
print("\npicking one switches the feed")
|
|
page.fill("#channel-search", "eporner")
|
|
page.wait_for_timeout(600)
|
|
page.click(".channel-row")
|
|
page.wait_for_timeout(1500)
|
|
c.ok("the picker closes",
|
|
page.evaluate("() => !document.getElementById('channel-picker').classList.contains('open')"))
|
|
session = page.evaluate("() => App.storage.getSession().channel.id")
|
|
c.ok("the session moved to it", session == "eporner", session)
|
|
c.ok("the trigger followed",
|
|
page.evaluate("() => document.getElementById('channel-trigger-name').textContent") == "EPorner")
|
|
page.wait_for_selector(".video-card", timeout=90000)
|
|
c.ok("and the grid reloaded",
|
|
page.evaluate("() => App.state.loadedVideos.length > 0"))
|
|
|
|
print("\nthe keyboard works too")
|
|
page.click("#channel-picker-btn")
|
|
page.wait_for_timeout(1200)
|
|
page.fill("#channel-search", "beeg")
|
|
page.wait_for_timeout(500)
|
|
page.press("#channel-search", "ArrowDown")
|
|
page.press("#channel-search", "ArrowUp")
|
|
page.press("#channel-search", "Enter")
|
|
page.wait_for_timeout(1500)
|
|
c.ok("Enter picks the highlighted row",
|
|
page.evaluate("() => App.storage.getSession().channel.id") == "beeg",
|
|
page.evaluate("() => App.storage.getSession().channel.id"))
|
|
|
|
page.click("#channel-picker-btn")
|
|
page.wait_for_timeout(800)
|
|
page.press("#channel-search", "Escape")
|
|
page.wait_for_timeout(300)
|
|
c.ok("Escape closes it without changing anything",
|
|
page.evaluate("""() => !document.getElementById('channel-picker').classList.contains('open')
|
|
&& App.storage.getSession().channel.id === 'beeg'"""))
|
|
|
|
print("\nthe command palette still offers the channels")
|
|
entries = page.evaluate("() => App.ui.channels.entries().length")
|
|
c.ok("the picker hands out its list", entries > 50, str(entries))
|
|
page.evaluate("() => App.enhance.openPalette()")
|
|
page.wait_for_timeout(400)
|
|
page.fill("#cmdk-input", "Redtube")
|
|
page.wait_for_timeout(400)
|
|
page.press("#cmdk-input", "Enter")
|
|
page.wait_for_timeout(1500)
|
|
c.ok("and choosing one from there switches the channel",
|
|
page.evaluate("() => App.storage.getSession().channel.id") == "redtube",
|
|
page.evaluate("() => App.storage.getSession().channel.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())
|