#!/usr/bin/env python3 """Merging saved searches from different providers into one listing. Run against a locally running backend: backend/main.py & .venv/bin/python tests/smoke_merge_searches.py Two fixture providers, one saved search on each, merged into one. What the merge has to do that a channel group cannot: a group's channels all live on one server and share one query, where these two share nothing at all. So the load-bearing check is that the grid comes back holding videos from *both* providers, taken alternately rather than one provider's page and then the other's. Both providers are fixtures, so "interleaved" is something the test can read off the ids rather than infer: provider A answers a-0, a-1 ... and B answers b-0, b-1 ... """ import json import sys from playwright.sync_api import sync_playwright BASE = "http://127.0.0.1:5000/" SERVER_A = "https://a.fixture.test" SERVER_B = "https://b.fixture.test" CHANNEL_A = "alpha" CHANNEL_B = "beta" # fetchMergedBatch asks each member for this many (GROUP_CHANNEL_PAGE_SIZE). PER_MEMBER = 4 def status(name, channel_id, channel_name): return { "id": name, "name": name, "online": True, "channels": [{ "id": channel_id, "name": channel_name, "description": "A channel that only exists for this test.", "options": [], }], } STATUS_A = status("alpha-co", CHANNEL_A, "Alpha Tube") STATUS_B = status("beta-co", CHANNEL_B, "Beta Tube") # Two saved searches, one per provider, seeded straight into the store -- the # saving of a search is smoke_saved_searches' business, not this test's. SEED = """([a, b, ca, cb]) => { localStorage.setItem('config', JSON.stringify({ servers: [{ [a]: {} }, { [b]: {} }] })); localStorage.removeItem('session'); localStorage.setItem('favorites', JSON.stringify([])); localStorage.setItem('savedSearches', JSON.stringify([ { id: 'ss_a', name: 'Alpha pick', server: a, channelId: ca, channelName: 'Alpha Tube', query: 'reef', options: {}, optionLabels: [], createdAt: 1, seenIds: [], lastViewedAt: 1, lastCheckedAt: 1, newCount: 0 }, { id: 'ss_b', name: 'Beta pick', server: b, channelId: cb, channelName: 'Beta Tube', query: 'ridge', options: {}, optionLabels: [], createdAt: 2, seenIds: [], lastViewedAt: 2, lastCheckedAt: 2, newCount: 0 } ])); }""" GRID_IDS = """() => (App.state.loadedVideos || []).map(v => v.id)""" SESSION = """() => { const s = App.storage.getSession(); return { merged: !!(s && s.channel && s.channel.isMerged), channelId: s && s.channel ? s.channel.id : null, members: s && s.channel && s.channel.members ? s.channel.members.map(m => m.server + '|' + m.channelId + '|' + m.query) : null, name: s && s.channel ? s.channel.name : null }; }""" STORE = """() => App.savedSearches.getAll().map(e => ({ id: e.id, name: e.name, members: e.members ? e.members.map(m => m.server) : 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 install_routes(page, seen): page.route(f"{SERVER_A}/api/status", lambda route: route.fulfill( status=200, content_type="application/json", body=json.dumps(STATUS_A))) page.route(f"{SERVER_B}/api/status", lambda route: route.fulfill( status=200, content_type="application/json", body=json.dumps(STATUS_B))) def videos(route): try: body = json.loads(route.request.post_data or "{}") except ValueError: body = {} server = body.get("server") or "" page_no = int(body.get("page") or 1) per = int(body.get("perPage") or 12) tag = "a" if server == SERVER_A else ("b" if server == SERVER_B else "?") seen.append({"server": server, "channel": body.get("channel"), "query": body.get("query"), "page": page_no, "perPage": per}) start = (page_no - 1) * per items = [{ "id": f"{tag}-{start + i}", "title": f"{tag} clip {start + i}", "url": f"{server}/v{start + i}", "thumb": "", "channel": body.get("channel"), "duration": 30, "isLive": False, "tags": [], } for i in range(per)] route.fulfill(status=200, content_type="application/json", body=json.dumps({"items": items, "pageInfo": {"hasNextPage": True}})) page.route("**/api/videos", videos) def main(): c = Checks() 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": 1280, "height": 900}) crashes = [] page.on("pageerror", lambda e: crashes.append(str(e))) seen = [] install_routes(page, seen) page.goto(BASE, wait_until="domcontentloaded") page.evaluate(SEED, [SERVER_A, SERVER_B, CHANNEL_A, CHANNEL_B]) page.goto(BASE, wait_until="load") page.wait_for_selector(".video-card", timeout=30000) page.wait_for_timeout(800) print("\nmerging the two") page.click("#saved-searches-btn") page.wait_for_selector("#saved-panel.open", timeout=10000) rows = page.eval_on_selector_all(".saved-row", "els => els.length") c.ok("both saved searches are listed", rows == 2, str(rows)) page.click("#saved-select-btn") page.wait_for_timeout(200) page.click('.saved-row[data-id="ss_a"] .saved-row-main') page.click('.saved-row[data-id="ss_b"] .saved-row-main') page.wait_for_timeout(200) label = page.eval_on_selector("#saved-merge-btn", "el => el.textContent") c.ok("the merge action counts what is picked", "2" in label, label) page.click("#saved-merge-btn") page.wait_for_timeout(400) # The new merge opens straight into its rename field; Enter keeps the # name it was given. page.keyboard.press("Enter") page.wait_for_timeout(300) store = page.evaluate(STORE) c.ok("the two originals were replaced by one merge", len(store) == 1, str(store)) c.ok("and it holds a member from each provider", len(store) == 1 and sorted(store[0]["members"] or []) == sorted([SERVER_A, SERVER_B]), str(store)) print("\nopening it") seen.clear() page.click(f'.saved-row[data-id="{store[0]["id"]}"] .saved-row-main') page.wait_for_timeout(2500) session = page.evaluate(SESSION) c.ok("the session is a merge", session["merged"], str(session)) c.ok("carrying both members, each with its own words", sorted(session["members"] or []) == sorted([ f"{SERVER_A}|{CHANNEL_A}|reef", f"{SERVER_B}|{CHANNEL_B}|ridge"]), str(session["members"])) asked = {(r["server"], r["query"]) for r in seen} c.ok("both providers were asked, each for its own search", asked == {(SERVER_A, "reef"), (SERVER_B, "ridge")}, str(asked)) ids = page.evaluate(GRID_IDS) kinds = [i.split("-")[0] for i in ids] c.ok("the grid holds videos from both providers", "a" in kinds and "b" in kinds, str(ids[:10])) # The point of the exercise: alternated, not one provider then the other. head = kinds[:2 * PER_MEMBER] c.ok("taken alternately rather than one provider at a time", head == ["a", "b"] * PER_MEMBER, str(head)) print("\nthe search box stays out of it") box = page.evaluate("""() => { const el = document.getElementById('search-input'); return { disabled: el.disabled, value: el.value, placeholder: el.placeholder }; }""") c.ok("the box is off while a merge is open", box["disabled"], str(box)) c.ok("and says why", "own words" in box["placeholder"], str(box["placeholder"])) print("\nit survives a reload") page.reload(wait_until="load") page.wait_for_selector(".video-card", timeout=30000) page.wait_for_timeout(2000) again = page.evaluate(SESSION) c.ok("still a merge after reloading", again["merged"], str(again)) c.ok("with the same members", sorted(again["members"] or []) == sorted(session["members"] or []), str(again["members"])) reloaded = [i.split("-")[0] for i in page.evaluate(GRID_IDS)] c.ok("and still fed by both providers", "a" in reloaded and "b" in reloaded, str(reloaded[:10])) print("\nturning one member off") page.evaluate("""(host) => { const s = App.storage.getSession(); const keep = s.channel.members.filter(m => m.server.indexOf(host) !== -1); s.options = { __mergedMembers: keep.map(m => ({ id: m.memberId, title: m.channelName })) }; App.storage.setSession(s); }""", "a.fixture") page.evaluate("() => { App.videos.resetGrid(); return App.videos.loadVideos(); }") page.wait_for_timeout(2000) only = [i.split("-")[0] for i in page.evaluate(GRID_IDS)] c.ok("only the member left on is asked", only and set(only) == {"a"}, str(only[:10])) print("\nsearching from inside a merge steps out of it") # A tag or an uploader tapped on a card comes through handleSearch. # There is nowhere in a merge to put new words, so rather than reload # the same videos and look broken, it lands on one real channel. page.evaluate("() => App.videos.handleSearch('anemone')") page.wait_for_timeout(2000) after = page.evaluate(SESSION) c.ok("the session is no longer a merge", not after["merged"], str(after)) c.ok("it landed on a member's own channel", after["channelId"] in (CHANNEL_A, CHANNEL_B), str(after["channelId"])) box2 = page.evaluate("""() => { const el = document.getElementById('search-input'); return { disabled: el.disabled, value: el.value }; }""") c.ok("and the box is usable again", not box2["disabled"], str(box2)) c.ok("holding what was searched for", box2["value"] == "anemone", str(box2)) searched = [r for r in seen if r["query"] == "anemone"] c.ok("which is what the provider was asked for", bool(searched), str(seen[-3:])) c.ok("nothing threw along the way", not crashes, str(crashes[:2])) browser.close() print(f"\n{'FAILED' if c.failed else 'OK'}: {c.failed} check(s) failed") return 1 if c.failed else 0 if __name__ == "__main__": sys.exit(main())