Keep a whole search, and say what is new in it
A search here is more than the words in the box: it is the source, the channel, every filter that channel offers, and the query together. The bookmark in the search bar keeps all of it, the one in the header opens the list, and tapping a row puts the whole thing back. Each saved search remembers the ids of the newest videos it showed the last time it was opened. Once a day -- at startup, when the tab is looked at again, and on a slow heartbeat for tabs left open -- the client asks each one's first page again and counts how many of those videos it has never shown. That count is the badge, on the row and summed in the header, and it stops at 9+. Opening a saved search is viewing it, so the count goes out and the mark moves to what is on screen now. Nothing new leaves the device: the check is the same listing request the grid would have made, and everything else is localStorage. Also: handleSearch's "put this in the box without searching for it" half is now App.videos.setSearchBox, which is what applying a saved search needs. tests/smoke_saved_searches.py covers it end to end against a fixture listing whose ids the test shifts to mean "new uploads": 46 checks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
This commit is contained in:
323
tests/smoke_saved_searches.py
Normal file
323
tests/smoke_saved_searches.py
Normal file
@@ -0,0 +1,323 @@
|
||||
#!/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
|
||||
}))"""
|
||||
|
||||
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}
|
||||
|
||||
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 + 12)]
|
||||
|
||||
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)
|
||||
|
||||
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())
|
||||
Reference in New Issue
Block a user