Compare commits
2 Commits
4b03789ef2
...
b8a51d9ee2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8a51d9ee2 | ||
|
|
b4bc90372d |
@@ -1528,6 +1528,22 @@ body.theme-light .favorite-btn {
|
|||||||
animation: cp-open 0.22s ease;
|
animation: cp-open 0.22s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Loading before it is shown: laid out, so the media element loads as it
|
||||||
|
normally would (a display:none video doesn't, on iOS), but invisible and
|
||||||
|
transparent to the pointer, so the grid underneath stays the page the
|
||||||
|
viewer is on. */
|
||||||
|
.custom-player.is-preloading,
|
||||||
|
/* The HUD's own children opt back into pointer events, so they have to be
|
||||||
|
told as well -- an invisible close button must not eat a card's click. */
|
||||||
|
.custom-player.is-preloading * {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-player.is-preloading {
|
||||||
|
display: block;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes cp-open {
|
@keyframes cp-open {
|
||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
|
|||||||
@@ -135,6 +135,59 @@ App.feed = App.feed || {};
|
|||||||
if (scroller) scroller.scrollTo({ top: next * slideHeight(), behavior: 'smooth' });
|
if (scroller) scroller.scrollTo({ top: next * slideHeight(), behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Sound in a split
|
||||||
|
//
|
||||||
|
// iOS gives a page one audible video at a time. Start a second one with
|
||||||
|
// sound and the system pauses the first -- which, in a split of two or
|
||||||
|
// four panels, reads as "only one panel is playing". Muted video carries
|
||||||
|
// no such limit, so where the rule applies exactly one panel keeps its
|
||||||
|
// sound and the rest are silenced: four pictures moving is what the split
|
||||||
|
// is for, and four audio tracks at once was never the point.
|
||||||
|
//
|
||||||
|
// Nothing in the platform announces this, and by the time the symptom
|
||||||
|
// shows -- a video we started, stopped by something that isn't us -- the
|
||||||
|
// reader has already watched a panel die. So it is read off the device
|
||||||
|
// rather than discovered.
|
||||||
|
const audioIsExclusive = (function() {
|
||||||
|
const ua = navigator.userAgent || '';
|
||||||
|
if (/iPhone|iPad|iPod/.test(ua)) return true;
|
||||||
|
// iPadOS 13+ claims to be a Mac; the touch points give it away.
|
||||||
|
return /Macintosh/.test(ua) && (navigator.maxTouchPoints || 0) > 1;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Leaves at most one audible panel in the step being watched. `prefer` is
|
||||||
|
// the panel whose sound was just asked for, if any; otherwise the first
|
||||||
|
// one already audible keeps it. Silenced panels get their own button
|
||||||
|
// updated too, so what is shown never disagrees with what is heard.
|
||||||
|
const limitAudibleToOne = function(prefer) {
|
||||||
|
if (!audioIsExclusive) return;
|
||||||
|
const slide = slidesByIndex.get(state.feedActiveIndex);
|
||||||
|
if (!slide) return;
|
||||||
|
let kept = (prefer && prefer._muted === false) ? prefer : null;
|
||||||
|
panesOf(slide).forEach((pane) => {
|
||||||
|
if (pane._muted) return;
|
||||||
|
if (!kept) { kept = pane; return; }
|
||||||
|
if (pane === kept) return;
|
||||||
|
pane._muted = true;
|
||||||
|
if (pane._syncMute) pane._syncMute();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Starts whatever in the current step is loaded but stopped -- which is
|
||||||
|
// what iOS leaves behind when a panel's sound is taken away from it.
|
||||||
|
const resumeActivePanes = function() {
|
||||||
|
const slide = slidesByIndex.get(state.feedActiveIndex);
|
||||||
|
if (!slide) return;
|
||||||
|
panesOf(slide).forEach((pane) => {
|
||||||
|
if (!pane.classList.contains('is-loaded')) return;
|
||||||
|
const video = pane.querySelector('.feed-video');
|
||||||
|
if (!video || !video.paused) return;
|
||||||
|
const playPromise = video.play();
|
||||||
|
if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Remembers playback position per video id so scrolling away and back
|
// Remembers playback position per video id so scrolling away and back
|
||||||
// resumes where the user left off. Slides kept in the window are merely
|
// resumes where the user left off. Slides kept in the window are merely
|
||||||
// paused (instant resume); slides whose <video> is torn down to free
|
// paused (instant resume); slides whose <video> is torn down to free
|
||||||
@@ -684,6 +737,11 @@ App.feed = App.feed || {};
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
pane._muted = !pane._muted;
|
pane._muted = !pane._muted;
|
||||||
syncMute();
|
syncMute();
|
||||||
|
// Asking for this panel's sound gives up another's, where only one
|
||||||
|
// panel may have any -- and the one that lost it may have been
|
||||||
|
// stopped by the system on the way, so it is started again.
|
||||||
|
limitAudibleToOne(pane);
|
||||||
|
resumeActivePanes();
|
||||||
refreshFeedMuteState();
|
refreshFeedMuteState();
|
||||||
// The panel you can hear is the one that should follow you out.
|
// The panel you can hear is the one that should follow you out.
|
||||||
updateAutoPiPTarget();
|
updateAutoPiPTarget();
|
||||||
@@ -952,6 +1010,9 @@ App.feed = App.feed || {};
|
|||||||
|
|
||||||
const activeSlide = slidesByIndex.get(clamped);
|
const activeSlide = slidesByIndex.get(clamped);
|
||||||
if (activeSlide) {
|
if (activeSlide) {
|
||||||
|
// Settled before anything starts: a second panel starting with
|
||||||
|
// sound is exactly what stops the first one (see audioIsExclusive).
|
||||||
|
limitAudibleToOne();
|
||||||
panesOf(activeSlide).forEach((pane) => {
|
panesOf(activeSlide).forEach((pane) => {
|
||||||
loadSlideSource(pane, pane._videoData, true);
|
loadSlideSource(pane, pane._videoData, true);
|
||||||
requestAnimationFrame(() => measureFeedTitle(pane));
|
requestAnimationFrame(() => measureFeedTitle(pane));
|
||||||
@@ -1548,6 +1609,10 @@ App.feed = App.feed || {};
|
|||||||
if (pane._syncMute) pane._syncMute();
|
if (pane._syncMute) pane._syncMute();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// Unmuting everything is still one panel's worth of sound where the
|
||||||
|
// device allows only one; the rest keep the picture.
|
||||||
|
limitAudibleToOne();
|
||||||
|
resumeActivePanes();
|
||||||
App.feed.updateMuteButton();
|
App.feed.updateMuteButton();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -27,9 +27,21 @@ App.player = App.player || {};
|
|||||||
hudHovered: false, // mouse resting on the controls (desktop)
|
hudHovered: false, // mouse resting on the controls (desktop)
|
||||||
activeUrl: '', // media URL actually playing, for the format menu's tick
|
activeUrl: '', // media URL actually playing, for the format menu's tick
|
||||||
attemptToken: 0, // bumps on every open()/format switch to void stale async callbacks
|
attemptToken: 0, // bumps on every open()/format switch to void stale async callbacks
|
||||||
fetchAbort: null // aborts the current attempt's own requests
|
fetchAbort: null, // aborts the current attempt's own requests
|
||||||
|
pending: false, // loading off-screen, not shown yet (see reveal)
|
||||||
|
revealTimer: null,
|
||||||
|
mutedBeforeReveal: null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A session exists: either on screen, or still loading in the background
|
||||||
|
// before it gets there. Everything that used to ask "is the player open?"
|
||||||
|
// means this -- a pending session owns the same history entry, the same
|
||||||
|
// card spinner and the same in-flight requests as a shown one.
|
||||||
|
function isActive() {
|
||||||
|
return !!(cp.container && (cp.pending || cp.container.classList.contains('open')));
|
||||||
|
}
|
||||||
|
App.player.isActive = isActive;
|
||||||
|
|
||||||
// Stops everything the current attempt has in flight. The token guards keep
|
// Stops everything the current attempt has in flight. The token guards keep
|
||||||
// stale *callbacks* from acting, but they don't stop the requests those
|
// stale *callbacks* from acting, but they don't stop the requests those
|
||||||
// callbacks were waiting on: hls.js goes on pulling segments through the
|
// callbacks were waiting on: hls.js goes on pulling segments through the
|
||||||
@@ -565,7 +577,10 @@ App.player = App.player || {};
|
|||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
function bindKeyboard(video) {
|
function bindKeyboard(video) {
|
||||||
const onKeyDown = (event) => {
|
const onKeyDown = (event) => {
|
||||||
if (!cp.container || !cp.container.classList.contains('open')) return;
|
if (!isActive()) return;
|
||||||
|
// Nothing else is worth doing to a video nobody can see yet, but
|
||||||
|
// changing your mind about it is.
|
||||||
|
if (cp.pending && event.key !== 'Escape') return;
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
case ' ':
|
case ' ':
|
||||||
case 'k':
|
case 'k':
|
||||||
@@ -615,7 +630,7 @@ App.player = App.player || {};
|
|||||||
addCleanup(() => closeBtn.removeEventListener('click', onClick));
|
addCleanup(() => closeBtn.removeEventListener('click', onClick));
|
||||||
}
|
}
|
||||||
const onPopState = () => {
|
const onPopState = () => {
|
||||||
if (cp.container && cp.container.classList.contains('open')) {
|
if (isActive()) {
|
||||||
cp.historyPushed = false; // the pushed state was just consumed by the browser
|
cp.historyPushed = false; // the pushed state was just consumed by the browser
|
||||||
App.player.close({ fromPopState: true });
|
App.player.close({ fromPopState: true });
|
||||||
}
|
}
|
||||||
@@ -657,7 +672,64 @@ App.player = App.player || {};
|
|||||||
if (spinner) spinner.classList.toggle('is-visible', show);
|
if (spinner) spinner.classList.toggle('is-visible', show);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Going on screen
|
||||||
|
//
|
||||||
|
// Opening a video used to put an empty black player up immediately and
|
||||||
|
// spin at the viewer until the first frame arrived -- which, between
|
||||||
|
// resolving the formats and the first bytes of a stream, is routinely a
|
||||||
|
// couple of seconds of nothing. So the session now starts off-screen:
|
||||||
|
// the card that was clicked keeps its own spinner, the page stays where
|
||||||
|
// it was, and the player appears only once the video has real data to
|
||||||
|
// show. Failures reveal it too -- the error and its retry live inside the
|
||||||
|
// player -- and so does the timeout below, because a stream that is merely
|
||||||
|
// slow is better watched from inside the player (which can be closed) than
|
||||||
|
// from a card that looks stuck.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const REVEAL_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
function reveal() {
|
||||||
|
if (!cp.container) return;
|
||||||
|
if (cp.revealTimer) {
|
||||||
|
clearTimeout(cp.revealTimer);
|
||||||
|
cp.revealTimer = null;
|
||||||
|
}
|
||||||
|
cp.pending = false;
|
||||||
|
// The card has handed over, whether this is the first reveal or a
|
||||||
|
// second video opened over the top of the first.
|
||||||
|
withOrigin(cp.originEl, cp.originToken, (el) => el.classList.remove('is-loading'));
|
||||||
|
if (cp.container.classList.contains('open')) return;
|
||||||
|
|
||||||
|
cp.container.classList.remove('is-preloading');
|
||||||
|
cp.container.classList.add('open');
|
||||||
|
cp.container.setAttribute('aria-hidden', 'false');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
// Muted while it was loading out of sight; it is in sight now.
|
||||||
|
if (cp.mutedBeforeReveal !== null && cp.video) {
|
||||||
|
cp.video.muted = cp.mutedBeforeReveal;
|
||||||
|
cp.mutedBeforeReveal = null;
|
||||||
|
}
|
||||||
|
wakeHud();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first data is the cue: `loadeddata` means a frame can be drawn, and
|
||||||
|
// `playing` covers the sources that get there without one (audio-only, and
|
||||||
|
// anything whose first frame lands before the listener is attached).
|
||||||
|
function bindReveal(video) {
|
||||||
|
const onData = () => reveal();
|
||||||
|
video.addEventListener('loadeddata', onData);
|
||||||
|
video.addEventListener('playing', onData);
|
||||||
|
addCleanup(() => {
|
||||||
|
video.removeEventListener('loadeddata', onData);
|
||||||
|
video.removeEventListener('playing', onData);
|
||||||
|
});
|
||||||
|
if (video.readyState >= 2) reveal();
|
||||||
|
}
|
||||||
|
|
||||||
function showError(message, onRetry, sourceUrl) {
|
function showError(message, onRetry, sourceUrl) {
|
||||||
|
// Whatever went wrong, it says so in the player -- which the viewer
|
||||||
|
// can only read if the player is on screen.
|
||||||
|
reveal();
|
||||||
showBuffering(false);
|
showBuffering(false);
|
||||||
const errorEl = q('.cp-error');
|
const errorEl = q('.cp-error');
|
||||||
const textEl = q('.cp-error-text');
|
const textEl = q('.cp-error-text');
|
||||||
@@ -854,7 +926,6 @@ App.player = App.player || {};
|
|||||||
// Whichever candidate got this far is the one on screen -- not
|
// Whichever candidate got this far is the one on screen -- not
|
||||||
// necessarily the one the ranking (or the viewer) asked for.
|
// necessarily the one the ranking (or the viewer) asked for.
|
||||||
cp.activeUrl = resolved.url || '';
|
cp.activeUrl = resolved.url || '';
|
||||||
clearLoading();
|
|
||||||
hideError();
|
hideError();
|
||||||
showBuffering(false);
|
showBuffering(false);
|
||||||
if (resumeAt > 0) {
|
if (resumeAt > 0) {
|
||||||
@@ -940,7 +1011,7 @@ App.player = App.player || {};
|
|||||||
// single history.back() could never fully unwind. Also clears the
|
// single history.back() could never fully unwind. Also clears the
|
||||||
// abandoned session's own loading spinner, since its card would
|
// abandoned session's own loading spinner, since its card would
|
||||||
// otherwise never hear about the takeover.
|
// otherwise never hear about the takeover.
|
||||||
const reopening = !!(cp.container && cp.container.classList.contains('open'));
|
const reopening = isActive();
|
||||||
if (reopening) {
|
if (reopening) {
|
||||||
cp.attemptToken++;
|
cp.attemptToken++;
|
||||||
cancelInFlight();
|
cancelInFlight();
|
||||||
@@ -1022,11 +1093,21 @@ App.player = App.player || {};
|
|||||||
bindKeyboard(cp.video);
|
bindKeyboard(cp.video);
|
||||||
bindClose();
|
bindClose();
|
||||||
bindBufferingIndicator(cp.video);
|
bindBufferingIndicator(cp.video);
|
||||||
|
bindReveal(cp.video);
|
||||||
|
|
||||||
cp.container.classList.add('open');
|
// Loading out of sight: laid out (so the media element behaves as it
|
||||||
cp.container.setAttribute('aria-hidden', 'false');
|
// would on screen -- iOS in particular will not load a display:none
|
||||||
document.body.style.overflow = 'hidden';
|
// video) but transparent and untouchable, so the page underneath is
|
||||||
wakeHud();
|
// still the page the viewer is using.
|
||||||
|
if (!cp.container.classList.contains('open')) {
|
||||||
|
cp.pending = true;
|
||||||
|
cp.container.classList.add('is-preloading');
|
||||||
|
// Nothing should be heard from a player that isn't there yet.
|
||||||
|
cp.mutedBeforeReveal = cp.video.muted;
|
||||||
|
cp.video.muted = true;
|
||||||
|
if (cp.revealTimer) clearTimeout(cp.revealTimer);
|
||||||
|
cp.revealTimer = setTimeout(reveal, REVEAL_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
// Already-resolved sources start immediately; unresolved ones start from
|
// Already-resolved sources start immediately; unresolved ones start from
|
||||||
// the ensureFormats() callback above (the spinner is already up).
|
// the ensureFormats() callback above (the spinner is already up).
|
||||||
@@ -1034,7 +1115,7 @@ App.player = App.player || {};
|
|||||||
};
|
};
|
||||||
|
|
||||||
App.player.close = function(opts) {
|
App.player.close = function(opts) {
|
||||||
if (!cp.container || !cp.container.classList.contains('open')) return;
|
if (!isActive()) return;
|
||||||
cp.attemptToken++; // void any in-flight attempt()/HEAD-probe callbacks
|
cp.attemptToken++; // void any in-flight attempt()/HEAD-probe callbacks
|
||||||
// Closing the player must also stop what it was fetching -- otherwise a
|
// Closing the player must also stop what it was fetching -- otherwise a
|
||||||
// proxied stream keeps being pulled, and the server keeps an upstream
|
// proxied stream keeps being pulled, and the server keeps an upstream
|
||||||
@@ -1043,7 +1124,13 @@ App.player = App.player || {};
|
|||||||
clearIdleTimer();
|
clearIdleTimer();
|
||||||
runCleanups();
|
runCleanups();
|
||||||
|
|
||||||
cp.container.classList.remove('open', 'cp-hud-idle', 'is-live');
|
if (cp.revealTimer) {
|
||||||
|
clearTimeout(cp.revealTimer);
|
||||||
|
cp.revealTimer = null;
|
||||||
|
}
|
||||||
|
cp.pending = false;
|
||||||
|
cp.mutedBeforeReveal = null;
|
||||||
|
cp.container.classList.remove('open', 'is-preloading', 'cp-hud-idle', 'is-live');
|
||||||
cp.container.style.transform = '';
|
cp.container.style.transform = '';
|
||||||
cp.container.style.opacity = '';
|
cp.container.style.opacity = '';
|
||||||
cp.container.setAttribute('aria-hidden', 'true');
|
cp.container.setAttribute('aria-hidden', 'true');
|
||||||
|
|||||||
@@ -54,9 +54,12 @@ App.version = App.version || {};
|
|||||||
// a reload because it is restored from localStorage on boot.
|
// a reload because it is restored from localStorage on boot.
|
||||||
function isSafeToReload() {
|
function isSafeToReload() {
|
||||||
if (App.state && App.state.feedOpen) return false;
|
if (App.state && App.state.feedOpen) return false;
|
||||||
const player = document.getElementById('custom-player');
|
// A player still loading out of sight counts as in use: reloading
|
||||||
if (player && player.classList.contains('open')) {
|
// would throw away the video the viewer just asked for.
|
||||||
const video = player.querySelector('.cp-video');
|
if (App.player && typeof App.player.isActive === 'function' && App.player.isActive()) {
|
||||||
|
const player = document.getElementById('custom-player');
|
||||||
|
const video = player && player.querySelector('.cp-video');
|
||||||
|
if (!player.classList.contains('open')) return false;
|
||||||
if (video && !video.paused && !video.ended) return false;
|
if (video && !video.paused && !video.ended) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
179
tests/smoke_ios_split.py
Normal file
179
tests/smoke_ios_split.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Split reels on iOS: every panel keeps playing, one panel keeps the sound.
|
||||||
|
|
||||||
|
Run against a locally running backend:
|
||||||
|
|
||||||
|
backend/main.py &
|
||||||
|
.venv/bin/python tests/smoke_ios_split.py
|
||||||
|
|
||||||
|
There is no iPhone here, and the bug was never about what the video element
|
||||||
|
does once asked -- it is about what iOS does to the *other* video when one
|
||||||
|
starts with sound. So the rule is installed into Chromium and the real feed is
|
||||||
|
driven through it: unmuting a panel used to unmute every panel, and the system
|
||||||
|
would then stop all but one of them.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
BASE = "http://127.0.0.1:5000/"
|
||||||
|
SERVER = "https://hottubapp.io"
|
||||||
|
CHANNEL = "xvideos"
|
||||||
|
|
||||||
|
IPHONE_UA = ("Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) "
|
||||||
|
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1")
|
||||||
|
|
||||||
|
# iOS's rule, near enough: one audible video at a time. Whatever starts (or
|
||||||
|
# unmutes) with sound takes the audio session, and whatever had it is paused.
|
||||||
|
AS_IOS = """(() => {
|
||||||
|
window.__systemPaused = [];
|
||||||
|
const claimAudio = (winner) => {
|
||||||
|
document.querySelectorAll('video').forEach((other) => {
|
||||||
|
if (other === winner || other.paused || other.muted) return;
|
||||||
|
window.__systemPaused.push(other.currentSrc || 'video');
|
||||||
|
HTMLMediaElement.prototype.pause.call(other);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const play = HTMLMediaElement.prototype.play;
|
||||||
|
HTMLMediaElement.prototype.play = function() {
|
||||||
|
if (!this.muted) claimAudio(this);
|
||||||
|
return play.apply(this, arguments);
|
||||||
|
};
|
||||||
|
const muted = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'muted');
|
||||||
|
Object.defineProperty(HTMLMediaElement.prototype, 'muted', {
|
||||||
|
configurable: true,
|
||||||
|
get() { return muted.get.call(this); },
|
||||||
|
set(value) {
|
||||||
|
muted.set.call(this, value);
|
||||||
|
if (!value && !this.paused) claimAudio(this);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})();"""
|
||||||
|
|
||||||
|
PANES = """() => {
|
||||||
|
const slide = document.querySelector('.feed-slide.is-active');
|
||||||
|
if (!slide) return null;
|
||||||
|
const panes = Array.from(slide.querySelectorAll('.feed-pane'));
|
||||||
|
return panes.map((pane) => {
|
||||||
|
const video = pane.querySelector('.feed-video');
|
||||||
|
const btn = pane.querySelector('.feed-pane-mute');
|
||||||
|
return {
|
||||||
|
muted: video ? video.muted : null,
|
||||||
|
flag: pane._muted === undefined ? null : !!pane._muted,
|
||||||
|
paused: video ? video.paused : null,
|
||||||
|
loaded: pane.classList.contains('is-loaded'),
|
||||||
|
button: btn ? btn.textContent : '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
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 open_reels(page):
|
||||||
|
page.goto(BASE, wait_until="domcontentloaded")
|
||||||
|
page.evaluate("""([server, channel]) => {
|
||||||
|
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||||
|
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||||
|
localStorage.removeItem('session');
|
||||||
|
}""", [SERVER, CHANNEL])
|
||||||
|
page.goto(BASE, wait_until="load")
|
||||||
|
page.wait_for_selector(".video-card", timeout=90000)
|
||||||
|
page.wait_for_timeout(1500)
|
||||||
|
page.evaluate("() => App.feed.open()")
|
||||||
|
page.wait_for_selector(".feed-slide", timeout=20000)
|
||||||
|
page.wait_for_timeout(4000)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
c = Checks()
|
||||||
|
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",
|
||||||
|
])
|
||||||
|
context = browser.new_context(
|
||||||
|
viewport={"width": 430, "height": 930},
|
||||||
|
user_agent=IPHONE_UA, is_mobile=True, has_touch=True,
|
||||||
|
device_scale_factor=3)
|
||||||
|
context.add_init_script(AS_IOS)
|
||||||
|
page = context.new_page()
|
||||||
|
crashes = []
|
||||||
|
page.on("pageerror", lambda e: crashes.append(str(e)))
|
||||||
|
open_reels(page)
|
||||||
|
|
||||||
|
print("\nthe rule is really in force")
|
||||||
|
c.ok("the page says it is an iPhone",
|
||||||
|
"iPhone" in page.evaluate("() => navigator.userAgent"))
|
||||||
|
|
||||||
|
print("\nsplit in two, with sound on")
|
||||||
|
page.click(".feed-slide.is-active .feed-pane .feed-pane-split-right")
|
||||||
|
page.wait_for_timeout(5000)
|
||||||
|
page.evaluate("() => App.feed.toggleMute()") # the feed-wide unmute
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
panes = page.evaluate(PANES)
|
||||||
|
c.ok("there are two panels", panes and len(panes) == 2, str(panes))
|
||||||
|
audible = [p for p in panes if p["muted"] is False]
|
||||||
|
c.ok("exactly one of them has sound", len(audible) == 1, str(panes))
|
||||||
|
c.ok("and the other says so on its own button",
|
||||||
|
all(p["button"] == "🔇" for p in panes if p["muted"]), str(panes))
|
||||||
|
c.ok("the flags agree with the elements",
|
||||||
|
all(p["flag"] == p["muted"] for p in panes), str(panes))
|
||||||
|
c.ok("both panels are playing",
|
||||||
|
all(p["paused"] is False for p in panes if p["loaded"]), str(panes))
|
||||||
|
|
||||||
|
print("\nmoving the sound to the other panel")
|
||||||
|
page.click(".feed-slide.is-active .feed-pane:last-of-type .feed-pane-mute")
|
||||||
|
page.wait_for_timeout(2500)
|
||||||
|
moved = page.evaluate(PANES)
|
||||||
|
c.ok("the panel asked for has it", moved[-1]["muted"] is False, str(moved))
|
||||||
|
c.ok("the first one gave it up", moved[0]["muted"] is True, str(moved))
|
||||||
|
c.ok("and nothing stopped playing",
|
||||||
|
all(p["paused"] is False for p in moved if p["loaded"]), str(moved))
|
||||||
|
|
||||||
|
print("\nswiping on")
|
||||||
|
page.evaluate("() => { const s = document.getElementById('feed-scroll');"
|
||||||
|
" s.scrollTop += s.clientHeight; }")
|
||||||
|
page.wait_for_timeout(5000)
|
||||||
|
stepped = page.evaluate(PANES)
|
||||||
|
c.ok("the next step plays in both panels",
|
||||||
|
stepped and all(p["paused"] is False for p in stepped if p["loaded"]),
|
||||||
|
str(stepped))
|
||||||
|
c.ok("still only one audible", len([p for p in stepped if p["muted"] is False]) <= 1,
|
||||||
|
str(stepped))
|
||||||
|
|
||||||
|
# Without the fix, both panels would be unmuted -- so prove the rule
|
||||||
|
# installed above actually bites, by breaking it on purpose.
|
||||||
|
print("\nthe simulated rule is what the fix is for")
|
||||||
|
page.evaluate("""() => {
|
||||||
|
document.querySelectorAll('.feed-slide.is-active .feed-video').forEach((v) => {
|
||||||
|
v.muted = false;
|
||||||
|
const p = v.play();
|
||||||
|
if (p && p.catch) p.catch(() => {});
|
||||||
|
});
|
||||||
|
}""")
|
||||||
|
page.wait_for_timeout(1500)
|
||||||
|
forced = page.evaluate(PANES)
|
||||||
|
c.ok("unmuting every panel does stop one of them",
|
||||||
|
any(p["paused"] for p in forced if p["loaded"]), str(forced))
|
||||||
|
|
||||||
|
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())
|
||||||
276
tests/smoke_open.py
Normal file
276
tests/smoke_open.py
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Opening a video: load first, show the player once there's something to show.
|
||||||
|
|
||||||
|
Run against a locally running backend:
|
||||||
|
|
||||||
|
backend/main.py &
|
||||||
|
.venv/bin/python tests/smoke_open.py
|
||||||
|
|
||||||
|
Both the listing and the media are served by this script, so "the stream is
|
||||||
|
slow" and "the stream is broken" are conditions the test can actually create
|
||||||
|
rather than wait for.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import http.server
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socketserver
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
BASE = os.environ.get("JACUZZI_BASE", "http://127.0.0.1:5000/")
|
||||||
|
SERVER = "https://hottubapp.io"
|
||||||
|
CHANNEL = "xvideos"
|
||||||
|
|
||||||
|
# One second of black, 64x64, h264 -- a real file, because the point of the
|
||||||
|
# test is that the browser decodes a frame from it.
|
||||||
|
MP4_B64 = (
|
||||||
|
"AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAOxbW9vdgAAAGxtdmhkAAAAAAAAAAAA"
|
||||||
|
"AAAAAAAD6AAAA+gAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAA"
|
||||||
|
"AABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAtx0cmFrAAAAXHRraGQAAAADAAAA"
|
||||||
|
"AAAAAAAAAAABAAAAAAAAA+gAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAA"
|
||||||
|
"AAAAAAAAAABAAAAAAEAAAABAAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAPoAAAIAAABAAAA"
|
||||||
|
"AAJUbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAoAAAAKABVxAAAAAAALWhkbHIAAAAAAAAAAHZp"
|
||||||
|
"ZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAAB/21pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAA"
|
||||||
|
"ACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAb9zdGJsAAAAv3N0c2QAAAAAAAAA"
|
||||||
|
"AQAAAK9hdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAEAAQABIAAAASAAAAAAAAAABFUxhdmM2"
|
||||||
|
"MS4xOS4xMDEgbGlieDI2NAAAAAAAAAAAAAAAGP//AAAANWF2Y0MBZAAK/+EAGGdkAAqs2UQmwEQA"
|
||||||
|
"AAMABAAAAwBQPEiWWAEABmjr48siwP34+AAAAAAQcGFzcAAAAAEAAAABAAAAFGJ0cnQAAAAAAAAa"
|
||||||
|
"uAAAAAAAAAAYc3R0cwAAAAAAAAABAAAACgAABAAAAAAUc3RzcwAAAAAAAAABAAAAAQAAAGBjdHRz"
|
||||||
|
"AAAAAAAAAAoAAAABAAAIAAAAAAEAABQAAAAAAQAACAAAAAABAAAAAAAAAAEAAAQAAAAAAQAAFAAA"
|
||||||
|
"AAABAAAIAAAAAAEAAAAAAAAAAQAABAAAAAABAAAIAAAAABxzdHNjAAAAAAAAAAEAAAABAAAACgAA"
|
||||||
|
"AAEAAAA8c3RzegAAAAAAAAAAAAAACgAAAtcAAAAOAAAADAAAAAwAAAAMAAAAFAAAAA4AAAAMAAAA"
|
||||||
|
"DAAAABQAAAAUc3RjbwAAAAAAAAABAAAD4QAAAGF1ZHRhAAAAWW1ldGEAAAAAAAAAIWhkbHIAAAAA"
|
||||||
|
"AAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALGlsc3QAAAAkqXRvbwAAABxkYXRhAAAAAQAAAABMYXZm"
|
||||||
|
"NjEuNy4xMDAAAAAIZnJlZQAAA19tZGF0AAACrgYF//+q3EXpvebZSLeWLNgg2SPu73gyNjQgLSBj"
|
||||||
|
"b3JlIDE2NCByMzEwOCAzMWUxOWY5IC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0"
|
||||||
|
"IDIwMDMtMjAyMyAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6"
|
||||||
|
"IGNhYmFjPTEgcmVmPTMgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzoweDExMyBtZT1oZXggc3Vi"
|
||||||
|
"bWU9NyBwc3k9MSBwc3lfcmQ9MS4wMDowLjAwIG1peGVkX3JlZj0xIG1lX3JhbmdlPTE2IGNocm9t"
|
||||||
|
"YV9tZT0xIHRyZWxsaXM9MSA4eDhkY3Q9MSBjcW09MCBkZWFkem9uZT0yMSwxMSBmYXN0X3Bza2lw"
|
||||||
|
"PTEgY2hyb21hX3FwX29mZnNldD0tMiB0aHJlYWRzPTIgbG9va2FoZWFkX3RocmVhZHM9MSBzbGlj"
|
||||||
|
"ZWRfdGhyZWFkcz0wIG5yPTAgZGVjaW1hdGU9MSBpbnRlcmxhY2VkPTAgYmx1cmF5X2NvbXBhdD0w"
|
||||||
|
"IGNvbnN0cmFpbmVkX2ludHJhPTAgYmZyYW1lcz0zIGJfcHlyYW1pZD0yIGJfYWRhcHQ9MSBiX2Jp"
|
||||||
|
"YXM9MCBkaXJlY3Q9MSB3ZWlnaHRiPTEgb3Blbl9nb3A9MCB3ZWlnaHRwPTIga2V5aW50PTI1MCBr"
|
||||||
|
"ZXlpbnRfbWluPTEwIHNjZW5lY3V0PTQwIGludHJhX3JlZnJlc2g9MCByY19sb29rYWhlYWQ9NDAg"
|
||||||
|
"cmM9Y3JmIG1idHJlZT0xIGNyZj0yMy4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0"
|
||||||
|
"ZXA9NCBpcF9yYXRpbz0xLjQwIGFxPTE6MS4wMACAAAAAIWWIhAAR//73iB8yy2+catdyEeesVP1G"
|
||||||
|
"Ixltc+dmuhineQAAAApBmiRsQQ/+qlfeAAAACEGeQniHfwW9AAAACAGeYXRDfwd8AAAACAGeY2pD"
|
||||||
|
"fwd9AAAAEEGaaEmoQWiZTAh3//6pnTUAAAAKQZ6GRREsO/8FvQAAAAgBnqV0Q38HfQAAAAgBnqdq"
|
||||||
|
"Q38HfAAAABBBmqlJqEFsmUwIb//+p4+I"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_server(delay_holder):
|
||||||
|
"""Serves the fixture video, optionally after a delay, plus a dead URL."""
|
||||||
|
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path.startswith("/slow") or self.path.startswith("/fast"):
|
||||||
|
time.sleep(delay_holder["slow"] if self.path.startswith("/slow") else 0)
|
||||||
|
body = base64.b64decode(MP4_B64)
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "video/mp4")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Accept-Ranges", "none")
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
return
|
||||||
|
self.send_response(404)
|
||||||
|
self.send_header("Content-Length", "0")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def do_HEAD(self):
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "video/mp4")
|
||||||
|
self.send_header("Content-Length", "0")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
httpd = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Handler)
|
||||||
|
httpd.daemon_threads = True
|
||||||
|
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||||
|
return httpd
|
||||||
|
|
||||||
|
|
||||||
|
SEED = """([server, channel]) => {
|
||||||
|
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||||
|
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||||
|
localStorage.removeItem('session');
|
||||||
|
localStorage.setItem('favorites', JSON.stringify([]));
|
||||||
|
}"""
|
||||||
|
|
||||||
|
STATE = """() => {
|
||||||
|
const player = document.getElementById('custom-player');
|
||||||
|
const video = player.querySelector('.cp-video');
|
||||||
|
const card = document.querySelector('#video-grid .video-card');
|
||||||
|
return {
|
||||||
|
shown: player.classList.contains('open'),
|
||||||
|
preloading: player.classList.contains('is-preloading'),
|
||||||
|
active: !!(App.player.isActive && App.player.isActive()),
|
||||||
|
cardBusy: !!(card && card.classList.contains('is-loading')),
|
||||||
|
src: video ? (video.currentSrc || video.getAttribute('src') || '') : '',
|
||||||
|
readyState: video ? video.readyState : -1,
|
||||||
|
muted: video ? video.muted : null,
|
||||||
|
bodyOverflow: document.body.style.overflow,
|
||||||
|
error: !!(player.querySelector('.cp-error') && !player.querySelector('.cp-error').hidden),
|
||||||
|
};
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
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 item(video_url, ident="test:0"):
|
||||||
|
"""One listing item whose formats are already resolved, so opening it goes
|
||||||
|
straight to playback rather than through a resolve first."""
|
||||||
|
return {
|
||||||
|
"id": ident,
|
||||||
|
"title": "A video",
|
||||||
|
"url": "https://example.test/watch",
|
||||||
|
"channel": "test",
|
||||||
|
"duration": 1,
|
||||||
|
"thumb": "",
|
||||||
|
"tags": [],
|
||||||
|
"meta": {
|
||||||
|
"url": video_url,
|
||||||
|
"http_headers": {},
|
||||||
|
"isLive": False,
|
||||||
|
"formats": [{"url": video_url, "ext": "mp4", "height": 240, "protocol": "https",
|
||||||
|
"vcodec": "avc1", "acodec": "none"}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def open_first(page):
|
||||||
|
"""Click the first card. Forced, because the hover preview parks a <video>
|
||||||
|
over the thumbnail -- which is inside the card and so opens it just the
|
||||||
|
same, but Playwright won't click through it on its own."""
|
||||||
|
page.click("#video-grid .video-card", force=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
c = Checks()
|
||||||
|
delay = {"slow": 0}
|
||||||
|
httpd = make_server(delay)
|
||||||
|
media = f"http://127.0.0.1:{httpd.server_address[1]}"
|
||||||
|
|
||||||
|
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": 1400, "height": 1000})
|
||||||
|
crashes = []
|
||||||
|
page.on("pageerror", lambda e: crashes.append(str(e)))
|
||||||
|
|
||||||
|
items = {"body": [item(f"{media}/fast.mp4")]}
|
||||||
|
page.route("**/api/videos", lambda route: route.fulfill(
|
||||||
|
status=200, content_type="application/json",
|
||||||
|
body=json.dumps({"items": items["body"], "pageInfo": {"hasNextPage": False}})))
|
||||||
|
|
||||||
|
page.goto(BASE, wait_until="domcontentloaded")
|
||||||
|
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||||
|
page.goto(BASE, wait_until="load")
|
||||||
|
page.wait_for_selector(".video-card", timeout=60000)
|
||||||
|
page.wait_for_timeout(800)
|
||||||
|
|
||||||
|
print("\na video that loads quickly")
|
||||||
|
open_first(page)
|
||||||
|
page.wait_for_timeout(2500)
|
||||||
|
after = page.evaluate(STATE)
|
||||||
|
c.ok("the player is on screen", after["shown"], str(after))
|
||||||
|
c.ok("it is no longer preloading", not after["preloading"], str(after))
|
||||||
|
c.ok("with data, not an empty frame", after["readyState"] >= 2, str(after["readyState"]))
|
||||||
|
c.ok("the card has stopped spinning", not after["cardBusy"], str(after))
|
||||||
|
c.ok("sound is back on", after["muted"] is False, str(after["muted"]))
|
||||||
|
c.ok("and the page behind it is locked", after["bodyOverflow"] == "hidden",
|
||||||
|
after["bodyOverflow"])
|
||||||
|
page.evaluate("() => App.player.close()")
|
||||||
|
page.wait_for_timeout(600)
|
||||||
|
|
||||||
|
print("\na video that takes its time")
|
||||||
|
delay["slow"] = 3.0
|
||||||
|
items["body"] = [item(f"{media}/slow.mp4", "test:slow")]
|
||||||
|
page.evaluate("() => { App.videos.resetGrid(); App.videos.loadVideos(); }")
|
||||||
|
page.wait_for_selector(".video-card", timeout=60000)
|
||||||
|
page.wait_for_timeout(600)
|
||||||
|
open_first(page)
|
||||||
|
page.wait_for_timeout(900)
|
||||||
|
during = page.evaluate(STATE)
|
||||||
|
c.ok("the player is not up yet", not during["shown"], str(during))
|
||||||
|
c.ok("but the session is live", during["active"] and during["preloading"], str(during))
|
||||||
|
c.ok("the card says it is working on it", during["cardBusy"], str(during))
|
||||||
|
c.ok("nothing can be heard from it", during["muted"] is True, str(during["muted"]))
|
||||||
|
c.ok("and the page is still the page", during["bodyOverflow"] != "hidden",
|
||||||
|
during["bodyOverflow"])
|
||||||
|
# The grid is still usable underneath: the invisible player must not be
|
||||||
|
# eating clicks.
|
||||||
|
c.ok("the grid underneath is still reachable",
|
||||||
|
page.evaluate("""() => {
|
||||||
|
const el = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2);
|
||||||
|
return !!(el && !el.closest('#custom-player'));
|
||||||
|
}"""))
|
||||||
|
page.wait_for_timeout(4000)
|
||||||
|
later = page.evaluate(STATE)
|
||||||
|
c.ok("once the data lands, the player appears", later["shown"], str(later))
|
||||||
|
c.ok("and the card is released", not later["cardBusy"], str(later))
|
||||||
|
page.evaluate("() => App.player.close()")
|
||||||
|
page.wait_for_timeout(600)
|
||||||
|
|
||||||
|
print("\nchanging your mind while it loads")
|
||||||
|
delay["slow"] = 8.0
|
||||||
|
open_first(page)
|
||||||
|
page.wait_for_timeout(700)
|
||||||
|
c.ok("Escape cancels a pending open",
|
||||||
|
page.evaluate("""() => {
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||||
|
return true;
|
||||||
|
}"""))
|
||||||
|
page.wait_for_timeout(600)
|
||||||
|
cancelled = page.evaluate(STATE)
|
||||||
|
c.ok("nothing is left running", not cancelled["active"], str(cancelled))
|
||||||
|
c.ok("the player never appeared", not cancelled["shown"], str(cancelled))
|
||||||
|
c.ok("and the card is free again", not cancelled["cardBusy"], str(cancelled))
|
||||||
|
|
||||||
|
print("\na video that will not load at all")
|
||||||
|
items["body"] = [item(f"{media}/nope.mp4", "test:dead")]
|
||||||
|
page.evaluate("() => { App.videos.resetGrid(); App.videos.loadVideos(); }")
|
||||||
|
page.wait_for_selector(".video-card", timeout=60000)
|
||||||
|
page.wait_for_timeout(600)
|
||||||
|
open_first(page)
|
||||||
|
page.wait_for_timeout(6000)
|
||||||
|
failed = page.evaluate(STATE)
|
||||||
|
c.ok("the player is shown so the failure can be read", failed["shown"], str(failed))
|
||||||
|
c.ok("with its error up", failed["error"], str(failed))
|
||||||
|
c.ok("and the card no longer spins", not failed["cardBusy"], str(failed))
|
||||||
|
page.evaluate("() => App.player.close()")
|
||||||
|
|
||||||
|
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
httpd.shutdown()
|
||||||
|
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