Files
jacuzzi/tests/smoke_ios_split.py
Simon b8a51d9ee2 Keep every panel playing on iOS, and give one of them the sound
iOS gives a page one audible video at a time. Start a second one with
sound and the system pauses the first, so on an iPhone a split of two
showed one panel playing and one stopped -- the feed-wide unmute set every
panel audible, and the panels then took the audio session from each other
in turn.

Muted video carries no such limit, so where the rule applies exactly one
panel keeps its sound and the rest keep their picture: four pictures moving
is what the split is for, and four audio tracks at once was never the
point. The sound follows whichever panel is asked for it, and a panel the
system stopped on the way is started again.

Nothing in the platform announces the rule, and by the time the symptom
shows -- a video we started, stopped by something that isn't us -- a panel
has already died, so it is read off the device rather than discovered.
Everywhere else nothing changes: panels keep their own sound.

No iPhone here, so the rule is installed into Chromium and the real feed is
driven through it. The last check breaks it on purpose -- unmuting every
panel by hand does stop one -- so the simulation can't quietly become a
no-op. Against the code before this commit the test fails seven checks,
showing exactly what was reported: both panels unmuted, the first paused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-09-21 16:39:17 +00:00

180 lines
7.2 KiB
Python

#!/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())