@@ -1591,6 +1591,130 @@ describe("HyperframesPlayer audio lock", () => {
15911591 } ) ;
15921592} ) ;
15931593
1594+ describe ( "HyperframesPlayer runtime ready handshake" , ( ) => {
1595+ // When the iframe runtime announces `{type: "ready"}` the player replays
1596+ // current bridge state (muted, volume, playback rate) so any control message
1597+ // that arrived before the iframe runtime registered its listener isn't lost.
1598+ // This fixes a deterministic race on warm-cache reloads of claude.ai and
1599+ // inside the Claude desktop Electron client where the iframe finishes
1600+ // loading after the player has already set audio-locked.
1601+ interface PlayerInternal extends HTMLElement {
1602+ muted : boolean ;
1603+ volume : number ;
1604+ audioLocked : boolean ;
1605+ playbackRate : number ;
1606+ iframe : HTMLIFrameElement ;
1607+ _onMessage : ( event : MessageEvent ) => void ;
1608+ }
1609+
1610+ let player : PlayerInternal ;
1611+ let frameWindow : Window ;
1612+ let postSpy : ReturnType < typeof vi . spyOn > ;
1613+
1614+ function readyMessage ( ) {
1615+ return new MessageEvent ( "message" , {
1616+ source : frameWindow ,
1617+ data : { source : "hf-preview" , type : "ready" } ,
1618+ } ) ;
1619+ }
1620+
1621+ function findControlCalls ( action : string ) {
1622+ return postSpy . mock . calls . filter ( ( call ) => {
1623+ const data = call [ 0 ] as { type ?: string ; action ?: string } ;
1624+ return data ?. type === "control" && data ?. action === action ;
1625+ } ) ;
1626+ }
1627+
1628+ beforeEach ( async ( ) => {
1629+ await import ( "./hyperframes-player.js" ) ;
1630+ player = document . createElement ( "hyperframes-player" ) as PlayerInternal ;
1631+ frameWindow = window ;
1632+ postSpy = vi . spyOn ( frameWindow , "postMessage" ) . mockImplementation ( ( ) => undefined ) ;
1633+ Object . defineProperty ( player . iframe , "contentWindow" , {
1634+ configurable : true ,
1635+ get : ( ) => frameWindow ,
1636+ } ) ;
1637+ document . body . appendChild ( player ) ;
1638+ } ) ;
1639+
1640+ afterEach ( ( ) => {
1641+ player . remove ( ) ;
1642+ vi . restoreAllMocks ( ) ;
1643+ } ) ;
1644+
1645+ it ( "replays current muted state when runtime emits ready" , ( ) => {
1646+ player . muted = true ;
1647+ postSpy . mockClear ( ) ;
1648+
1649+ player . _onMessage ( readyMessage ( ) ) ;
1650+
1651+ const muteCalls = findControlCalls ( "set-muted" ) ;
1652+ expect ( muteCalls ) . toHaveLength ( 1 ) ;
1653+ expect ( muteCalls [ 0 ] ?. [ 0 ] ) . toMatchObject ( {
1654+ source : "hf-parent" ,
1655+ type : "control" ,
1656+ action : "set-muted" ,
1657+ muted : true ,
1658+ } ) ;
1659+ } ) ;
1660+
1661+ it ( "replays volume and playback-rate alongside muted" , ( ) => {
1662+ player . volume = 0.5 ;
1663+ player . playbackRate = 1.25 ;
1664+ postSpy . mockClear ( ) ;
1665+
1666+ player . _onMessage ( readyMessage ( ) ) ;
1667+
1668+ expect ( findControlCalls ( "set-muted" ) ) . toHaveLength ( 1 ) ;
1669+ expect ( findControlCalls ( "set-volume" ) [ 0 ] ?. [ 0 ] ) . toMatchObject ( {
1670+ action : "set-volume" ,
1671+ volume : 0.5 ,
1672+ } ) ;
1673+ expect ( findControlCalls ( "set-playback-rate" ) [ 0 ] ?. [ 0 ] ) . toMatchObject ( {
1674+ action : "set-playback-rate" ,
1675+ playbackRate : 1.25 ,
1676+ } ) ;
1677+ } ) ;
1678+
1679+ it ( "replays the muted state forced by audio-locked" , ( ) => {
1680+ // The audio-locked attribute is the original motivating case for this
1681+ // handshake — its `muted = true` side effect must survive an iframe race.
1682+ player . setAttribute ( "audio-locked" , "" ) ;
1683+ expect ( player . muted ) . toBe ( true ) ;
1684+ postSpy . mockClear ( ) ;
1685+
1686+ player . _onMessage ( readyMessage ( ) ) ;
1687+
1688+ const muteCalls = findControlCalls ( "set-muted" ) ;
1689+ expect ( muteCalls ) . toHaveLength ( 1 ) ;
1690+ expect ( muteCalls [ 0 ] ?. [ 0 ] ) . toMatchObject ( { action : "set-muted" , muted : true } ) ;
1691+ } ) ;
1692+
1693+ it ( "replays again on a second ready (idempotent — iframe reloads emit again)" , ( ) => {
1694+ player . muted = true ;
1695+ postSpy . mockClear ( ) ;
1696+
1697+ player . _onMessage ( readyMessage ( ) ) ;
1698+ player . _onMessage ( readyMessage ( ) ) ;
1699+
1700+ expect ( findControlCalls ( "set-muted" ) ) . toHaveLength ( 2 ) ;
1701+ } ) ;
1702+
1703+ it ( "ignores ready events from a different window" , ( ) => {
1704+ postSpy . mockClear ( ) ;
1705+ const otherSource = { } as Window ;
1706+
1707+ player . _onMessage (
1708+ new MessageEvent ( "message" , {
1709+ source : otherSource ,
1710+ data : { source : "hf-preview" , type : "ready" } ,
1711+ } ) ,
1712+ ) ;
1713+
1714+ expect ( findControlCalls ( "set-muted" ) ) . toHaveLength ( 0 ) ;
1715+ } ) ;
1716+ } ) ;
1717+
15941718describe ( "HyperframesPlayer audio lock — Claude desktop UA fallback" , ( ) => {
15951719 // Some host renderers (observed on the Claude desktop Electron client) strip
15961720 // unknown custom-element attributes before they reach the DOM, so the
0 commit comments