diff --git a/CHANGELOG.md b/CHANGELOG.md index 77d814a9..c6fe66f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ ### Fixed +- **A completed assistant turn no longer renders twice in the feed.** In `renderMessages()`, the live-turn preserve guard kept the in-flight assistant node across the DOM rebuild whenever it held any rendered content, so when a stream ended but its inflight bookkeeping was not yet cleared the just-completed turn was re-attached over the rebuilt settled transcript (a first copy without the model label, then a second with it). The stored data was always clean (state.db, sidecar, and `/api/session` each hold one row) — the duplicate existed only in the DOM. Preservation now requires a provable live owner (`S.activeStreamId` or explicit live-projection markers) instead of bare DOM content; the mid-stream flicker guard and blank-turn guard are preserved. Thanks @webtecnica. (#7109) + +- **Visible inline chat videos no longer appear stalled after metadata.** Every inline video stayed at `preload="metadata"`, so a currently-visible mobile player could look frozen because the browser stops loading after metadata. A shared IntersectionObserver now promotes a near-viewport video to `preload="auto"` (and immediately on play), keeps off-screen history videos at metadata to avoid buffering every historical video, and unobserves nodes removed before they intersect. No media URL, caching, or controls change. Thanks @allenliang2022. (#7076) + - **Profile goal contract no longer drifts across profiles on older agent builds.** On Hermes Agent builds where the context-override API was exposed but `SessionDB()` still used the import-time default DB path (v2026.5.28–v2026.7.20), a goal mutation in one profile could overwrite another profile's goal. Native profile-goal persistence is now gated on the agent supporting *both* context-home resolution *and* call-time DB-path selection; otherwise it falls back to the legacy per-profile manager. Profiles stay isolated. Thanks @ticketclosed-won. (#6899) - **Packaged `hermes-webui` console entry point.** `pip install`-style wheel installs now expose a `hermes-webui` command that boots the WebUI through `bootstrap:main` — the same launcher path as `python server.py`, so agent-interpreter discovery and dependency validation run before the server starts (a direct `server:main` entry started the UI but broke every chat with a missing-dependency error). Thanks @webtecnica. (#6742) diff --git a/static/ui.js b/static/ui.js index bcd41c7b..2b47b8e9 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2586,6 +2586,37 @@ function _applyMediaPlaybackRate(media, rate=_getStoredMediaPlaybackRate()){ media.playbackRate=rate; _syncMediaSpeedButtons(media.closest('.msg-media-editor,.preview-media-wrap'),rate); } +let _mediaVisibilityObserver=null; +function _promoteVisibleVideoPreload(video){ + if(!video||!video.matches||!video.matches('.msg-media-video')) return; + if(video.isConnected===false) return; + if(video.dataset&&video.dataset.visiblePreload==='1') return; + if(video.dataset) video.dataset.visiblePreload='1'; + video.preload='auto'; + // Off-screen history stays metadata-only. Once a card approaches the + // viewport, restart just that resource so Chromium fills a playable buffer. + if(video.paused&&video.readyState<4&&typeof video.load==='function') video.load(); +} +function _observeVideoPreload(video){ + if(!video||!video.matches||!video.matches('.msg-media-video')) return; + if(video.dataset&&video.dataset.visiblePreload==='1') return; + if(_mediaVisibilityObserver) _mediaVisibilityObserver.observe(video); +} +function _unobserveVideoPreload(video){ + if(!video||!_mediaVisibilityObserver) return; + _mediaVisibilityObserver.unobserve(video); +} +function _initMediaVisibilityObserver(){ + if(_mediaVisibilityObserver||typeof IntersectionObserver==='undefined') return; + _mediaVisibilityObserver=new IntersectionObserver(entries=>{ + for(const entry of entries){ + if(!entry.isIntersecting) continue; + const video=entry.target; + _unobserveVideoPreload(video); + _promoteVisibleVideoPreload(video); + } + },{root:null,rootMargin:'300px 0px',threshold:0.01}); +} function _mediaKindForName(name=''){ const clean=String(name||'').split('?')[0].toLowerCase(); if(_VIDEO_EXTS.test(clean)) return 'video'; @@ -2752,21 +2783,40 @@ document.addEventListener("loadedmetadata", e=>{ _applyMediaPlaybackRate(e.target); } },true); +document.addEventListener('play',e=>{ + if(e.target&&e.target.matches&&e.target.matches('.msg-media-video')){ + _promoteVisibleVideoPreload(e.target); + } +},true); function _initMediaPlaybackObserver(){ if(!document.body||window._mediaPlaybackObserver) return; + _initMediaVisibilityObserver(); window._mediaPlaybackObserver=new MutationObserver(records=>{ for(const rec of records){ + for(const node of rec.removedNodes||[]){ + if(!node||node.nodeType!==1) continue; + const videos=[]; + if(node.matches&&node.matches('.msg-media-video')) videos.push(node); + if(node.querySelectorAll) videos.push(...node.querySelectorAll('.msg-media-video')); + videos.forEach(_unobserveVideoPreload); + } for(const node of rec.addedNodes||[]){ if(!node||node.nodeType!==1) continue; const media=[]; if(node.matches&&node.matches('audio,video')) media.push(node); if(node.querySelectorAll) media.push(...node.querySelectorAll('audio,video')); - media.forEach(m=>_applyMediaPlaybackRate(m)); + media.forEach(m=>{ + _applyMediaPlaybackRate(m); + _observeVideoPreload(m); + }); } } }); window._mediaPlaybackObserver.observe(document.body,{childList:true,subtree:true}); - document.querySelectorAll('audio,video').forEach(m=>_applyMediaPlaybackRate(m)); + document.querySelectorAll('audio,video').forEach(m=>{ + _applyMediaPlaybackRate(m); + _observeVideoPreload(m); + }); } if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',_initMediaPlaybackObserver); else _initMediaPlaybackObserver(); @@ -16539,22 +16589,27 @@ function renderMessages(options){ if(sid&&INFLIGHT[sid]){ const _lt=document.getElementById('liveAssistantTurn'); if(_lt&&(!_lt.dataset||!_lt.dataset.sessionId||_lt.dataset.sessionId===sid)){ - // Blank-turn fix (对话消失): only preserve the live turn across the DOM - // wipe if it is GENUINELY live — either an active stream is still running - // (S.activeStreamId set: the #3877 mid-stream flicker case this preserve - // was written for), or the turn already holds real rendered content (a - // visible answer body, a tool card, or a reasoning row). A DEAD shell — - // an interrupted turn whose stream dropped (S.activeStreamId cleared to - // null) but whose INFLIGHT[sid] entry was not cleaned, leaving only an - // empty worklog group ("Processed Ns" with no body/tool rows) — must NOT - // be preserved: re-attaching it on a session-updated swap re-render pins - // an avatar-only empty turn OVER the settled transcript, hiding the real - // (already-persisted) answer. That is the reported blank. Reproduced + - // fix verified on an isolated debug instance (8710): stale INFLIGHT + - // empty live-turn survived the swap → blank; gating on real-content / - // active-stream clears it while a genuine live turn still renders. - const _hasRealLiveContent=!!_lt.querySelector('.msg-body, .tool-card-row, .wl-reason'); - if(_hasRealLiveContent || S.activeStreamId){ + // Live-turn preservation requires a PROVABLE live owner — never bare DOM + // content. (#6948) The live turn is preserved across the wipe only while + // (a) the stream is genuinely active (S.activeStreamId — the #3877 + // mid-stream flicker case this preserve was written for), or (b) the + // current message projection (S.messages) still carries explicit + // live-assistant evidence — a client-side _live / _activityBurstId / + // _liveSegmentSeq marker merged in from the INFLIGHT tail or a server + // journal snapshot (the reconnect / terminal-projection case). A settled + // transcript has neither, so a contentful but DEAD live node (stream + // ended — S.activeStreamId cleared — while INFLIGHT[sid] was not yet + // cleaned) is no longer preserved: re-attaching it over the settled + // transcript pinned a second copy of the same assistant message (#6948; + // data was always clean — state.db, sidecar, and /api/session each hold + // one row; the duplicate existed only in the rendered DOM). The #5390 + // blank-turn guard (对话消失) is preserved: a dead EMPTY shell has no live + // projection either, so it is still dropped with the wipe instead of + // pinning an avatar-only blank turn over the settled answer. + const _hasLiveAssistantProjection=Array.isArray(S.messages)&&S.messages.some(m=> + m&&m.role==='assistant'&&(m._live||m._activityBurstId!==undefined||m._liveSegmentSeq!==undefined) + ); + if(S.activeStreamId || _hasLiveAssistantProjection){ _preservedLiveTurn=_lt; } } diff --git a/tests/test_blank_live_turn_preserve_guard.py b/tests/test_blank_live_turn_preserve_guard.py index eeb652fc..c7b7f18c 100644 --- a/tests/test_blank_live_turn_preserve_guard.py +++ b/tests/test_blank_live_turn_preserve_guard.py @@ -1,5 +1,5 @@ -"""Regression: blank assistant turn (对话消失) — dead empty live-turn shell -survives a session-updated swap re-render and hides the settled answer. +"""Regression: blank assistant turn (对话消失) AND duplicate settled render (#6948) +— the live-turn preserve guard must require a PROVABLE live owner. Root cause (reproduced + fixed on an isolated debug instance, 2026-07-01) ------------------------------------------------------------------------ @@ -15,20 +15,47 @@ whenever `INFLIGHT[sid]` existed: } When a turn's SSE dropped (S.activeStreamId cleared to null) but its -`INFLIGHT[sid]` entry was NOT cleaned, the live turn was a DEAD empty shell — +`INFLIGHT[sid]` entry was NOT cleaned, the live turn was a DEAD EMPTY shell — avatar + an empty worklog group ("Processed Ns", no body/tool rows). On the next `session-updated` self-heal swap (loadSession force + keepStaleUntilLoaded, common under repeated self-wake restarts), the guard re-attached that empty shell OVER the freshly-wiped transcript, pinning an avatar-only blank turn on top of the already-persisted answer. That is the reported "对话消失". -Fix ---- -Preserve the live turn ONLY when it is genuinely live: an active stream is -still running (`S.activeStreamId`) — the #3877 case — OR it already holds real -rendered content (`.msg-body`, `.tool-card-row`, or `.wl-reason`). A dead empty -shell (no content, no active stream) is no longer preserved, so the swap wipe -drops it and the settled transcript renders normally. +First fix (#5390) +----------------- +Gate preservation on "real rendered content OR an active stream": + + const _hasRealLiveContent=!!_lt.querySelector( + '.msg-body, .tool-card-row, .wl-reason' + ); + if(_hasRealLiveContent || S.activeStreamId){ _preservedLiveTurn=_lt; } + +That stopped the EMPTY shell but made rendered content itself act as authority. + +Second bug (#6948) +------------------ +After an assistant turn COMPLETES, the same message can render twice in the +feed (first copy without model label, second with it). Data was always clean — +state.db, the sidecar, and /api/session each hold one row; the duplicate is a +rendering artifact: a stale live-turn DOM node survives the settled-transcript +swap and is re-attached on top of it. When the stream has ended (S.activeStreamId +nulled) but `INFLIGHT[sid]` has not been cleaned yet, `_hasRealLiveContent` is +still true (the completed body is in the DOM), so the DEAD live turn is +preserved and re-attached OVER the settled transcript — two copies. + +Final contract (this file) +-------------------------- +Preservation requires a PROVABLE live owner: current-session ownership, an +`INFLIGHT` owner, and EITHER an active stream (`S.activeStreamId`) OR explicit +live-assistant evidence in the current message projection (`S.messages` — a +client-side `_live` / `_activityBurstId` / `_liveSegmentSeq` marker merged in +from the INFLIGHT tail or a server journal snapshot, i.e. the reconnect / +terminal-projection case). Bare `.msg-body` presence never proves liveness: a +settled transcript has no live projection, so the durable settled transcript +wins once no live owner remains. Both reported regressions are covered: the +dead EMPTY shell (no projection → dropped) and the dead CONTENTFUL turn +(no projection → dropped, settled transcript renders exactly once). """ import pathlib import re @@ -54,24 +81,39 @@ def _preserve_guard_src(): class TestBlankLiveTurnPreserveGuard: - def test_guard_requires_real_content_or_active_stream(self): + def test_guard_requires_live_owner_not_dom_content(self): guard = _preserve_guard_src() - # Must gate the preserve on real content OR an active stream — not merely - # on INFLIGHT existence. - assert "_hasRealLiveContent" in guard, ( - "preserve guard must compute whether the live turn has real content" - ) - assert ".msg-body" in guard and ".tool-card-row" in guard and ".wl-reason" in guard, ( - "real-content check must look for a visible body / tool card / reason row" - ) + # Must gate the preserve on a PROVABLE live owner — an active stream or + # explicit live-assistant evidence in the message projection — never on + # bare DOM content presence. (#6948) assert "S.activeStreamId" in guard, ( "preserve guard must still preserve a genuinely-streaming turn (#3877)" ) + assert "_hasLiveAssistantProjection" in guard, ( + "preserve guard must consult the current message projection for " + "live-assistant evidence" + ) + assert "S.messages" in guard, ( + "live-assistant evidence must come from the message projection" + ) + # The projection check must look at assistant-role messages carrying a + # client-side live marker (_live / _activityBurstId / _liveSegmentSeq). + assert "role==='assistant'" in guard, ( + "live-assistant evidence must be scoped to assistant messages" + ) + assert "m._live" in guard and "_activityBurstId" in guard and "_liveSegmentSeq" in guard, ( + "live-assistant evidence must accept the client-side live markers" + ) # The assignment must be inside the new conditional. assert re.search( - r"if\(_hasRealLiveContent\s*\|\|\s*S\.activeStreamId\)\{\s*_preservedLiveTurn=_lt;", + r"if\(S\.activeStreamId\s*\|\|\s*_hasLiveAssistantProjection\)\{\s*_preservedLiveTurn=_lt;", guard, - ), "preserve assignment must be gated by (hasRealContent || activeStreamId)" + ), "preserve assignment must be gated by (activeStreamId || live projection)" + # DOM-content presence must NOT act as authority (#6948 regression guard). + assert "_hasRealLiveContent" not in guard, ( + "bare .msg-body presence must not prove liveness — a contentful dead " + "live turn re-attached over the settled transcript is the #6948 duplicate" + ) def test_runtime_rejects_dead_shell_preserves_live(self): node = shutil.which("node") @@ -81,30 +123,138 @@ class TestBlankLiveTurnPreserveGuard: script = textwrap.dedent( """ const assert=require('assert'); - // Mirror the guard's decision predicate exactly. - function guardWouldPreserve(lt, activeStreamId){ + // Mirror the guard's decision predicate exactly: current-session + // ownership + INFLIGHT owner + (active stream || live projection). + function guardWouldPreserve(lt, activeStreamId, messages, inflightOwner, sessionId){ + if(!inflightOwner) return false; // no-owner rejected if(!lt) return false; - const hasReal=!!lt.querySelector('.msg-body, .tool-card-row, .wl-reason'); - return hasReal || !!activeStreamId; + if(lt.dataset&<.dataset.sessionId&<.dataset.sessionId!==sessionId){ + return false; // wrong-session rejected + } + const hasLiveProjection=Array.isArray(messages)&&messages.some(m=> + m&&m.role==='assistant'&&(m._live||m._activityBurstId!==undefined||m._liveSegmentSeq!==undefined) + ); + return !!activeStreamId || hasLiveProjection; } // Minimal DOM element stub with querySelector over a class set. - function el(classes){ + function el(classes, dataset){ const set=new Set(classes||[]); - return { querySelector(sel){ - // sel is a comma list of .class tokens - return sel.split(',').map(s=>s.trim().replace(/^\\./,'')) - .some(c=>set.has(c)) ? {} : null; - }}; + return { + dataset: dataset||null, + querySelector(sel){ + // sel is a comma list of .class tokens + return sel.split(',').map(s=>s.trim().replace(/^\\\\./,'')) + .some(c=>set.has(c)) ? {} : null; + } + }; } + const settledMessages=[]; // no live markers + const liveMessages=[{role:'assistant',content:'answer',_live:true}]; + const burstLiveMessages=[{role:'assistant',content:'x',_activityBurstId:3}]; + const segLiveMessages=[{role:'assistant',content:'x',_liveSegmentSeq:1}]; const deadShell = el([]); // empty worklog shell, no content - const withBody = el(['msg-body']); - const withTool = el(['tool-card-row']); - const withReason= el(['wl-reason']); - assert.strictEqual(guardWouldPreserve(deadShell, null), false, 'dead shell must NOT be preserved'); - assert.strictEqual(guardWouldPreserve(deadShell, 'sid'), true, 'streaming empty shell preserved (#3877)'); - assert.strictEqual(guardWouldPreserve(withBody, null), true, 'body content preserved'); - assert.strictEqual(guardWouldPreserve(withTool, null), true, 'tool card preserved'); - assert.strictEqual(guardWouldPreserve(withReason, null), true, 'reason row preserved'); + const withBody = el(['msg-body']); // contentful (completed) turn + const wrongSess = el(['msg-body'], {sessionId:'other-sid'}); + // #5390: dead empty shell must NOT be preserved (settled or live projection). + assert.strictEqual(guardWouldPreserve(deadShell, null, settledMessages, true, 's1'), false, 'dead shell must NOT be preserved'); + // #3877: streaming shell IS preserved regardless of projection. + assert.strictEqual(guardWouldPreserve(deadShell, 'sid', settledMessages, true, 's1'), true, 'streaming empty shell preserved (#3877)'); + // #6948: a CONTENTFUL dead live node (stream ended, stale INFLIGHT, + // settled projection) must NOT be preserved — this is the duplicate. + assert.strictEqual(guardWouldPreserve(withBody, null, settledMessages, true, 's1'), false, 'contentful dead turn must NOT be preserved (#6948)'); + // Reconnect: contentful DOM + explicit live projection IS preserved. + assert.strictEqual(guardWouldPreserve(withBody, null, liveMessages, true, 's1'), true, 'reconnect live projection preserved'); + assert.strictEqual(guardWouldPreserve(withBody, 'sid', settledMessages, true, 's1'), true, 'active stream preserved'); + // The projection markers are accepted as live-assistant evidence. + assert.strictEqual(guardWouldPreserve(withBody, null, burstLiveMessages, true, 's1'), true, '_activityBurstId projection preserved'); + assert.strictEqual(guardWouldPreserve(withBody, null, segLiveMessages, true, 's1'), true, '_liveSegmentSeq projection preserved'); + // Ownership gates: wrong-session DOM and no-owner are always rejected. + assert.strictEqual(guardWouldPreserve(wrongSess, 'sid', liveMessages, true, 's1'), false, 'wrong-session DOM rejected'); + assert.strictEqual(guardWouldPreserve(withBody, 'sid', liveMessages, false, 's1'), false, 'no-owner (no INFLIGHT) rejected'); + console.log('OK'); + """ + ) + out = subprocess.run([node, "-e", script], capture_output=True, text=True) + assert out.returncode == 0, f"node harness failed: {out.stderr}\n{out.stdout}" + assert "OK" in out.stdout + + def test_settled_transcript_with_stale_inflight_renders_once(self): + """#6948 browser-lifecycle equivalent in the node harness: the full + renderMessages decision — capture guard + wipe + rebuild — must yield + exactly ONE assistant row when a contentful live DOM node exists but + the projection is settled (stream ended, INFLIGHT[sid] stale).""" + node = shutil.which("node") + if not node: + import pytest + pytest.skip("node not available") + script = textwrap.dedent( + """ + const assert=require('assert'); + // Faithful mirror of the renderMessages preserve decision + the + // re-attach step. Semantics of the real code (static/ui.js): + // - the rebuilt DOM renders settled assistant rows from the + // projection; a live-projection assistant renders as the + // rebuilt live turn; + // - when the guard captured the live node it REPLACES the rebuilt + // live row (segment/whole-turn swap) — never appends on top of a + // row that already exists in the projection; + // - mid-stream the in-progress turn is NOT yet in the projection, + // so the preserved node appends as the live tail (one live row); + // - when the guard did NOT capture (settled + stale INFLIGHT), the + // rebuilt settled rows stand alone — exactly one copy. + function renderMessagesSim(lt, activeStreamId, messages, inflightOwner){ + let preservedLiveTurn=null; + const sid='s1'; + if(sid&&inflightOwner){ + if(lt&&(!lt.dataset||!lt.dataset.sessionId||lt.dataset.sessionId===sid)){ + const hasLiveProjection=Array.isArray(messages)&&messages.some(m=> + m&&m.role==='assistant'&&(m._live||m._activityBurstId!==undefined||m._liveSegmentSeq!==undefined) + ); + if(activeStreamId || hasLiveProjection){ preservedLiveTurn=lt; } + } + } + const liveMark=m=>m&&m.role==='assistant'&&(m._live||m._activityBurstId!==undefined||m._liveSegmentSeq!==undefined); + const hasLiveRow=Array.isArray(messages)&&messages.some(liveMark); + const finalRows=(messages||[]).filter(m=>m&&m.role==='assistant'&&!liveMark(m)); + if(preservedLiveTurn){ + // Swap-in replaces the rebuilt live row when one exists; the + // mid-stream case appends the live tail (turn not yet in the + // projection). Either way: one row for the turn. + finalRows.push({role:'assistant',_preserved:true}); + }else if(hasLiveRow){ + finalRows.push({role:'assistant',_rebuiltLive:true}); + } + return finalRows; + } + const settled=[{role:'user',content:'hi'},{role:'assistant',content:'final answer'}]; + const midStreamMessages=[{role:'user',content:'hi'}]; // turn not yet persisted + const liveTail=[{role:'user',content:'hi'},{role:'assistant',content:'partial',_live:true}]; + const contentfulLiveNode={dataset:{sessionId:'s1'},querySelector:()=>({})}; + // The bug (#6948): stale INFLIGHT + settled projection + contentful + // dead node → TWO rows before the fix; exactly ONE after. + assert.strictEqual( + renderMessagesSim(contentfulLiveNode, null, settled, true).length, 1, + 'settled transcript must render exactly once despite stale INFLIGHT + contentful DOM (#6948)' + ); + // Mid-stream: active stream keeps the live node attached (one live row). + assert.strictEqual( + renderMessagesSim(contentfulLiveNode, 'sid', midStreamMessages, true).length, 1, + 'mid-stream render must keep exactly one live row' + ); + // Reconnect: explicit live projection keeps the live node (one row). + assert.strictEqual( + renderMessagesSim(contentfulLiveNode, null, liveTail, true).length, 1, + 'reconnect with live projection must keep exactly one live row' + ); + // Rapid turn completion: a second settled turn cannot be duplicated. + const twoTurns=[ + {role:'user',content:'a'},{role:'assistant',content:'answer one'}, + {role:'user',content:'b'},{role:'assistant',content:'answer two'}, + ]; + assert.strictEqual( + renderMessagesSim(contentfulLiveNode, null, twoTurns, true).length, 2, + 'rapid turn completion must not duplicate settled assistant rows' + ); console.log('OK'); """ ) diff --git a/tests/test_visible_video_preload.py b/tests/test_visible_video_preload.py new file mode 100644 index 00000000..c057c108 --- /dev/null +++ b/tests/test_visible_video_preload.py @@ -0,0 +1,134 @@ +"""Behavioral tests for visibility-driven chat video preloading.""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") + + +def _extract_function(name: str) -> str: + start = UI_JS.index(f"function {name}(") + brace = UI_JS.index("{", start) + depth = 1 + i = brace + 1 + while depth: + if UI_JS[i] == "{": + depth += 1 + elif UI_JS[i] == "}": + depth -= 1 + i += 1 + return UI_JS[start:i] + + +def _run_node(body: str) -> dict: + script = "\n".join( + [ + "let _mediaVisibilityObserver=null;", + _extract_function("_promoteVisibleVideoPreload"), + _extract_function("_observeVideoPreload"), + _extract_function("_unobserveVideoPreload"), + _extract_function("_initMediaVisibilityObserver"), + body, + ] + ) + result = subprocess.run( + ["node", "-e", script], + cwd=ROOT, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_offscreen_video_stays_metadata_only_until_first_intersection(): + result = _run_node( + r""" +let callback=null; +const observed=[]; +const unobserved=[]; +global.IntersectionObserver=class { + constructor(cb,options){callback=cb;this.options=options;} + observe(v){observed.push(v);} + unobserve(v){unobserved.push(v);} +}; +const video={ + dataset:{}, preload:'metadata', paused:true, readyState:1, loads:0, + matches:s=>s==='.msg-media-video', load(){this.loads+=1;} +}; +_initMediaVisibilityObserver(); +_observeVideoPreload(video); +const before={preload:video.preload,loads:video.loads,observed:observed.length}; +callback([{target:video,isIntersecting:false}]); +const offscreen={preload:video.preload,loads:video.loads,unobserved:unobserved.length}; +callback([{target:video,isIntersecting:true}]); +const first={preload:video.preload,loads:video.loads,unobserved:unobserved.length}; +callback([{target:video,isIntersecting:true}]); +const second={preload:video.preload,loads:video.loads,unobserved:unobserved.length}; +console.log(JSON.stringify({before,offscreen,first,second,options:_mediaVisibilityObserver.options})); +""" + ) + + assert result["before"] == {"preload": "metadata", "loads": 0, "observed": 1} + assert result["offscreen"] == {"preload": "metadata", "loads": 0, "unobserved": 0} + assert result["first"] == {"preload": "auto", "loads": 1, "unobserved": 1} + assert result["second"] == {"preload": "auto", "loads": 1, "unobserved": 2} + assert result["options"]["rootMargin"] == "300px 0px" + + +def test_removed_video_is_unobserved_before_it_intersects(): + script = "\n".join( + [ + "let _mediaVisibilityObserver=null;", + _extract_function("_promoteVisibleVideoPreload"), + _extract_function("_observeVideoPreload"), + _extract_function("_unobserveVideoPreload"), + _extract_function("_initMediaVisibilityObserver"), + _extract_function("_initMediaPlaybackObserver"), + r""" +let ioCallback=null,mutationCallback=null; +const observed=[],unobserved=[]; +global.IntersectionObserver=class { + constructor(cb){ioCallback=cb;} + observe(v){observed.push(v);} + unobserve(v){unobserved.push(v);} +}; +global.MutationObserver=class { + constructor(cb){mutationCallback=cb;} + observe(){} +}; +const documentVideos=[]; +global.document={ + body:{}, readyState:'complete', + querySelectorAll:()=>documentVideos, + addEventListener:()=>{} +}; +global.window={}; +global._applyMediaPlaybackRate=()=>{}; +const video={nodeType:1,isConnected:true,dataset:{},preload:'metadata',loads:0,load(){this.loads+=1;},matches:s=>s==='.msg-media-video'||s==='audio,video',querySelectorAll:()=>[]}; +_initMediaPlaybackObserver(); +mutationCallback([{addedNodes:[video],removedNodes:[]}]); +mutationCallback([{addedNodes:[],removedNodes:[video]}]); +video.isConnected=false; +ioCallback([{target:video,isIntersecting:true}]); +console.log(JSON.stringify({observed:observed.length,unobserved:unobserved.length,preload:video.preload,loads:video.loads,marker:video.dataset.visiblePreload||null})); +""", + ] + ) + result = subprocess.run( + ["node", "-e", script], cwd=ROOT, capture_output=True, text=True, timeout=30 + ) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == { + "observed": 1, + "unobserved": 2, + "preload": "metadata", + "loads": 0, + "marker": None, + }