#!/usr/bin/env python3 """A captured thumbnail has to be a picture of something. Run against a locally running backend: backend/main.py & .venv/bin/python tests/smoke_poster_black.py Two ways a card ends up a black box rather than a picture, both of which the capture used to keep for the life of the item -- the only test a frame had to pass was being a few hundred bytes long: * the frame at the mark is black. A video that opens on a fade, or whose length nobody will say (so there is no quarter mark to seek to and the opening frame is all there is), hands over exactly that. * the element decoded nothing. Drawing one that has not presented a frame gives an untouched canvas, which is a pure black JPEG about 1.6 KB long. The fixture is black for its first three and a half seconds -- past the quarter mark a capture aims for -- and flat orange after, so the picture on the card says whether anything looked at what it was keeping. """ import json import os import re import sys from playwright.sync_api import sync_playwright CLIP = os.path.join(os.path.dirname(__file__), "fixtures", "dark.mp4") BASE = "http://127.0.0.1:5000/" SERVER = "https://fixture.test" CHANNEL = "clips" CACHE_KEY = "posterCache" STATUS = { "online": True, "channels": [{ "id": CHANNEL, "name": "Fixture Clips", "description": "A channel that only exists for this test.", "options": [], }], } # Saved with the thumbnail it had at the time, and with nothing known about how # long it is -- which is the state the item this test comes from is in. SEED = """([server, channel]) => { localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] })); localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } })); localStorage.removeItem('session'); localStorage.removeItem('posterCache'); Object.keys(localStorage).forEach((k) => { if (k.indexOf('posterCache:') === 0) localStorage.removeItem(k); }); localStorage.setItem('favorites', JSON.stringify([{ key: 'vid-0', id: 'vid-0', url: 'https://fixture.test/v0.mp4', title: 'kept clip 0', thumb: 'https://pictures.fixture.test/gone/0.jpg', channel: channel, uploader: '', duration: 0, isLive: false, favoriteDate: new Date().toISOString() }])); }""" CAPTURED = """(id) => { const card = document.querySelector(`.video-card[data-video-id="${id}"]`); if (!card) return false; const img = card.querySelector('.video-thumb img'); return (img.getAttribute('src') || '').indexOf('data:image/jpeg') === 0; }""" # What the card is showing, measured: the brightest sample in it and the # average, which is all it takes to tell a picture from a black box. LIGHT = """(id) => new Promise((resolve) => { const card = document.querySelector(`.video-card[data-video-id="${id}"]`); const img = card ? card.querySelector('.video-thumb img') : null; const src = img ? (img.getAttribute('src') || '') : ''; if (!src) { resolve(null); return; } const probe = new Image(); probe.onload = () => { const canvas = document.createElement('canvas'); canvas.width = probe.naturalWidth; canvas.height = probe.naturalHeight; const ctx = canvas.getContext('2d'); ctx.drawImage(probe, 0, 0); const px = ctx.getImageData(0, 0, canvas.width, canvas.height).data; let sum = 0, brightest = 0; for (let i = 0; i < px.length; i += 4) { const value = Math.max(px[i], px[i + 1], px[i + 2]); sum += value; if (value > brightest) brightest = value; } resolve({ brightest, mean: Math.round(sum / (px.length / 4)), bytes: src.length, w: canvas.width, h: canvas.height }); }; probe.onerror = () => resolve(null); probe.src = src; })""" CACHE = """() => JSON.parse(localStorage.getItem('posterCache') || '[]')""" FORGET = """() => { localStorage.removeItem('posterCache'); Object.keys(localStorage).forEach((k) => { if (k.indexOf('posterCache:') === 0) localStorage.removeItem(k); }); }""" # The item this test comes from has no duration: the extractor reports none and # the favorite kept a zero. Where the browser can't supply one either, there is # no quarter mark to seek to -- and the frame the element decodes on its own is # the opening one, which is the frame this clip is black for. NO_DURATION = """ Object.defineProperty(HTMLVideoElement.prototype, 'duration', { configurable: true, get: function() { return NaN; } }); """ # What the version before this kept: an untouched canvas, JPEG'd. Written over # the picture in the cache, with the "this was looked at" marker taken off the # index entry, which is exactly the state those sessions left behind. POISON = """() => { const canvas = document.createElement('canvas'); canvas.width = 360; canvas.height = 203; const black = canvas.toDataURL('image/jpeg', 0.72); const index = JSON.parse(localStorage.getItem('posterCache') || '[]'); index.forEach((entry) => { delete entry.v; entry.n = black.length; localStorage.setItem(`posterCache:${entry.k}`, black); }); localStorage.setItem('posterCache', JSON.stringify(index)); return { black: black, entries: index.length }; }""" 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(): return [{"id": "vid-0", "title": "clip 0", "url": "https://fixture.test/v0.mp4", "thumb": "https://pictures.fixture.test/gone/0.jpg", "channel": CHANNEL, "duration": 0, "isLive": False, "tags": []}] def main(): c = Checks() streamed = [] with sync_playwright() as p: browser = p.chromium.launch(args=[ "--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu", "--autoplay-policy=no-user-gesture-required", "--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))) page.route("**/api/status", lambda route: route.fulfill( status=200, content_type="application/json", body=json.dumps(STATUS))) page.route("**/api/videos", lambda route: route.fulfill( status=200, content_type="application/json", body=json.dumps({"items": items(), "pageInfo": {"hasNextPage": False}}))) page.route(re.compile(r"pictures\.fixture\.test"), lambda route: route.fulfill(status=404, body="")) page.route(re.compile(r"/api/image"), lambda route: route.fulfill(status=404, body="")) page.route(re.compile(r"/api/poster"), lambda route: route.fulfill( status=200, content_type="application/json", body=json.dumps({"thumb": ""}))) # Byte ranges and all: a response a browser cannot seek in is one it # will not take a frame out of the middle of. def stream(route): streamed.append(route.request.url) body = open(CLIP, "rb").read() span = re.match(r"bytes=(\d+)-(\d*)", route.request.headers.get("range") or "") if span: start = int(span.group(1)) end = int(span.group(2)) if span.group(2) else len(body) - 1 chunk = body[start:end + 1] route.fulfill(status=206, body=chunk, headers={ "Content-Type": "video/mp4", "Accept-Ranges": "bytes", "Content-Range": f"bytes {start}-{end}/{len(body)}", "Content-Length": str(len(chunk)), }) return route.fulfill(status=200, body=body, headers={ "Content-Type": "video/mp4", "Accept-Ranges": "bytes", "Content-Length": str(len(body)), }) page.route(re.compile(r"/api/stream"), stream) 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) print("\nthe quarter mark of this clip is black") page.wait_for_function(CAPTURED, arg="vid-0", timeout=60000) shot = page.evaluate(LIGHT, "vid-0") c.ok("the card ended up with a captured picture", shot is not None, str(shot)) c.ok("and it is not a black box", shot and shot["brightest"] > 40, str(shot)) c.ok("it is the orange the clip shows past its lead-in", shot and shot["mean"] > 60, str(shot)) c.ok("at card width, so it is a thumbnail and not a frame of video", shot and shot["w"] == 360, str(shot)) print("\nand the picture it kept says it was looked at") cached = page.evaluate(CACHE) c.ok("one picture is in the cache", len(cached) == 1, str(cached)) c.ok("marked as checked", cached and cached[0].get("v") == 1, str(cached)) print("\na black picture kept by an earlier version is replaced") poisoned = page.evaluate(POISON) c.ok("the cache now holds what that version would have kept", poisoned["entries"] == 1, str(poisoned["entries"])) streamed.clear() page.goto(BASE, wait_until="load") page.wait_for_selector(".video-card", timeout=30000) # A flat frame weighs about what a blank one does -- both are a single # colour -- so what says the card moved on is the picture itself. page.wait_for_function( """([id, black]) => { const card = document.querySelector(`.video-card[data-video-id="${id}"]`); const img = card && card.querySelector('.video-thumb img'); const src = img ? (img.getAttribute('src') || '') : ''; // The listed picture is on the card while the ladder walks // down it, so what counts is a captured picture that is not // the one that was there. return src.indexOf('data:image/jpeg') === 0 && src !== black; }""", arg=["vid-0", poisoned["black"]], timeout=60000) healed = page.evaluate(LIGHT, "vid-0") c.ok("the card shows a picture again", healed is not None, str(healed)) c.ok("not the black one it was given", healed and healed["brightest"] > 40, str(healed)) c.ok("taken from the video, which was fetched for it", any("/api/stream" in url for url in streamed), str(streamed[:2])) kept = page.evaluate(CACHE) c.ok("and the replacement is what is kept now", len(kept) == 1 and kept[0].get("v") == 1, str(kept)) print("\nand it is not measured a second time") streamed.clear() page.goto(BASE, wait_until="load") page.wait_for_selector(".video-card", timeout=30000) page.wait_for_timeout(2000) again = page.evaluate(LIGHT, "vid-0") c.ok("the kept picture is on the card straight away", again and again["brightest"] > 40, str(again)) c.ok("without going back to the video", streamed == [], str(streamed)) print("\nand a clip nobody will say the length of is not left on frame one") page.evaluate(FORGET) page.add_init_script(NO_DURATION) streamed.clear() page.goto(BASE, wait_until="load") page.wait_for_selector(".video-card", timeout=30000) c.ok("the browser gives no length to go by", page.evaluate("() => String(document.createElement('video').duration)") == 'NaN') page.wait_for_function(CAPTURED, arg="vid-0", timeout=60000) blind = page.evaluate(LIGHT, "vid-0") c.ok("a frame came out of the video anyway", blind is not None, str(blind)) c.ok("and it is not the black one it opens on", blind and blind["brightest"] > 40, str(blind)) 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())