A check asks each saved search in turn and writes back what it found, and the list was built again from scratch on every one of those writes. So a background check -- which has no business being visible at all beyond a new count and a new time -- replayed the whole list's entry animation, threw away the reader's scroll position, and took the pointer out of whatever row it was over, several times over. Rows are now matched by id and told what changed: the count, the name, the line underneath, nothing else. A row is only built when it is new, gone, or changes shape, and the entry animation belongs to a row that has arrived rather than to every row every time. One mid-rename or mid-confirm is left alone entirely, so a check can no longer type over the reader. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
417 lines
19 KiB
Python
417 lines
19 KiB
Python
#!/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
|
|
}))"""
|
|
|
|
MARK_ROWS = """() => {
|
|
// A mark on the elements themselves: a rebuilt row cannot carry it.
|
|
const rows = Array.from(document.querySelectorAll('#saved-panel .saved-row'));
|
|
rows.forEach((row, i) => { row.dataset.probe = `p${i}`; });
|
|
return rows.length;
|
|
}"""
|
|
|
|
PROBES = """() => Array.from(document.querySelectorAll('#saved-panel .saved-row')).map((row) => ({
|
|
probe: row.dataset.probe || '',
|
|
count: row.querySelector('.saved-count').textContent,
|
|
fresh: row.classList.contains('is-new'),
|
|
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)))
|
|
|
|
print("\na check writes into the list without rebuilding it")
|
|
c.ok("the list is open with a row in it", page.evaluate(MARK_ROWS) == 1)
|
|
offset["at"] = offset["at"] + 2
|
|
page.evaluate("() => App.savedSearches.check({ force: true })")
|
|
page.wait_for_timeout(1000)
|
|
rows = page.evaluate(PROBES)
|
|
c.ok("the row is the same element it was before the check",
|
|
len(rows) == 1 and rows[0]["probe"] == "p0", str(rows))
|
|
c.ok("with the count written into it where it stood",
|
|
rows[0]["count"] == "2", str(rows[0]["count"]))
|
|
c.ok("and the time of the check with it",
|
|
"checked" in rows[0]["stamp"].lower(), str(rows[0]["stamp"]))
|
|
c.ok("nothing animated itself back in", rows[0]["fresh"] is False, str(rows[0]))
|
|
|
|
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())
|