#!/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 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", ]) 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())