Searches are kept in local storage and the five that best fit what is in
the box are offered under it: the ones it starts, then the ones it starts
a word of, then the ones it appears in anywhere, most recently searched
first. Walk them with the arrows, take one with enter or a tap, dismiss
with escape. The part that matched is picked out, and rows are built from
text nodes -- these strings are the reader's own and they are going back
on screen.
The box searches as you type, so there is no submit to record and storing
every pause would fill the list with the way to a query rather than the
query: a new entry swallows the ones it grew out of ("hot", "hote",
"hotel" leaves "hotel"), and going back to something shorter adds
nothing. Enter records straight away, since that is a reader saying they
meant it.
That turned up a bug the suggestions would have run into constantly: a
search started while the box's own last search was still out was dropped
-- loadVideos saw a load running and returned -- leaving the grid on the
previous query. Changing the result set now stops the load that is
running before starting the next one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
250 lines
9.8 KiB
Python
250 lines
9.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Searches are kept, and offered back under the box as you type.
|
|
|
|
Run against a locally running backend:
|
|
|
|
backend/main.py &
|
|
.venv/bin/python tests/smoke_search_history.py
|
|
|
|
The listing is a fixture and records what it was asked for, so "the search
|
|
actually ran" is something the test can see rather than infer.
|
|
"""
|
|
import json
|
|
import sys
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
BASE = "http://127.0.0.1:5000/"
|
|
SERVER = "https://hottubapp.io"
|
|
CHANNEL = "xvideos"
|
|
|
|
SEED = """([server, channel]) => {
|
|
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
|
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
|
localStorage.removeItem('session');
|
|
localStorage.removeItem('searchHistory');
|
|
}"""
|
|
|
|
HISTORY = """() => JSON.parse(localStorage.getItem('searchHistory') || '[]')"""
|
|
|
|
ROWS = """() => Array.from(document.querySelectorAll('#search-suggest .search-suggest-row'))
|
|
.map((row) => row.dataset.query)"""
|
|
|
|
OPEN = """() => !document.getElementById('search-suggest').hidden"""
|
|
|
|
ACTIVE = """() => {
|
|
const row = document.querySelector('#search-suggest .search-suggest-row.is-active');
|
|
return row ? row.dataset.query : 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 items(query):
|
|
return [{"id": f"fix-{query}-{i}", "title": f"{query or 'anything'} {i}",
|
|
"url": f"https://fixture.test/v{i}.mp4", "thumb": "", "channel": "test",
|
|
"duration": 1, "isLive": False, "tags": []} for i in range(12)]
|
|
|
|
|
|
def wait_for_query(page, asked, query, ms=4000):
|
|
"""The search may go out on the request already in flight rather than a new
|
|
one of its own (the box searches as you type, so there usually is one), so
|
|
what is waited for is the query reaching the server, not a fresh request."""
|
|
for _ in range(ms // 100):
|
|
if query in asked:
|
|
return True
|
|
page.wait_for_timeout(100)
|
|
return query in asked
|
|
|
|
|
|
def type_into(page, text):
|
|
"""Types a query the way a reader would, a character at a time."""
|
|
page.click("#search-input")
|
|
page.fill("#search-input", "")
|
|
page.type("#search-input", text, delay=40)
|
|
|
|
|
|
def main():
|
|
c = Checks()
|
|
asked = []
|
|
|
|
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": 1100, "height": 900})
|
|
crashes = []
|
|
page.on("pageerror", lambda e: crashes.append(str(e)))
|
|
|
|
# With `hold` on, listing requests are recorded but left unanswered, so
|
|
# the test can act while one is still out.
|
|
hold = {"on": False, "parked": []}
|
|
|
|
def answer(route, query):
|
|
route.fulfill(status=200, content_type="application/json", body=json.dumps(
|
|
{"items": items(query), "pageInfo": {"hasNextPage": False}}))
|
|
|
|
def listing(route):
|
|
try:
|
|
body = json.loads(route.request.post_data or "{}")
|
|
except Exception:
|
|
body = {}
|
|
query = body.get("query", "")
|
|
asked.append(query)
|
|
if hold["on"]:
|
|
hold["parked"].append((route, query))
|
|
return
|
|
answer(route, query)
|
|
|
|
def release():
|
|
hold["on"] = False
|
|
while hold["parked"]:
|
|
route, query = hold["parked"].pop(0)
|
|
answer(route, query)
|
|
|
|
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(500)
|
|
|
|
print("\nsearching for something")
|
|
type_into(page, "hotel")
|
|
page.wait_for_timeout(1200)
|
|
stored = page.evaluate(HISTORY)
|
|
c.ok("the search was kept", "hotel" in stored, str(stored))
|
|
c.ok("and the typing on the way to it was not",
|
|
stored == ["hotel"], str(stored))
|
|
c.ok("the search itself ran", asked and asked[-1] == "hotel", str(asked[-3:]))
|
|
|
|
print("\nand for something else, then reloading")
|
|
type_into(page, "sauna")
|
|
page.wait_for_timeout(1200)
|
|
page.goto(BASE, wait_until="load")
|
|
page.wait_for_selector(".video-card", timeout=30000)
|
|
stored = page.evaluate(HISTORY)
|
|
c.ok("both searches survived the reload",
|
|
stored == ["sauna", "hotel"], str(stored))
|
|
|
|
print("\nwith a history to draw on")
|
|
page.evaluate("""() => localStorage.setItem('searchHistory', JSON.stringify([
|
|
'hot tub', 'hotel room', 'shot in the dark', 'sauna', 'steam room',
|
|
'hot spring', 'cold plunge'
|
|
]))""")
|
|
page.click("#search-input")
|
|
page.wait_for_timeout(300)
|
|
rows = page.evaluate(ROWS)
|
|
c.ok("the box offers the recent ones when empty", page.evaluate(OPEN) is True)
|
|
c.ok("five of them, newest first",
|
|
rows == ['hot tub', 'hotel room', 'shot in the dark', 'sauna', 'steam room'], str(rows))
|
|
|
|
print("\ntyping narrows them down")
|
|
page.type("#search-input", "hot", delay=40)
|
|
page.wait_for_timeout(200)
|
|
rows = page.evaluate(ROWS)
|
|
print(f" (offered: {rows})")
|
|
c.ok("only the ones it appears in are left",
|
|
set(rows) == {'hot tub', 'hotel room', 'hot spring', 'shot in the dark'}, str(rows))
|
|
c.ok("the ones it starts come first",
|
|
rows[:3] == ['hot tub', 'hotel room', 'hot spring'], str(rows))
|
|
c.ok("and the one it is buried in comes last",
|
|
rows[-1] == 'shot in the dark', str(rows))
|
|
c.ok("the part that matched is picked out",
|
|
page.evaluate("""() => {
|
|
const hit = document.querySelector('#search-suggest .search-suggest-hit');
|
|
return !!hit && hit.textContent.toLowerCase() === 'hot';
|
|
}"""))
|
|
|
|
print("\nwhat is already typed in full is not a suggestion")
|
|
page.fill("#search-input", "")
|
|
page.type("#search-input", "sauna", delay=20)
|
|
page.wait_for_timeout(200)
|
|
rows = page.evaluate(ROWS)
|
|
c.ok("it offers itself back to nobody",
|
|
'sauna' not in rows and page.evaluate(OPEN) is False, str(rows))
|
|
|
|
print("\ntaking one with the keyboard")
|
|
page.fill("#search-input", "")
|
|
page.type("#search-input", "hot", delay=20)
|
|
page.wait_for_timeout(200)
|
|
page.keyboard.press("ArrowDown")
|
|
first = page.evaluate(ACTIVE)
|
|
page.keyboard.press("ArrowDown")
|
|
second = page.evaluate(ACTIVE)
|
|
c.ok("the arrows walk the list", first == 'hot tub' and second == 'hotel room',
|
|
f"{first} then {second}")
|
|
asked.clear()
|
|
page.keyboard.press("Enter")
|
|
page.wait_for_timeout(300)
|
|
c.ok("enter takes the one highlighted",
|
|
page.evaluate("() => document.getElementById('search-input').value") == 'hotel room')
|
|
c.ok("and searches for it", wait_for_query(page, asked, 'hotel room'), str(asked))
|
|
c.ok("the list is put away", page.evaluate(OPEN) is False)
|
|
|
|
print("\ntaking one with the pointer")
|
|
page.fill("#search-input", "")
|
|
page.click("#search-input")
|
|
page.type("#search-input", "st", delay=20)
|
|
page.wait_for_timeout(250)
|
|
asked.clear()
|
|
rows = page.evaluate(ROWS)
|
|
page.click(f"#search-suggest .search-suggest-row:nth-of-type(1)")
|
|
page.wait_for_timeout(300)
|
|
c.ok("the one clicked is what gets searched for",
|
|
page.evaluate("() => document.getElementById('search-input').value") == rows[0]
|
|
and wait_for_query(page, asked, rows[0]), f"{rows} then {asked}")
|
|
|
|
print("\nescape")
|
|
page.fill("#search-input", "")
|
|
page.click("#search-input")
|
|
page.wait_for_timeout(250)
|
|
c.ok("the list is up again", page.evaluate(OPEN) is True)
|
|
page.keyboard.press("Escape")
|
|
page.wait_for_timeout(150)
|
|
c.ok("escape puts it away", page.evaluate(OPEN) is False)
|
|
|
|
print("\nchoosing one while the box's own search is still out")
|
|
# The box searches as you type: choose a suggestion at the wrong moment
|
|
# and the search behind it used to be dropped, leaving the results on
|
|
# the query before it.
|
|
page.fill("#search-input", "")
|
|
page.click("#search-input")
|
|
hold["on"] = True
|
|
asked.clear()
|
|
page.type("#search-input", "hot", delay=20)
|
|
page.wait_for_timeout(700) # long enough for the typed search to go out
|
|
c.ok("the typed search is on the wire", asked == ['hot'], str(asked))
|
|
page.keyboard.press("ArrowDown")
|
|
chosen = page.evaluate(ACTIVE)
|
|
page.keyboard.press("Enter")
|
|
landed = wait_for_query(page, asked, chosen, 3000)
|
|
release()
|
|
page.wait_for_timeout(800)
|
|
c.ok(f"the chosen one ({chosen}) is searched for all the same", landed, str(asked))
|
|
c.ok("and its results are what the grid ends up with",
|
|
page.evaluate("() => (App.state.loadedVideos[0] || {}).id || ''")
|
|
.startswith(f"fix-{chosen}"),
|
|
page.evaluate("() => (App.state.loadedVideos[0] || {}).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())
|