#!/usr/bin/env python3 """A whole search -- source, channel, filters, query -- can be kept and come back to. Run against a locally running backend: backend/main.py & .venv/bin/python tests/smoke_saved_searches.py Both the status and the listing are fixtures, so the test owns what "new" means: it changes the ids the listing hands back and then asks the client how many of them it has never shown. """ import json import sys from playwright.sync_api import sync_playwright BASE = "http://127.0.0.1:5000/" SERVER = "https://fixture.test" CHANNEL = "clips" STATUS = { "online": True, "channels": [{ "id": CHANNEL, "name": "Fixture Clips", "description": "A channel that only exists for this test.", "options": [{ "id": "sort", "title": "Sort", "options": [ {"id": "latest", "title": "Latest"}, {"id": "views", "title": "Most viewed"}, ], }], }], } SEED = """([server, channel]) => { localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] })); localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } })); localStorage.removeItem('session'); localStorage.removeItem('searchHistory'); localStorage.removeItem('savedSearches'); }""" SAVED = """() => JSON.parse(localStorage.getItem('savedSearches') || '[]')""" BADGE = """() => { const badge = document.getElementById('saved-searches-badge'); return { hidden: badge.hidden, text: badge.textContent }; }""" ROWS = """() => Array.from(document.querySelectorAll('#saved-panel .saved-row')).map((row) => ({ name: row.querySelector('.saved-row-name') ? row.querySelector('.saved-row-name').textContent : '', count: row.querySelector('.saved-count').textContent, quiet: row.querySelector('.saved-count').classList.contains('is-quiet'), meta: row.querySelector('.saved-row-meta').textContent, stamp: row.querySelector('.saved-row-stamp').textContent }))""" READ_BTN = """() => { const button = document.getElementById('saved-read-btn'); return { disabled: button.disabled, text: button.textContent }; }""" IS_SAVED = """() => document.getElementById('search-save-btn').classList.contains('is-saved')""" PANEL_OPEN = """() => document.getElementById('saved-panel').classList.contains('open')""" SESSION = """() => { const s = JSON.parse(localStorage.getItem('session')); return { channel: s.channel.id, sort: s.options.sort ? s.options.sort.id : null }; }""" 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() asked = [] # Which ids the listing hands back. Shifting this window is how the test # says "three new videos were uploaded". offset = {"at": 0, "fail": False} # How many videos a page comes back with, whatever was asked for: real # sources cut pages their own way and send what they send. size = {"n": 12} def items(query): start = offset["at"] return [{"id": f"vid-{i}", "title": f"{query or 'anything'} {i}", "url": f"https://fixture.test/v{i}.mp4", "thumb": "", "channel": CHANNEL, "duration": 1, "isLive": False, "tags": []} for i in range(start, start + size["n"])] with sync_playwright() as p: browser = p.chromium.launch(args=[ "--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu", "--renderer-process-limit=1", "--js-flags=--max-old-space-size=512", ]) page = browser.new_page(viewport={"width": 1180, "height": 920}) crashes = [] page.on("pageerror", lambda e: crashes.append(str(e))) def status(route): route.fulfill(status=200, content_type="application/json", body=json.dumps(STATUS)) def listing(route): try: body = json.loads(route.request.post_data or "{}") except Exception: body = {} asked.append(body) if offset["fail"]: route.fulfill(status=500, content_type="application/json", body="{}") return route.fulfill(status=200, content_type="application/json", body=json.dumps( {"items": items(body.get("query", "")), "pageInfo": {"hasNextPage": False}})) page.route("**/api/status", status) page.route("**/api/videos", listing) page.goto(BASE, wait_until="domcontentloaded") page.evaluate(SEED, [SERVER, CHANNEL]) page.goto(BASE, wait_until="load") page.wait_for_selector(".video-card", timeout=30000) page.wait_for_timeout(400) print("\nthe feature is there at all") c.ok("the module loaded", page.evaluate("() => typeof App.savedSearches.saveCurrent") == 'function') c.ok("with nothing saved, the header count is out", page.evaluate(BADGE)["hidden"] is True) c.ok("and the bookmark is unlit", page.evaluate(IS_SAVED) is False) print("\nkeeping the search that is on screen") page.fill("#search-input", "") page.type("#search-input", "sauna", delay=30) page.wait_for_timeout(900) page.click("#search-save-btn") page.wait_for_timeout(200) saved = page.evaluate(SAVED) c.ok("one search was kept", len(saved) == 1, str(saved)) entry = saved[0] if saved else {} c.ok("with the words in it", entry.get("query") == "sauna", str(entry.get("query"))) c.ok("the channel it was run against", entry.get("channelId") == CHANNEL, str(entry.get("channelId"))) c.ok("the source", entry.get("server") == SERVER, str(entry.get("server"))) c.ok("and the filters as they stood", entry.get("options") == {"sort": "latest"}, str(entry.get("options"))) c.ok("it is named after the search", entry.get("name") == "sauna", str(entry.get("name"))) c.ok("what is on screen counts as seen", len(entry.get("seenIds") or []) == 12, str(len(entry.get("seenIds") or []))) c.ok("so nothing is new yet", entry.get("newCount") == 0 and page.evaluate(BADGE)["hidden"] is True) c.ok("and the bookmark is lit", page.evaluate(IS_SAVED) is True) print("\nthe bookmark is about this search, not just this channel") page.fill("#search-input", "") page.type("#search-input", "pool", delay=30) page.wait_for_timeout(300) c.ok("typing something else puts it out", page.evaluate(IS_SAVED) is False) page.fill("#search-input", "") page.type("#search-input", "sauna", delay=30) page.wait_for_timeout(300) c.ok("coming back to it lights it again", page.evaluate(IS_SAVED) is True) print("\nand a filter is part of it too") page.evaluate("""() => { const s = JSON.parse(localStorage.getItem('session')); s.options.sort = { id: 'views', title: 'Most viewed' }; localStorage.setItem('session', JSON.stringify(s)); App.ui.renderMenu(); }""") page.wait_for_timeout(200) c.ok("a different sort is a different search", page.evaluate(IS_SAVED) is False) print("\nthree videos turn up while nobody is looking") offset["at"] = 3 asked.clear() page.evaluate("() => App.savedSearches.check({ force: true })") page.wait_for_timeout(900) saved = page.evaluate(SAVED) c.ok("the check asked for the saved search, not what is on screen", any(b.get("query") == "sauna" and b.get("sort") == "latest" for b in asked), str(asked)) c.ok("three of them are new", saved[0].get("newCount") == 3, str(saved[0].get("newCount"))) badge = page.evaluate(BADGE) c.ok("the header says so", badge["hidden"] is False and badge["text"] == "3", str(badge)) print("\na busy day") offset["at"] = 40 page.evaluate("() => App.savedSearches.check({ force: true })") page.wait_for_timeout(900) saved = page.evaluate(SAVED) badge = page.evaluate(BADGE) c.ok("every one of them is new", saved[0].get("newCount") == 12, str(saved[0].get("newCount"))) c.ok("the badge stops counting at 9+", badge["text"] == "9+", str(badge)) print("\nonce a day, not once a page view") asked.clear() page.evaluate("() => App.savedSearches.check()") page.wait_for_timeout(500) c.ok("a search checked a moment ago is left alone", asked == [], str(asked)) page.evaluate("""() => { const all = JSON.parse(localStorage.getItem('savedSearches')); all[0].lastCheckedAt = Date.now() - 2 * 24 * 60 * 60 * 1000; localStorage.setItem('savedSearches', JSON.stringify(all)); }""") page.evaluate("() => App.savedSearches.check()") page.wait_for_timeout(900) c.ok("one last checked two days ago is asked about", any(b.get("query") == "sauna" for b in asked), str(asked)) print("\nthe list") page.click("#saved-searches-btn") page.wait_for_timeout(300) rows = page.evaluate(ROWS) c.ok("it opens", page.evaluate(PANEL_OPEN) is True) c.ok("with the saved search in it", len(rows) == 1 and rows[0]["name"] == "sauna", str(rows)) c.ok("carrying its count", rows[0]["count"] == "9+" and rows[0]["quiet"] is False, str(rows[0])) c.ok("and saying where it points", "Fixture Clips" in rows[0]["meta"] and "Latest" in rows[0]["meta"] and "sauna" in rows[0]["meta"], rows[0]["meta"]) print("\ntapping it puts the whole search back") asked.clear() page.click("#saved-panel .saved-row-main") page.wait_for_timeout(1200) c.ok("the list closes", page.evaluate(PANEL_OPEN) is False) c.ok("the words are back in the box", page.evaluate("() => document.getElementById('search-input').value") == "sauna") session = page.evaluate(SESSION) c.ok("the filter is back as it was", session == {"channel": CHANNEL, "sort": "latest"}, str(session)) c.ok("and that is what was fetched", any(b.get("query") == "sauna" and b.get("sort") == "latest" for b in asked), str(asked)) saved = page.evaluate(SAVED) c.ok("opening it is viewing it, so the count goes out", saved[0].get("newCount") == 0 and page.evaluate(BADGE)["hidden"] is True, str(saved[0])) c.ok("and what it showed is the new mark", "vid-40" in (saved[0].get("seenIds") or []), str((saved[0].get("seenIds") or [])[:3])) c.ok("so the bookmark is lit again", page.evaluate(IS_SAVED) is True) print("\nnothing new, said quietly") page.click("#saved-searches-btn") page.wait_for_timeout(300) rows = page.evaluate(ROWS) c.ok("no number where there is no news", rows[0]["quiet"] is True and "nothing new" in rows[0]["stamp"].lower(), str(rows[0])) print("\nrenaming one") page.click("#saved-panel .saved-tool[data-act='rename']") page.wait_for_timeout(250) page.fill("#saved-panel .saved-rename", "Sauna, newest first") page.keyboard.press("Enter") page.wait_for_timeout(300) saved = page.evaluate(SAVED) c.ok("the new name is kept", saved[0].get("name") == "Sauna, newest first", str(saved[0].get("name"))) c.ok("and shown", page.evaluate(ROWS)[0]["name"] == "Sauna, newest first") print("\nsaving a second one from the list itself") page.evaluate("() => App.ui.savedSearches.close()") page.fill("#search-input", "") page.type("#search-input", "steam", delay=30) page.wait_for_timeout(900) page.click("#saved-searches-btn") page.wait_for_timeout(250) page.click("#saved-add-btn") page.wait_for_timeout(300) saved = page.evaluate(SAVED) c.ok("there are two now", len(saved) == 2, str([s.get("name") for s in saved])) c.ok("the new one is first and named after its query", saved[0].get("query") == "steam", str(saved[0].get("name"))) c.ok("and it opens ready to be named", page.evaluate("() => !!document.querySelector('#saved-panel .saved-rename')")) page.keyboard.press("Escape") page.wait_for_timeout(200) print("\nremoving one asks first") page.click("#saved-panel .saved-row:nth-of-type(1) .saved-tool[data-act='delete']") page.wait_for_timeout(250) c.ok("the row asks", page.evaluate( "() => !!document.querySelector('#saved-panel .saved-confirm')")) page.click("#saved-panel .saved-confirm-btn:not(.is-danger)") page.wait_for_timeout(250) c.ok("keeping keeps it", len(page.evaluate(SAVED)) == 2) page.click("#saved-panel .saved-row:nth-of-type(1) .saved-tool[data-act='delete']") page.wait_for_timeout(250) page.click("#saved-panel .saved-confirm-btn.is-danger") page.wait_for_timeout(250) saved = page.evaluate(SAVED) c.ok("removing removes it", len(saved) == 1 and saved[0].get("query") == "sauna", str(saved)) print("\na source that does not answer") page.evaluate("""() => { const all = JSON.parse(localStorage.getItem('savedSearches')); all[0].newCount = 0; localStorage.setItem('savedSearches', JSON.stringify(all)); }""") offset["fail"] = True page.evaluate("() => App.savedSearches.check({ force: true })") page.wait_for_timeout(1200) saved = page.evaluate(SAVED) c.ok("is not a search with nothing new in it", saved[0].get("unreachable") is True and saved[0].get("newCount") == 0, str(saved[0])) rows = page.evaluate(ROWS) c.ok("and the row says as much", "could not be checked" in rows[0]["stamp"].lower(), str(rows[0]["stamp"])) offset["fail"] = False print("\nacross a reload") page.evaluate("""() => { const all = JSON.parse(localStorage.getItem('savedSearches')); all[0].newCount = 4; all[0].unreachable = false; all[0].lastCheckedAt = Date.now(); localStorage.setItem('savedSearches', JSON.stringify(all)); }""") page.goto(BASE, wait_until="load") page.wait_for_selector(".video-card", timeout=30000) page.wait_for_timeout(600) badge = page.evaluate(BADGE) c.ok("the count is on screen straight from storage", badge["hidden"] is False and badge["text"] == "4", str(badge)) c.ok("the search survived too", len(page.evaluate(SAVED)) == 1) print("\na source that sends more than the page it was asked for") # sxyprn answers a page of 12 with 30. Everything past the twelfth was # never shown to the reader and was never new either -- it sat on the # same page all along -- so none of it may end up on the badge. size["n"] = 30 page.evaluate("""async () => { App.videos.resetGrid(); await App.videos.loadVideos(); App.savedSearches.markViewed(App.savedSearches.getAll()[0].id); }""") page.wait_for_timeout(800) saved = page.evaluate(SAVED) c.ok("the mark holds the whole page the source sent, not the one asked for", len(saved[0].get("seenIds") or []) == 30, str(len(saved[0].get("seenIds") or []))) page.evaluate("() => App.savedSearches.check({ force: true })") page.wait_for_timeout(900) saved = page.evaluate(SAVED) badge = page.evaluate(BADGE) c.ok("the same page, checked again, is not news", saved[0].get("newCount") == 0, str(saved[0].get("newCount"))) c.ok("so the badge stays out instead of sticking on a number", badge["hidden"] is True, str(badge)) c.ok("and nothing is counted anywhere else either", page.evaluate("() => App.savedSearches.totalNew()") == 0) print("\nnews on such a source still counts") offset["at"] = offset["at"] + 5 page.evaluate("() => App.savedSearches.check({ force: true })") page.wait_for_timeout(900) saved = page.evaluate(SAVED) c.ok("five turning up is five", saved[0].get("newCount") == 5, str(saved[0].get("newCount"))) print("\nmarking the whole list read") page.click("#saved-searches-btn") page.wait_for_timeout(300) c.ok("the button is offered while something is new", page.evaluate(READ_BTN)["disabled"] is False, str(page.evaluate(READ_BTN))) asked.clear() page.click("#saved-read-btn") page.wait_for_timeout(1400) saved = page.evaluate(SAVED) badge = page.evaluate(BADGE) c.ok("every count goes out", all(entry.get("newCount") == 0 for entry in saved), str(saved)) c.ok("and the header count with them", badge["hidden"] is True, str(badge)) c.ok("the mark moves on to what the source lists now, so it stays out", f"vid-{offset['at']}" in (saved[0].get("seenIds") or []) and f"vid-{offset['at'] - 5}" not in (saved[0].get("seenIds") or []), str((saved[0].get("seenIds") or [])[:3])) c.ok("which took asking the source", any(b.get("query") == "sauna" for b in asked), str(asked)) page.evaluate("() => App.savedSearches.check({ force: true })") page.wait_for_timeout(900) saved = page.evaluate(SAVED) c.ok("a check straight after finds nothing new", saved[0].get("newCount") == 0, str(saved[0].get("newCount"))) c.ok("and the button turns itself off with nothing left to clear", page.evaluate(READ_BTN)["disabled"] is True, str(page.evaluate(READ_BTN))) 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())