From 02adfb59f37f146def787fa46ea81f91c7b8dda9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=A5=E8=AA=9E=E3=81=BB=E3=81=97=E3=81=9E=E3=82=89?= <92282870+YueyuHoshizora@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:50:33 +0800 Subject: [PATCH 01/38] feat(widget): add confirmed controller room closure --- PLAN.md | 3 + README.md | 2 + packages/client/README.md | 4 +- packages/client/src/element.ts | 233 +++++++-- packages/client/test/element.test.ts | 694 +++++++++++++++++++++++++++ 5 files changed, 906 insertions(+), 30 deletions(-) diff --git a/PLAN.md b/PLAN.md index 05a2aa7..96f3cde 100644 --- a/PLAN.md +++ b/PLAN.md @@ -820,3 +820,6 @@ N 路混音管線(decode→mix→encode)、不含自己的台上混音、lim 215. **最小 embed 明確確認關房(第194批,2026-10-16):** immutable193完整embed+SDK→validator→Hub十一例證明Leave只disconnect並保留controller grace/succession,既有closeRoom由controller判定且終止全房;功能只補獨立native danger button與全員離場/不可再加入的明確confirm,不改Leave。close自有client/room/pid/entry/tenure owner、revision/authority fence與exact attempt,確認前先reserve,render/dialog/SDK重入皆重查;觀測失權/換席/重連/能力消失永久撤銷當次確認,恢復只允許fresh click。own role/transferred先於state時先標記close與transfer兩側authority fence再render,避免其中一側setter重入未封鎖另一側;stage/mute/rename/無關participant不撤銷room-level consent。與transfer只互斥同步確認流程,marker跨owner退役保留至原dialog返回,不擴成全命令鎖。發送後不retry、不本機close/disconnect或ACK成功推論;terminal-before-heldACK可令promise disconnected而終止文案仍正確,新entry不受舊event/finally污染。錯誤限同owner/token/仍有效且feedback revision未變,SDK全域wire error不改。10檔範圍含3個inline fixture各加一個required selector,原assertions不弱化。無SDK/server/protocol/media/config/依賴變更、無真房間操作;完整controlled證據不代表native dialog/layout/keyboard/AT/硬體音訊驗收。 + + +216. **Widget 明確確認關房(第195批,2026-10-16):** 以PR22已合併main為parent,tree與immutable194基線相同;完整StageElement+公開SDK→validator→Hub十四例證明Leave保留controller grace/succession,而既有closeRoom只允許目前主控並終止全房。僅element原生獨立關房按鈕/說明與明確confirm,不改Leave、SDK或server。close自有client/room/pid/generation/tenure owner、exact attempt與error revision,實際shadow/actions/button containment及connected檢查防保留handler;觀測失格不讓同一次確認復活。own role/transferred先於state時,兩側pure authority notes在任何DOM sync前標記,避免舊getter讓同步重入越過資格;收到失格state或reconnecting/disconnected時也先撤銷舊確認,避免早期setter恢復snapshot使舊consent復活,原移交新tenure接納與target政策不變。關房與移交只互斥同步接納/確認,transfer使用可巢狀exact token集合,保留舊移交對話中換席後新移交的既有語義與preadmission reconciliation;不加等待ACK全命令鎖。錯誤限有效同owner/token且無較新feedback,舊finally不清新pending;不本機close、disconnect、成功提示或retry,terminal truth沿用原事件。五檔範圍為element源碼/測試及三份文件,無SDK/server/protocol/media/config/依賴變更、無真實房間操作。完整受控驗證不等於原生dialog/layout/keyboard/focus/AT或硬體音訊驗收。 diff --git a/README.md b/README.md index 3031421..dd6ed1b 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,8 @@ Web Component 的台上者收到伺服器本人品質回報後,可看到最近 Web Component 的「離開房間」會停止本機收聽/擷取及自動重連,保留屬性與音量/收聽靜音偏好;同一元素更換屬性或移除再掛載後仍等待手動「重新加入」。重入採用最新屬性,不保證原台位/主控身分;關房/被踢不提供這個重入操作。離房先退休 client,因此與元件移除一樣不轉送其晚到事件(含 disconnect status),也不新增合成 SDK 狀態。此 UI 使用既有 disconnect,不代表伺服器房間立即關閉;受控 DOM/SDK 假媒體測試仍不等於真實瀏覽器音訊驗收。 +Web Component 的目前主控另有獨立「關閉房間」,台上與台下皆可用;先確認「確定關閉此房間並讓所有人離場?關閉後無法再加入此房間。」,才呼叫既有 `closeRoom()`。離開仍只清理本人連線並沿用主控寬限,關閉則由伺服器終止全房。重連、失權、控制項被移出元件或缺少確認能力時不能送出;確認途中曾失去資格,即使恢復也須重新確認。與移交僅互斥同步確認流程,不新增所有命令的等待鎖;原有換席後再次移交的行為保留。取消、送出與 ACK 不清既有錯誤,也不本機宣告成功、離房或自動重試;關閉文案依既有終止事件,舊請求結果不能污染新席位或較新錯誤。SDK 全域錯誤政策不變。受控完整元件/實際 SDK/Hub 驗證不代表原生確認視窗、版面、鍵盤/焦點/AT 或硬體音訊驗收。 + 連線中取消、關房與被踢會完整停止麥克風/麥克風測試並清除待回覆請求;同一個 client 可在取消後重用。Web Component 快速移除/重掛載時只接受最新一次連線的事件,音訊解鎖失敗可重試,成功後隱藏按鈕。 本機收聽可用 `setPlaybackVolume(0.5)`/`setPlaybackMuted(true)` 調整,`playbackVolume`/`playbackMuted` 即時讀取;不影響麥克風或其他人,重連時保留。範例前端的「音訊設定」也提供音量與靜音收聽控制。 diff --git a/packages/client/README.md b/packages/client/README.md index 7f5cadf..53ebd84 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -220,8 +220,10 @@ WebSocket 建構失敗、hello 逾時或尚未進房的連線關閉,也會釋 - 舉手佇列位置來自目前 `state.hands` 的本人 ID;快照更新時重算,未找到本人則只顯示「已舉手」,重連期間標示「上次佇列」。保留寬限期的斷線者仍在佇列中;主控可任意核准人選,因此這不是保證的發言順序或等待時間。 - 輸出裝置選擇:已進房時可在「輸出裝置選擇(草稿)」手動重新整理揚聲器/耳機清單,再明確送出選擇;選單變更本身不呼叫 setSinkId。空字串沿用既有 SDK 的系統預設輸出,不支援 outputDeviceSupported 的瀏覽器顯示說明並停用此組控制。名稱以 textContent 顯示,匿名裝置有通用名稱;重新整理後缺失的草稿保留為未列出項,不偷偷改用其他裝置。重新整理與送出共用等待鎖,送出完成只恢復控制,草稿不表示目前實際輸出、已套用或可聽音訊;不會自動 enumerate、play、開麥克風或儲存裝置 ID,也不另呼叫權限申請 API;setSinkId 的裝置存取與權限仍由瀏覽器決定。 輸出設定作用於同一 client 的 audio 元件,因此等待/草稿/清單按 client 生命週期保留,而非按 room/participant 重設。公開 client 斷線、關房、換席或重入相同/不同房間時,沒有有效房間便隱藏停用;重新取得快照仍要等原輸出操作完成才可再次送出。一般重連保留顯示但停用。真正更換 client 或移除元件才退役舊 UI,既有 setSinkId 無取消 API,不能取消已交給瀏覽器的操作;舊 client 的遲到完成不操作新 client 的控制。永不完成的操作持續等待,沒有新增 timeout/強制解除;目前有效連線且可見 owner 的失敗可明確重試,隱藏或重連等待期間的失敗不另冒出錯誤。宿主直接呼叫 element.client.setOutputDevice 不經此 UI 鎖,不能保證與宿主並行選擇的先後結果。可用 ::part(output-device) 設定外觀;驗證限公開 SDK 與受控 DOM/audio/socket,原生裝置、權限、鍵盤、AT 與實際播放未驗收。 -- 內建介面:狀態列(房名.直播狀態.收聽中/已舉手/台上;舉手時顯示目前佇列第幾位)、錯誤列、`啟用音訊`(自動播放被擋時出現,失敗可重試、成功後隱藏)、觀眾的舉手/收回(含冷卻倒數)、台上的麥克風靜音/離開舞台、台下主控的返回舞台,以及所有已進房者的「靜音收聽/恢復收聽」。離開、關房與被踢後隱藏失效操作。除房名、舉手申請與舞台靜音/移下台以外的主控管理功能請改用 `element.client`(底層 `StageClient`,尚未建立實例、明確離房或元件已移除時為 null)。 +- 內建介面:狀態列(房名.直播狀態.收聽中/已舉手/台上;舉手時顯示目前佇列第幾位)、錯誤列、`啟用音訊`(自動播放被擋時出現,失敗可重試、成功後隱藏)、觀眾的舉手/收回(含冷卻倒數)、台上的麥克風靜音/離開舞台、台下主控的返回舞台,以及所有已進房者的「靜音收聽/恢復收聽」。離開、關房與被踢後隱藏失效操作。除房名、舉手申請、舞台靜音/移下台、混音增益、控制權移交與關閉房間以外的主控管理功能請改用 `element.client`(底層 `StageClient`,尚未建立實例、明確離房或元件已移除時為 null)。 - 所有已收到房間快照的參與者另有「離開房間」,初次 ACK 尚未到達或信令重連中也可使用。先退休目前 client 與舊請求/事件,再沿用 SDK disconnect 清理本機擷取、收聽及重連;顯示「已離開房間。」與手動「重新加入」,cleanup 等待期間按鈕停用並標示 aria-busy。屬性與收聽偏好保留,重新加入採用當時最新值;沒有自動重新加入,也不保證還原先前 participant、台位或主控身分。主控的伺服器寬限政策不變,離房不是關閉房間,不保證底層 socket 立即完成關閉。與元件移除相同,先退休的 client 事件(含 disconnect status)不再轉送,不另外產生合成 stage-status;`element.client` 在這次 teardown 後為 null。直接呼叫公開 `element.client.disconnect()` 仍採原本事件/提示行為,不會建立這個手動重入狀態。關房/被踢亦不新增重入按鈕。 +- 目前主控另有獨立「關閉房間」與全員離場提示,台上/台下皆可用。必須明確確認「確定關閉此房間並讓所有人離場?關閉後無法再加入此房間。」,才呼叫既有 `closeRoom()`;這不是 Leave,不本機設定離房/重新加入狀態。缺少 SDK 方法隱藏入口,缺少確認 API 不提供未確認替代。重連、斷線、失權、換房/席位、屬性重入、卸載或控制項脫離目前元件後停用/退役;確認中觀測到資格消失,即使立即恢復也需新確認。本人台上/台下、靜音與普通改名不撤銷房間層級資格。 +- 關房保留獨立操作 token,先保留再重繪/確認,當前等待不能重複送出;舊 owner 的結果與 finally 不影響新席位。關房與控制權移交只在同步確認/接納期間互斥,標記跨舊 owner 退役保留至同步呼叫返回;不因等待 ACK 鎖住所有命令。原有舊移交確認中換席後的新移交仍可進行。取消、開始與 ACK 成功不清除原錯誤,不自動重試、關閉本機或以 ACK 推論全房終止;權威 `closed` 事件沿用原提示,終止先於 ACK 可使 promise 拒絕但仍顯示房間已關閉。新增 catch 只在同 owner/attempt、仍有效且 error revision 未變時回報,SDK 全域 wire error 仍沿用原政策。受控完整 widget/實際 SDK/validator/Hub 證據不代表原生 dialog、鍵盤、焦點、AT 或硬體音訊驗收。 - 個人舞台按鈕依目前快照切換:台上者「離開舞台」,台下主控「返回舞台」,一般台下觀眾隱藏。主控下台仍保留主控權限;返回沿用伺服器的主控席位與准入規則,可能依既有 SDK 流程再次要求麥克風。強制靜音不妨礙下台。快照早於 ACK 時可先切換文字,但同一按鈕仍保持 pending/aria-busy,直到原請求完成;錯誤保留原本回報,不自動重試。 - 台上麥克風擷取失敗或自然中斷後,提供「重試麥克風」,只在使用者按下時呼叫既有 `retryMicrophone()`。接納後顯示「正在要求麥克風…」與 `aria-busy`,直到 `micready`/`micerror` 或該房間/台位生命週期結束;boolean 回傳不是完成擷取。回 `false` 時恢復按鈕並保留目前錯誤,同步例外仍可回報。重連期間停用但保留同席待處理狀態;下台、換房、離線或移除元件清除。自我/強制靜音不阻止擷取準備,也不會因此解除靜音。只清除屬於該次麥克風失敗的舊提示,較新的其他錯誤保留。房間初次/重試擷取的 `micready` 早於 sender 安裝,且事件與裝置替換及 `element.client.startMicTest()` 的獨立預覽共用;它會結束這個 UI 的擷取等待,但不證明房間麥克風已傳送、協商完成或可聽見,後續安裝錯誤照常顯示。 - 本機收聽靜音:布林屬性 `playback-muted` 與反映它的 `element.playbackMuted` property。加入 `playback-muted` 可初始靜音;移除屬性或設定 property 為 false 恢復收聽。HTML 布林屬性只看是否存在,`playback-muted="false"` 仍表示靜音。變更立即套用於本機 audio,不重新進房、不送控制請求、不改麥克風;更換房間、移除再掛載與 ws 重連保留設定。建議透過此 widget property 控制收聽偏好;直接修改 `element.client` 的 audio 設定不會反映回 widget attribute。按鈕使用原生 button 與 `aria-pressed`,未進房時隱藏。 diff --git a/packages/client/src/element.ts b/packages/client/src/element.ts index a344427..146c165 100644 --- a/packages/client/src/element.ts +++ b/packages/client/src/element.ts @@ -45,6 +45,7 @@ input[type=range] { flex: 1; min-width: 5rem; max-width: 12rem; } button { font: inherit; padding: .4rem .8rem; border-radius: var(--stage-radius, .5rem); border: 1px solid var(--stage-border, #8884); background: var(--stage-button-bg, #fff1); color: inherit; cursor: pointer; } button[hidden] { display: none; } button:disabled { opacity: .5; cursor: default; } +button.danger { color: var(--stage-error, #dc2626); border-color: currentColor; } button.primary { background: var(--stage-accent, #2563eb); border-color: transparent; color: var(--stage-accent-fg, #fff); } `; @@ -146,6 +147,18 @@ export class StageElement extends HTMLElement { private errorRevision = 0; private readonly leaveStage: HTMLButtonElement; private readonly roomAction: HTMLButtonElement; + private readonly roomClose: HTMLButtonElement; + private readonly roomCloseNotice: HTMLParagraphElement; + private readonly roomActions: HTMLDivElement; + private readonly widgetRoot: ShadowRoot; + private roomCloseOwner: { client: StageClient; roomId: string; participantId: string; generation: number; revision: number } | undefined; + private roomCloseAuthority: { client: StageClient; roomId: string; participantId: string; generation: number; revision: number; awaitingState: boolean } | undefined; + private roomCloseTerminalClient: StageClient | undefined; + private roomCloseAttempt: { owner: NonNullable; revision: number; invalid: boolean; errorRevision: number } | undefined; + private roomCloseRenderRevision = 0; + // Dialog scopes outlive retired owners, but never the synchronous admission stack. + private roomCloseDialog: object | undefined; + private readonly stageTransferDialogs = new Set(); private leftRoom = false; private roomExit: object | undefined; private readonly listen: HTMLButtonElement; @@ -165,7 +178,7 @@ export class StageElement extends HTMLElement { constructor() { super(); - const root = this.attachShadow({ mode: 'open' }); + const root = this.widgetRoot = this.attachShadow({ mode: 'open' }); const style = document.createElement('style'); style.textContent = STYLE; const panel = document.createElement('div'); @@ -232,7 +245,7 @@ export class StageElement extends HTMLElement { this.stageManagementNotice = document.createElement('p'); this.stageManagementList = document.createElement('ul'); this.stageManagementPanel.append(this.stageManagementSummary, this.stageManagementNotice, this.stageManagementList); - const actions = document.createElement('div'); + const actions = this.roomActions = document.createElement('div'); actions.part.add('actions'); this.unlock = document.createElement('button'); this.unlock.type = 'button'; this.unlock.className = 'primary'; this.unlock.textContent = '啟用音訊'; @@ -309,6 +322,15 @@ export class StageElement extends HTMLElement { this.roomAction = document.createElement('button'); this.roomAction.type = 'button'; this.roomAction.onclick = () => this.changeRoomPresence(); + this.roomClose = document.createElement('button'); + this.roomClose.type = 'button'; this.roomClose.className = 'danger'; this.roomClose.textContent = '關閉房間'; + this.roomClose.hidden = this.roomClose.disabled = true; + this.roomClose.setAttribute('aria-busy', 'false'); + this.roomClose.setAttribute('aria-describedby', 'room-close-notice'); + this.roomClose.onclick = () => this.closeRoom(); + this.roomCloseNotice = document.createElement('p'); + this.roomCloseNotice.id = 'room-close-notice'; this.roomCloseNotice.hidden = true; + this.roomCloseNotice.textContent = '關閉房間會讓所有人離場,且無法再加入此房間。離開房間只會讓你離場,房間可能保留並由其他人接任主控。'; this.listen = this.button('靜音收聽', () => { this.playbackMuted = !this.playbackMuted; }); this.listen.setAttribute('aria-label', '靜音收聽'); this.playback = document.createElement('div'); @@ -330,8 +352,8 @@ export class StageElement extends HTMLElement { this.playback.append(volumeLabel, this.volume, this.volumeValue); this.retry = this.button('重試加入', () => { if (this.canRetry()) this.rejoin(); }); this.cancelJoin = this.button('取消加入', () => this.cancelInitial?.()); - actions.append(this.unlock, this.listen, this.hand, this.mic, this.micRetry, this.audioRetry, this.leaveStage, this.roomAction, this.retry, this.cancelJoin); - panel.append(this.status, this.quality, this.speaking, this.handRejection, this.error, this.errorDismiss, this.namePanel, this.roomNamePanel, this.handQueuePanel, this.stageManagementPanel, actions, this.audioRecovery, this.microphonePanel, this.outputDevicePanel, this.playback); + actions.append(this.unlock, this.listen, this.hand, this.mic, this.micRetry, this.audioRetry, this.leaveStage, this.roomAction, this.roomClose, this.retry, this.cancelJoin); + panel.append(this.status, this.quality, this.speaking, this.handRejection, this.error, this.errorDismiss, this.namePanel, this.roomNamePanel, this.handQueuePanel, this.stageManagementPanel, actions, this.roomCloseNotice, this.audioRecovery, this.microphonePanel, this.outputDevicePanel, this.playback); root.append(style, panel); this.render(); } @@ -421,6 +443,9 @@ export class StageElement extends HTMLElement { for (const type of EVENTS) { client.on(type, ({ detail }) => { if (generation !== this.generation || this.current !== client) return; + // Own-role deltas precede their snapshot. Fence both dialogs before any DOM setter can reenter. + this.noteRoomCloseEvent(type, detail); + this.noteStageTransferEvent(type, detail); if (type === 'closed' || type === 'kicked') this.microphoneTerminalClient = client; else if (type === 'state') this.microphoneTerminalClient = undefined; if (type === 'closed' || type === 'kicked') this.outputDeviceTerminalClient = client; @@ -440,8 +465,8 @@ export class StageElement extends HTMLElement { if (type === 'state' || type === 'status' || type === 'closed' || type === 'kicked') this.syncNameOwner(); if (type === 'state' || type === 'status' || type === 'closed' || type === 'kicked') this.syncRoomNameOwner(); if (type === 'state' || type === 'status' || type === 'closed' || type === 'kicked') this.syncHandQueue(); - this.noteStageTransferEvent(type, detail); this.syncStageManagement(); + this.syncRoomClose(); const ownId = client.me?.participantId; if (type === 'closed' || type === 'kicked' || (type === 'status' && (detail === 'reconnecting' || detail === 'disconnected')) @@ -489,6 +514,7 @@ export class StageElement extends HTMLElement { this.syncMicrophoneOwner(); this.syncOutputDeviceOwner(); this.syncStageManagement(); + this.syncRoomClose(); this.syncHandRejection(); this.syncAudioRecovery(); if (type === 'state' || type === 'status' || type === 'mic' || type === 'closed' || type === 'kicked') this.render(); @@ -543,6 +569,8 @@ export class StageElement extends HTMLElement { } private async leave(): Promise { + this.retireRoomClose(); + this.roomCloseAuthority = undefined; this.roomCloseTerminalClient = undefined; this.joinPhase = undefined; this.micFailure = undefined; this.microphoneTerminalClient = undefined; @@ -572,6 +600,133 @@ export class StageElement extends HTMLElement { await client?.disconnect(); } + private retireRoomClose(): void { + if (this.roomCloseAttempt) this.roomCloseAttempt.invalid = true; + this.roomCloseOwner = undefined; this.roomCloseAttempt = undefined; + } + + private noteRoomCloseEvent(type: keyof StageEventMap, detail: StageEventMap[keyof StageEventMap]): void { + const client = this.current, state = client?.state, me = client?.me; + if (type === 'closed' || type === 'kicked' || (type === 'status' && detail === 'disconnected')) { + this.roomCloseTerminalClient = client ?? undefined; this.retireRoomClose(); return; + } + if (type === 'state') { + this.roomCloseTerminalClient = undefined; + if (this.roomCloseAuthority) this.roomCloseAuthority.awaitingState = false; + } + const role = type === 'role' ? detail as StageEventMap['role'] : undefined; + const transfer = type === 'transferred' ? detail as StageEventMap['transferred'] : undefined; + if (client && state && me && ((role && role.participantId === me.participantId && role.role !== 'controller') + || (transfer && transfer.fromId === me.participantId && transfer.toId !== me.participantId))) { + const previous = this.roomCloseAuthority; + this.roomCloseAuthority = { client, roomId: state.roomId, participantId: me.participantId, generation: this.generation, + revision: (previous?.revision ?? 0) + 1, awaitingState: true }; + this.retireRoomClose(); + } + if (type === 'status' && detail === 'reconnecting' && this.roomCloseAttempt) this.roomCloseAttempt.invalid = true; + this.reconcileRoomClose(); + } + + private roomCloseMounted(): boolean { + return this.isConnected && this.roomClose.isConnected && this.widgetRoot.contains(this.roomActions) + && this.roomActions.contains(this.roomClose); + } + + private canCloseRoom(): boolean { + const owner = this.roomCloseOwner, client = owner?.client; + return Boolean(owner && client && this.current === client && this.generation === owner.generation + && client.state?.roomId === owner.roomId && client.me?.participantId === owner.participantId + && client.me?.role === 'controller' && client.me.connected === true + && (client.status === 'waiting' || client.status === 'live') && this.roomCloseTerminalClient !== client + && !this.roomCloseAuthority?.awaitingState && this.roomCloseAuthority?.revision === owner.revision + && typeof client.closeRoom === 'function' && typeof window.confirm === 'function' && this.roomCloseMounted()); + } + + private reconcileRoomClose(): void { + const client = this.current, state = client?.state, me = client?.me; + const active = client?.status === 'waiting' || client?.status === 'live'; + const authority = this.roomCloseAuthority; + if (authority && (authority.client !== client || authority.generation !== this.generation + || authority.roomId !== state?.roomId || authority.participantId !== me?.participantId)) this.roomCloseAuthority = undefined; + if (!this.isConnected || !client || !state || me?.role !== 'controller' || this.roomCloseTerminalClient === client + || (!active && client.status !== 'reconnecting') || typeof client.closeRoom !== 'function' || this.roomCloseAuthority?.awaitingState) { + this.retireRoomClose(); + } else { + const owner = this.roomCloseOwner; + if (!owner || owner.client !== client || owner.generation !== this.generation + || owner.roomId !== state.roomId || owner.participantId !== me.participantId) { + this.retireRoomClose(); + // Reconnect can retain a controller tenure, but cannot establish consent for a new one. + if (active && me.connected) { + this.roomCloseAuthority ??= { client, roomId: state.roomId, participantId: me.participantId, + generation: this.generation, revision: 0, awaitingState: false }; + this.roomCloseOwner = { client, roomId: state.roomId, participantId: me.participantId, + generation: this.generation, revision: this.roomCloseAuthority.revision }; + } + } + } + if (this.roomCloseAttempt && !this.canCloseRoom()) this.roomCloseAttempt.invalid = true; + } + + private syncRoomClose(): void { + this.reconcileRoomClose(); + const owner = this.roomCloseOwner, attempt = this.roomCloseAttempt, revision = ++this.roomCloseRenderRevision; + const allowed = this.canCloseRoom(), hidden = !owner, disabled = !allowed || Boolean(attempt), busy = String(Boolean(attempt)); + const current = (): boolean => { + if (revision !== this.roomCloseRenderRevision) return false; + // A host setter can change the exposed snapshot or detach a control without rendering. + this.reconcileRoomClose(); + if (this.roomCloseOwner !== owner || this.roomCloseAttempt !== attempt || this.canCloseRoom() !== allowed) { + this.syncRoomClose(); return false; + } + return true; + }; + if (this.roomClose.hidden !== hidden) this.roomClose.hidden = hidden; + if (!current()) return; + if (this.roomClose.disabled !== disabled) this.roomClose.disabled = disabled; + if (!current()) return; + if (this.roomClose.getAttribute('aria-busy') !== busy) this.roomClose.setAttribute('aria-busy', busy); + if (!current()) return; + if (this.roomCloseNotice.hidden !== hidden) this.roomCloseNotice.hidden = hidden; + current(); + } + + private closeRoom(): void { + if (this.roomCloseDialog || this.stageTransferDialogs.size) return; + const dialog = this.roomCloseDialog = {}; + let owner: StageElement['roomCloseOwner'], attempt: StageElement['roomCloseAttempt']; + const owns = (): boolean => Boolean(owner && attempt && this.roomCloseOwner === owner && this.roomCloseAttempt === attempt); + const eligible = (): boolean => owns() && !attempt!.invalid && attempt!.revision === owner!.revision && this.canCloseRoom(); + const releaseDialog = (): void => { if (this.roomCloseDialog === dialog) this.roomCloseDialog = undefined; }; + void (async () => { + try { + this.reconcileRoomClose(); owner = this.roomCloseOwner; + if (!owner || this.roomCloseAttempt || !this.canCloseRoom()) { this.syncRoomClose(); return; } + attempt = this.roomCloseAttempt = { owner, revision: owner.revision, invalid: false, errorRevision: this.errorRevision }; + this.syncRoomClose(); + if (!eligible()) return; + if (!window.confirm('確定關閉此房間並讓所有人離場?關閉後無法再加入此房間。')) return; + this.syncRoomClose(); + if (!eligible()) return; + const result = owner.client.closeRoom(); + releaseDialog(); + // ACK is not terminal truth: only the SDK's closed/kicked lifecycle changes room presence. + await result; + } catch (error) { + if (owns()) { + try { this.syncRoomClose(); } catch { /* A failed DOM write can be reconciled on the next action. */ } + if (eligible() && this.errorRevision === attempt!.errorRevision) this.show(error); + } + } finally { + releaseDialog(); + if (owns()) { + this.roomCloseAttempt = undefined; + try { this.syncRoomClose(); } catch { /* Release the exact token even if a host setter throws. */ } + } + } + })(); + } + private retireQualityOwner(): void { this.qualityOwner = undefined; this.qualityReport = undefined; this.quality.textContent = ''; this.quality.hidden = true; @@ -997,7 +1152,17 @@ export class StageElement extends HTMLElement { private noteStageTransferEvent(type: keyof StageEventMap, detail: StageEventMap[keyof StageEventMap]): void { const owner = this.stageManagementOwner; if (!owner) return; - if (type === 'state') { owner.transferAwaitingState = false; return; } + if (type === 'status' && (detail === 'reconnecting' || detail === 'disconnected') && this.stageTransferAttempt) this.stageTransferAttempt.invalid = true; + const client = this.current, state = client?.state, me = client?.me; + const sameController = owner.client === client && owner.generation === this.generation + && state?.roomId === owner.roomId && me?.participantId === owner.participantId + && me.role === 'controller' && me.connected === true; + // A replacement snapshot can emit status before state; observe its own-seat loss before DOM sync. + if (!sameController) { + owner.transferRevision++; owner.transferAwaitingState = true; + if (this.stageTransferAttempt) this.stageTransferAttempt.invalid = true; + } + if (type === 'state') { owner.transferAwaitingState = !sameController; return; } const role = type === 'role' ? detail as StageEventMap['role'] : undefined; const left = type === 'stageleft' ? detail as StageEventMap['stageleft'] : undefined; const transferred = type === 'transferred' ? detail as StageEventMap['transferred'] : undefined; @@ -1022,30 +1187,39 @@ export class StageElement extends HTMLElement { } private transferStageControl(id: string, node: HTMLLIElement): void { - this.syncStageManagement(); - const owner = this.stageManagementOwner; - if (!owner || this.stageTransferAttempt || !this.canTransferStage(id, node)) return; - const attempt = { id, node, revision: owner.transferRevision, invalid: false }; - this.stageTransferAttempt = attempt; this.syncStageManagement(); - const current = (): boolean => { - if (this.stageManagementOwner !== owner || this.stageTransferAttempt !== attempt) return false; + const dialog = {}; + this.stageTransferDialogs.add(dialog); + const releaseDialog = (): void => { this.stageTransferDialogs.delete(dialog); }; + try { + // Reconcile even when a close dialog excludes this admission. this.syncStageManagement(); - return this.stageManagementOwner === owner && this.stageTransferAttempt === attempt; - }; - const eligible = (): boolean => current() && !attempt.invalid && owner.transferRevision === attempt.revision && this.canTransferStage(id, node); - void (async () => { - try { - if (!eligible()) return; - const target = owner.client.state!.speakers.find(person => person.participantId === id && person.role === 'speaker' && person.onStage)!; - if (!window.confirm(`確定將控制權移交給 ${decodeName(target.name)}?你將失去主控權限,但保留目前台位並留在房間。`) || !eligible()) return; - this.hideError(); - await owner.client.transferControl(id); - } catch (error) { - if (current() && !attempt.invalid && this.stageManagementRows.get(id)?.node === node) this.show(error); - } finally { - if (current()) { this.stageTransferAttempt = undefined; this.syncStageManagement(); } - } - })(); + const owner = this.stageManagementOwner; + if (this.roomCloseDialog || !owner || this.stageTransferAttempt || !this.canTransferStage(id, node)) { releaseDialog(); return; } + const attempt = { id, node, revision: owner.transferRevision, invalid: false }; + this.stageTransferAttempt = attempt; this.syncStageManagement(); + const current = (): boolean => { + if (this.stageManagementOwner !== owner || this.stageTransferAttempt !== attempt) return false; + this.syncStageManagement(); + return this.stageManagementOwner === owner && this.stageTransferAttempt === attempt; + }; + const eligible = (): boolean => current() && !attempt.invalid && owner.transferRevision === attempt.revision && this.canTransferStage(id, node); + void (async () => { + try { + if (!eligible()) return; + const target = owner.client.state!.speakers.find(person => person.participantId === id && person.role === 'speaker' && person.onStage)!; + if (!window.confirm(`確定將控制權移交給 ${decodeName(target.name)}?你將失去主控權限,但保留目前台位並留在房間。`) || !eligible()) return; + this.hideError(); + const result = owner.client.transferControl(id); + releaseDialog(); + await result; + } catch (error) { + if (current() && !attempt.invalid && this.stageManagementRows.get(id)?.node === node) this.show(error); + } finally { + releaseDialog(); + if (current()) { this.stageTransferAttempt = undefined; this.syncStageManagement(); } + } + })(); + } catch (error) { releaseDialog(); throw error; } } private retireRoomNameOwner(): void { @@ -1536,6 +1710,7 @@ export class StageElement extends HTMLElement { this.syncRoomNameOwner(); this.syncHandQueue(); this.syncStageManagement(); + this.syncRoomClose(); this.syncQualityOwner(); this.syncSpeakingOwner(); this.syncHandRejection(); diff --git a/packages/client/test/element.test.ts b/packages/client/test/element.test.ts index 32d3266..025ecd0 100644 --- a/packages/client/test/element.test.ts +++ b/packages/client/test/element.test.ts @@ -7,6 +7,7 @@ import type { TestContext } from 'node:test'; import { StageClient } from '../src/index.ts'; import type { ClientMessage, RoomStatePayload, ServerMessage } from '../../../shared/protocol.ts'; import { Room } from '../../../src/model/room.ts'; +import type { Session } from '../../../src/ws/hub.ts'; import { parseClientMessage, ValidationError } from '../../../src/ws/validate.ts'; /** Minimal DOM surface for widget state tests; this does not simulate a browser layout or input system. */ @@ -11076,3 +11077,696 @@ describe('widget manual audio negotiation recovery', () => { assert.equal(client.playbackMuted, true); assert.equal(client.playbackVolume, .37); assert.equal(peer.connectionState, 'new'); }); }); + + +describe('widget confirmed room closure', () => { + type CloseWidget = InstanceType; + const closePrompt = '確定關閉此房間並讓所有人離場?關閉後無法再加入此房間。'; + function controllerState(onStage = false): RoomStatePayload { + const state = roomState(onStage); state.controllerId = state.me.participantId; state.me.role = 'controller'; + state.speakers = ['target', 'second'].map(participantId => ({ ...roomState(true).me, participantId, name: `Speaker ${participantId}` })); + return state; + } + const closeRequests = (socket: { sent: ClientMessage[] }) => socket.sent.filter(message => message.type === 'room:close'); + const closeBusy = (element: CloseWidget, busy: boolean) => assert.equal(element['roomClose'].getAttribute('aria-busy'), String(busy)); + const transferButton = (element: CloseWidget, id = 'target') => { + const row = element['stageManagementRows'].get(id); assert.ok(row); return row.transfer; + }; + function feedback(element: CloseWidget, message = 'Keep existing feedback'): void { element['show'](new Error(message)); } + function controlSetter(t: TestContext, control: Node, property: 'hidden' | 'disabled' | 'textContent', callback: () => void): void { + let value = control[property]; + Object.defineProperty(control, property, { configurable: true, get: () => value, set(next: string | boolean) { value = next; callback(); } }); + t.after(() => Object.defineProperty(control, property, { configurable: true, writable: true, value })); + } + + for (const onStage of [false, true]) for (const muted of [false, true]) it(`offers independent destructive closure for controller onStage=${onStage} muted=${muted}`, async t => { + const state = controllerState(onStage); state.me.muted = muted; state.me.forceMuted = muted; + const { element, client } = await socketJoinedWidget(t, state), pending = deferred(); + const close = t.mock.method(client, 'closeRoom', () => pending.promise), dialog = t.mock.method(window, 'confirm', () => true); + const action = element['roomClose']; + assert.equal(action.type, 'button'); assert.equal(action.className, 'danger'); assert.equal(action.hidden, false); assert.equal(action.disabled, false); + assert.equal(action.textContent, '關閉房間'); assert.equal(action.getAttribute('aria-describedby'), element['roomCloseNotice'].id); + assert.equal(element['roomCloseNotice'].hidden, false); assert.equal(element['roomCloseNotice'].textContent, + '關閉房間會讓所有人離場,且無法再加入此房間。離開房間只會讓你離場,房間可能保留並由其他人接任主控。'); + assert.equal(element['roomAction'].textContent, '離開房間'); feedback(element); + click(action); closeBusy(element, true); assert.equal(action.disabled, true); click(action); + assert.equal(dialog.mock.callCount(), 1); assert.equal(dialog.mock.calls[0]!.arguments[0], closePrompt); assert.equal(close.mock.callCount(), 1); + pending.resolve(); await settle(); closeBusy(element, false); assert.equal(action.disabled, false); + assert.equal(element['error'].textContent, 'Keep existing feedback'); assert.equal(element['error'].hidden, false); + assert.equal(element['leftRoom'], false); assert.equal(element.client, client); assert.notEqual(element['status'].textContent, '房間已關閉。'); + }); + + for (const condition of ['initial', 'unjoined', 'audience', 'speaker', 'disconnected', 'unsupported', 'unmounted'] as const) { + it(`rejects retained closure clicks for ${condition} without a dialog or SDK call`, async t => { + const { element, client } = await socketJoinedWidget(t, controllerState()), action = element['roomClose']; + const close = t.mock.method(client, 'closeRoom', async () => {}), dialog = t.mock.method(window, 'confirm', () => true); + feedback(element); + if (condition === 'initial') element['current'] = null; + else if (condition === 'unjoined') client['snapshot'] = null; + else if (condition === 'audience' || condition === 'speaker') client.me!.role = condition; + else if (condition === 'disconnected') client['setStatus']('disconnected'); + else if (condition === 'unsupported') Reflect.set(client, 'closeRoom', undefined); + else Object.assign(element, { isConnected: false }); + element['render'](); assert.equal(action.hidden, true); assert.equal(action.disabled, true); action.disabled = false; click(action); + assert.equal(dialog.mock.callCount(), 0); assert.equal(close.mock.callCount(), 0); assert.equal(element['error'].textContent, 'Keep existing feedback'); + assert.equal(element['error'].hidden, false); closeBusy(element, false); + }); + } + + for (const decision of ['cancel', 'throw', 'missing'] as const) it(`handles ${decision} confirmation without dispatch or fallback and permits a fresh action`, async t => { + const { element, client } = await socketJoinedWidget(t, controllerState()), action = element['roomClose']; + const close = t.mock.method(client, 'closeRoom', async () => {}), leave = t.mock.method(client, 'disconnect', async () => {}); + const transfer = t.mock.method(client, 'transferControl', async () => {}); feedback(element); + const dialog = t.mock.method(window, 'confirm', () => { if (decision === 'throw') throw new Error('Confirmation failed'); return false; }); + if (decision === 'missing') Reflect.set(window, 'confirm', undefined); + element['render'](); assert.equal(action.hidden, false); assert.equal(action.disabled, decision === 'missing'); click(action); await settle(); + assert.equal(close.mock.callCount(), 0); assert.equal(leave.mock.callCount(), 0); assert.equal(transfer.mock.callCount(), 0); closeBusy(element, false); + assert.equal(element['error'].textContent, decision === 'throw' ? 'Confirmation failed' : 'Keep existing feedback'); assert.equal(element['error'].hidden, false); + dialog.mock.restore(); t.mock.method(window, 'confirm', () => true); element['render'](); click(action); await settle(); assert.equal(close.mock.callCount(), 1); + }); + + for (const entry of ['reservation setter', 'confirmation', 'SDK invocation'] as const) it(`reserves exact close consent before ${entry} reentry`, async t => { + const { element, client, socket } = await socketJoinedWidget(t, controllerState()), pending = deferred(); + let entered = false; + const reenter = () => { if (entered) return; entered = true; click(element['roomClose']); click(transferButton(element)); }; + const close = t.mock.method(client, 'closeRoom', () => { if (entry === 'SDK invocation') { socket.deliver({ type: 'room:state', ...controllerState() }); reenter(); } return pending.promise; }); + const transfer = t.mock.method(client, 'transferControl', async () => {}); + const dialog = t.mock.method(window, 'confirm', () => { if (entry === 'confirmation') reenter(); return true; }); + if (entry === 'reservation setter') controlSetter(t, element['roomClose'] as unknown as Node, 'disabled', reenter); + click(element['roomClose']); assert.equal(entered, true); assert.equal(close.mock.callCount(), 1); assert.equal(dialog.mock.callCount(), 1); + assert.equal(transfer.mock.callCount(), 0); closeBusy(element, true); pending.resolve(); await settle(); closeBusy(element, false); + }); + + it('reserves the original owner before the first admission render can replace its controller tenure', async t => { + const { element, client, socket } = await socketJoinedWidget(t, controllerState()), action = element['roomClose'], owner = element['roomCloseOwner']; + const close = t.mock.method(client, 'closeRoom', async () => {}), dialog = t.mock.method(window, 'confirm', () => true); + action.hidden = true; let replaced = false; + controlSetter(t, action as unknown as Node, 'hidden', () => { + if (replaced || action.hidden) return; replaced = true; + socket.deliver({ type: 'room:state', ...roomState() }); socket.deliver({ type: 'room:state', ...controllerState() }); + }); + click(action); await settle(); assert.equal(replaced, true); assert.notEqual(element['roomCloseOwner'], owner); + assert.equal(dialog.mock.callCount(), 0); assert.equal(close.mock.callCount(), 0); closeBusy(element, false); + click(action); await settle(); assert.equal(dialog.mock.callCount(), 1); assert.equal(close.mock.callCount(), 1); + }); + + for (const boundary of ['reconnect', 'local disconnected', 'role', 'method', 'confirmation', 'button disconnected', 'button moved', 'actions removed', 'snapshot', 'room', 'seat', 'client'] as const) { + it(`permanently invalidates consent after observed ${boundary} even when restored inside confirmation`, async t => { + const { element, client, socket } = await socketJoinedWidget(t, controllerState()), action = element['roomClose']; + const close = t.mock.method(client, 'closeRoom', async () => {}); let once = false; + const dialog = t.mock.method(window, 'confirm', () => { + if (once) return true; once = true; + if (boundary === 'reconnect') { client['setStatus']('reconnecting'); socket.deliver({ type: 'room:state', ...controllerState() }); } + else if (boundary === 'local disconnected') { client.me!.connected = false; element['render'](); client.me!.connected = true; } + else if (boundary === 'role') { socket.deliver({ type: 'room:state', ...roomState() }); socket.deliver({ type: 'room:state', ...controllerState() }); } + else if (boundary === 'method') { const method = client.closeRoom; Reflect.set(client, 'closeRoom', undefined); element['render'](); Reflect.set(client, 'closeRoom', method); } + else if (boundary === 'confirmation') { const confirm = window.confirm; Reflect.set(window, 'confirm', undefined); element['render'](); Reflect.set(window, 'confirm', confirm); } + else if (boundary === 'button disconnected') { Object.assign(action, { isConnected: false }); element['render'](); Object.assign(action, { isConnected: true }); } + else if (boundary === 'button moved') { + const actions = element['roomActions'] as unknown as Node, original = [...actions.children], root = element['widgetRoot'] as unknown as Node; + actions.replaceChildren(...original.filter(node => node !== action as unknown as Node)); root.append(action as unknown as Node); + assert.equal(action.isConnected, true); element['render'](); root.children.splice(root.children.indexOf(action as unknown as Node), 1); actions.replaceChildren(...original); + } else if (boundary === 'actions removed') { + const root = element['widgetRoot'] as unknown as Node, original = [...root.children]; root.replaceChildren(); element['render'](); root.replaceChildren(...original); + } else if (boundary === 'snapshot') { const original = client['snapshot']; client['snapshot'] = null; element['render'](); client['snapshot'] = original; } + else if (boundary === 'room') { client.state!.roomId = 'other'; element['render'](); client.state!.roomId = 'one'; } + else if (boundary === 'seat') { client.me!.participantId = 'other'; element['render'](); client.me!.participantId = 'me'; } + else { element['current'] = null; element['render'](); element['current'] = client; } + element['render'](); click(action); return true; + }); + feedback(element); click(action); await settle(); assert.equal(dialog.mock.callCount(), 1); assert.equal(close.mock.callCount(), 0); + assert.equal(element['error'].textContent, 'Keep existing feedback'); closeBusy(element, false); + click(action); await settle(); assert.equal(dialog.mock.callCount(), 2); assert.equal(close.mock.callCount(), 1); + }); + } + + for (const attribute of ['room', 'name', 'code', 'url'] as const) it(`invalidates consent for ${attribute} identity replacement and never replays it`, async t => { + const { element, client, tick } = await socketJoinedWidget(t, controllerState()); + const close = t.mock.method(client, 'closeRoom', async () => {}), dialog = t.mock.method(window, 'confirm', () => { + element.setAttribute(attribute, attribute === 'url' ? 'wss://synthetic.invalid/replacement' : 'replacement'); click(element['roomClose']); return true; + }); + click(element['roomClose']); await tick(100); assert.equal(dialog.mock.callCount(), 1); assert.equal(close.mock.callCount(), 0); + assert.notEqual(element.client, client); assert.equal(element['leftRoom'], false); closeBusy(element, false); + }); + + it('invalidates an open dialog through unmount/remount while retaining its synchronous exclusion', async t => { + const { element, client } = await socketJoinedWidget(t, controllerState()); + const close = t.mock.method(client, 'closeRoom', async () => {}), dialog = t.mock.method(window, 'confirm', () => { + Object.assign(element, { isConnected: false }); element.disconnectedCallback(); + Object.assign(element, { isConnected: true }); element.connectedCallback(); click(element['roomClose']); return true; + }); + click(element['roomClose']); await settle(); assert.equal(dialog.mock.callCount(), 1); assert.equal(close.mock.callCount(), 0); closeBusy(element, false); + }); + + for (const update of ['snapshot', 'names', 'stage and mute', 'target removal', 'other role delta', 'other stage delta', 'playback volume', 'playback mute'] as const) { + it(`keeps room-level consent through unrelated ${update}`, async t => { + const { element, client, socket } = await socketJoinedWidget(t, controllerState()), owner = element['roomCloseOwner']; + const close = t.mock.method(client, 'closeRoom', async () => {}); + const dialog = t.mock.method(window, 'confirm', () => { + const state = controllerState(); + if (update === 'names') { state.name = 'Renamed room'; state.me.name = 'Renamed controller'; } + if (update === 'stage and mute') { state.me.onStage = true; state.me.muted = true; state.me.forceMuted = true; } + if (update === 'target removal') state.speakers = []; + if (update === 'other role delta') socket.deliver({ type: 'role:update', participantId: 'target', role: 'audience' }); + else if (update === 'other stage delta') socket.deliver({ type: 'stage:left', participantId: 'target', role: 'audience' }); + else if (update === 'playback volume') element.playbackVolume = .41; + else if (update === 'playback mute') element.playbackMuted = true; + else socket.deliver({ type: 'room:state', ...state }); + assert.equal(element['roomCloseOwner'], owner); return true; + }); + click(element['roomClose']); await settle(); assert.equal(dialog.mock.callCount(), 1); assert.equal(close.mock.callCount(), 1); + }); + } + + for (const boundary of ['closed', 'shutdown', 'kicked', 'disconnect', 'role tenure', 'unmount', 'identity'] as const) for (const result of ['resolve', 'reject'] as const) { + it(`ignores delayed ${result} after ${boundary} and preserves newer feedback`, async t => { + const { element, client, socket } = await socketJoinedWidget(t, controllerState()), old = deferred(), active = deferred(); let calls = 0; + t.mock.method(client, 'closeRoom', () => (++calls === 1 ? old : active).promise); t.mock.method(window, 'confirm', () => true); + click(element['roomClose']); closeBusy(element, true); + if (boundary === 'closed' || boundary === 'shutdown') socket.deliver({ type: 'room:closed', roomId: 'one', ...(boundary === 'shutdown' ? { reason: 'shutdown' as const } : {}) }); + else if (boundary === 'kicked') socket.deliver({ type: 'kicked', roomId: 'one' }); + else if (boundary === 'disconnect') await client.disconnect(); + else if (boundary === 'role tenure') { socket.deliver({ type: 'room:state', ...roomState() }); socket.deliver({ type: 'room:state', ...controllerState() }); click(element['roomClose']); closeBusy(element, true); } + else if (boundary === 'unmount') { Object.assign(element, { isConnected: false }); element.disconnectedCallback(); } + else element.setAttribute('name', 'Replacement'); + await settle(); feedback(element, 'New feedback'); if (result === 'resolve') old.resolve(); else old.reject(new Error('Obsolete close failure')); await settle(); + assert.equal(element['error'].textContent, 'New feedback'); assert.equal(element['error'].hidden, false); closeBusy(element, boundary === 'role tenure'); + if (boundary === 'role tenure') { assert.equal(calls, 2); active.resolve(); await settle(); closeBusy(element, false); } + if (boundary === 'closed' || boundary === 'shutdown' || boundary === 'kicked') { assert.equal(element['roomAction'].hidden, true); assert.equal(element['roomClose'].hidden, true); assert.equal(element['leftRoom'], false); } + }); + } + + for (const newerFeedback of [false, true]) it(`reports current close rejection only with unchanged feedback revision: newer=${newerFeedback}`, async t => { + const { element, client } = await socketJoinedWidget(t, controllerState()), pending = deferred(); + t.mock.method(client, 'closeRoom', () => pending.promise); t.mock.method(window, 'confirm', () => true); + feedback(element); click(element['roomClose']); if (newerFeedback) feedback(element, 'Newer feedback'); + pending.reject(new Error('Current close failed')); await settle(); + assert.equal(element['error'].textContent, newerFeedback ? 'Newer feedback' : 'Current close failed'); closeBusy(element, false); + }); + + it('releases an actual SDK ACK without synthesizing terminal state, clearing feedback, or replaying closure', async t => { + const { element, client, socket, tick } = await socketJoinedWidget(t, controllerState()); t.mock.method(window, 'confirm', () => true); feedback(element); + click(element['roomClose']); await tick(100); const request = closeRequests(socket)[0]!; assert.deepEqual(Object.keys(request).sort(), ['requestId', 'type']); + socket.deliver({ type: 'ok', requestId: request.requestId }); await settle(); closeBusy(element, false); + assert.equal(client.me!.role, 'controller'); assert.equal(element['leftRoom'], false); assert.equal(element['roomAction'].textContent, '離開房間'); + assert.notEqual(element['status'].textContent, '房間已關閉。'); assert.equal(element['error'].textContent, 'Keep existing feedback'); assert.equal(element['error'].hidden, false); + await tick(500); assert.equal(closeRequests(socket).length, 1); + }); + + it('retains the same pending owner across reconnect, releases only its request rejection and permits a fresh resumed action', async t => { + const { element, client, socket, sockets, tick, hello } = await socketJoinedWidget(t, controllerState()); t.mock.method(window, 'confirm', () => true); + click(element['roomClose']); await tick(100); const owner = element['roomCloseOwner'], stale = socket.onmessage, old = closeRequests(socket)[0]!; + socket.close(); assert.equal(element['roomCloseOwner'], owner); assert.equal(element['roomClose'].disabled, true); await settle(); closeBusy(element, false); + feedback(element, 'Reconnect feedback'); await tick(500); const next = sockets[1]!, join = await hello(next); + next.deliver({ type: 'room:state', ...controllerState() }); next.deliver({ type: 'ok', requestId: join.requestId }); await settle(); + assert.equal(element['roomCloseOwner'], owner); assert.equal(closeRequests(next).length, 0); click(element['roomClose']); await tick(100); closeBusy(element, true); + feedback(element, 'Fresh feedback'); stale?.({ data: JSON.stringify({ type: 'error', requestId: old.requestId, code: 'forbidden', message: 'Stale wire error' }) }); await settle(); + assert.equal(element['error'].textContent, 'Fresh feedback'); closeBusy(element, true); + next.deliver({ type: 'ok', requestId: closeRequests(next)[0]!.requestId }); await settle(); closeBusy(element, false); assert.equal(client.me!.role, 'controller'); + }); + + for (const property of ['hidden', 'disabled', 'aria-busy', 'notice'] as const) it(`stops old close render writes after ${property} setter retires the owner without another render`, async t => { + const { element, client } = await socketJoinedWidget(t, controllerState()), action = element['roomClose']; let retired = false; + const retire = () => { if (retired) return; retired = true; client.me!.role = 'audience'; }; + if (property === 'hidden') { action.hidden = true; controlSetter(t, action as unknown as Node, 'hidden', retire); } + else if (property === 'disabled') { action.disabled = true; controlSetter(t, action as unknown as Node, 'disabled', retire); } + else if (property === 'notice') { element['roomCloseNotice'].hidden = true; controlSetter(t, element['roomCloseNotice'] as unknown as Node, 'hidden', retire); } + else { action.setAttribute('aria-busy', 'tampered'); const original = action.setAttribute; t.mock.method(action, 'setAttribute', function (this: HTMLButtonElement, key: string, value: string) { original.call(this, key, value); retire(); }); } + element['syncRoomClose'](); assert.equal(retired, true); assert.equal(action.hidden, true); assert.equal(action.disabled, true); closeBusy(element, false); + assert.equal(element['roomCloseNotice'].hidden, true); assert.equal(element['roomCloseOwner'], undefined); + }); + + for (const direction of ['close to transfer', 'transfer to close'] as const) for (const decision of ['cancel', 'throw', 'replacement', 'remount'] as const) { + it(`excludes ${direction} during ${decision} while allowing a fresh action after synchronous scope exit`, async t => { + const { element, client, socket } = await socketJoinedWidget(t, controllerState()), pending = deferred(); + const close = t.mock.method(client, 'closeRoom', () => pending.promise), transfer = t.mock.method(client, 'transferControl', () => pending.promise); + const outer = direction === 'close to transfer' ? element['roomClose'] : transferButton(element); + let dialogs = 0; + t.mock.method(window, 'confirm', () => { + dialogs++; + if (decision === 'replacement') { socket.deliver({ type: 'room:state', ...roomState() }); socket.deliver({ type: 'room:state', ...controllerState() }); } + if (decision === 'remount') { Object.assign(element, { isConnected: false }); element.disconnectedCallback(); Object.assign(element, { isConnected: true }); element.connectedCallback(); } + const inner = direction === 'close to transfer' ? (element['stageManagementRows'].get('target')?.transfer ?? outer) : element['roomClose']; click(inner); + if (decision === 'throw') throw new Error('Dialog failed'); return false; + }); + click(outer); await settle(); assert.equal(dialogs, 1); assert.equal(close.mock.callCount(), 0); assert.equal(transfer.mock.callCount(), 0); + assert.equal(element['roomCloseDialog'], undefined); assert.equal(element['stageTransferDialogs'].size, 0); + if (decision !== 'remount') { t.mock.method(window, 'confirm', () => true); click(direction === 'close to transfer' ? transferButton(element) : element['roomClose']); + assert.equal(direction === 'close to transfer' ? transfer.mock.callCount() : close.mock.callCount(), 1); pending.resolve(); await settle(); } + }); + } + + it('retains an outer transfer scope after a nested replacement transfer exits and releases it before waiting for ACK', async t => { + const { element, client, socket } = await socketJoinedWidget(t, controllerState()), pending = deferred(); let transfers = 0, closes = 0; + const transfer = t.mock.method(client, 'transferControl', () => pending.promise), close = t.mock.method(client, 'closeRoom', async () => {}); + t.mock.method(window, 'confirm', (message: string) => { + if (message === closePrompt) { closes++; return true; } + if (++transfers === 2) return true; + socket.deliver({ type: 'room:state', ...roomState() }); socket.deliver({ type: 'room:state', ...controllerState() }); + click(transferButton(element, 'second')); assert.equal(transfer.mock.callCount(), 1); + click(element['roomClose']); assert.equal(closes, 0); return false; + }); + click(transferButton(element)); await settle(); assert.equal(transfers, 2); assert.equal(transfer.mock.callCount(), 1); assert.equal(closes, 0); + assert.equal(element['stageTransferDialogs'].size, 0); click(element['roomClose']); await settle(); assert.equal(closes, 1); assert.equal(close.mock.callCount(), 1); + pending.resolve(); await settle(); assert.equal(element['stageTransferAttempt'], undefined); + }); + + it('preserves transfer pre-admission reconciliation even when a close dialog excludes it', async t => { + const { element, client } = await socketJoinedWidget(t, controllerState()), action = transferButton(element); + const close = t.mock.method(client, 'closeRoom', async () => {}), transfer = t.mock.method(client, 'transferControl', async () => {}); + t.mock.method(window, 'confirm', () => { client.me!.role = 'audience'; click(action); assert.equal(element['stageManagementOwner'], undefined); return true; }); + click(element['roomClose']); await settle(); assert.equal(close.mock.callCount(), 0); assert.equal(transfer.mock.callCount(), 0); + }); + + it('marks transfer admission before pre-admission setters can attempt closure', async t => { + const { element, client } = await socketJoinedWidget(t, controllerState()); let entered = false; + const close = t.mock.method(client, 'closeRoom', async () => {}), transfer = t.mock.method(client, 'transferControl', async () => {}); + const dialog = t.mock.method(window, 'confirm', () => true); + controlSetter(t, element['stageManagementSummary'] as unknown as Node, 'textContent', () => { if (entered) return; entered = true; click(element['roomClose']); }); + click(transferButton(element)); await settle(); assert.equal(entered, true); assert.equal(dialog.mock.callCount(), 1); + assert.equal(close.mock.callCount(), 0); assert.equal(transfer.mock.callCount(), 1); + }); + + for (const event of ['role:update', 'control:transferred'] as const) for (const lane of ['audio', 'output', 'transfer', 'close'] as const) { + it(`fences both controller actions before ${lane} DOM setters on real Hub ${event} with its snapshot held`, async t => { + const f = await hubCloseWidget(t), transferNode = [...f.element['stageManagementRows'].values()][0]?.transfer; + assert.ok(transferNode); f.holdSnapshots(); f.holdDeltas(); + await f.server.hub.handle(f.session, { type: 'control:transfer', requestId: 'real-held-deltas', targetId: f.host.id }); await f.drain(); + const delta = f.heldDeltas.find(message => message.type === event && (message.type !== 'role:update' || message.participantId === f.pid)); assert.ok(delta); + assert.equal(f.client.me!.role, 'controller'); + const close = t.mock.method(f.client, 'closeRoom', async () => {}), transfer = t.mock.method(f.client, 'transferControl', async () => {}); + const dialog = t.mock.method(window, 'confirm', () => true); let entered = false; + const node = lane === 'audio' ? f.element['audioRetry'] : lane === 'output' ? f.element['outputDeviceApply'] : lane === 'transfer' ? transferNode : f.element['roomClose']; + controlSetter(t, node as unknown as Node, lane === 'close' ? 'hidden' : 'disabled', () => { + if (entered) return; entered = true; assert.equal(f.client.me!.role, 'controller'); + click(f.element['roomClose']); click(transferNode); + }); + f.socket.deliver(delta); await settle(); assert.equal(entered, true); assert.equal(dialog.mock.callCount(), 0); + assert.equal(close.mock.callCount(), 0); assert.equal(transfer.mock.callCount(), 0); assert.equal(f.element['roomCloseOwner'], undefined); + assert.equal(f.element['roomCloseAuthority']?.awaitingState, true); assert.equal(f.element['stageManagementOwner']?.transferAwaitingState, true); + }); + } + + for (const event of ['role:update', 'control:transferred'] as const) it(`cannot restore consent inside confirmation after a real ${event} loses control before its snapshot`, async t => { + const f = await hubCloseWidget(t), state = structuredClone(f.client.state!); f.holdSnapshots(); f.holdDeltas(); + await f.server.hub.handle(f.session, { type: 'control:transfer', requestId: 'real-confirm-delta', targetId: f.host.id }); await f.drain(); + const delta = f.heldDeltas.find(message => message.type === event && (message.type !== 'role:update' || message.participantId === f.pid)); assert.ok(delta); + const close = t.mock.method(f.client, 'closeRoom', async () => {}), owner = f.element['roomCloseOwner']; + const dialog = t.mock.method(window, 'confirm', () => { + f.socket.deliver(delta); assert.equal(f.client.me!.role, 'controller'); click(f.element['roomClose']); + f.socket.deliver({ type: 'room:state', ...state }); assert.notEqual(f.element['roomCloseOwner'], owner); click(f.element['roomClose']); return true; + }); + click(f.element['roomClose']); await settle(); assert.equal(dialog.mock.callCount(), 1); assert.equal(close.mock.callCount(), 0); closeBusy(f.element, false); + }); + + it('retains the own-role fence when unsupported close capability retires the UI owner before the real delta', async t => { + const f = await hubCloseWidget(t); f.holdSnapshots(); f.holdDeltas(); + const method = f.client.closeRoom; Reflect.set(f.client, 'closeRoom', undefined); f.element['render'](); assert.equal(f.element['roomCloseOwner'], undefined); + await f.server.hub.handle(f.session, { type: 'control:transfer', requestId: 'missing-method-fence', targetId: f.host.id }); await f.drain(); + const delta = f.heldDeltas.find(message => message.type === 'role:update' && message.participantId === f.pid); assert.ok(delta); + const dialog = t.mock.method(window, 'confirm', () => true); let restored = false; + controlSetter(t, f.element['audioRetry'] as unknown as Node, 'disabled', () => { + if (restored) return; restored = true; Reflect.set(f.client, 'closeRoom', method); click(f.element['roomClose']); + }); + f.socket.deliver(delta); await f.drain(); assert.equal(restored, true); assert.equal(dialog.mock.callCount(), 0); + assert.equal(f.validated.some(message => message.type === 'room:close'), false); assert.equal(f.element['roomCloseAuthority']?.awaitingState, true); + for (const state of f.heldSnapshots) f.socket.deliver(state); assert.equal(f.element['roomClose'].hidden, true); + }); + + it('never establishes a fresh close owner from a reconnecting snapshot', async t => { + const { element, client, socket } = await socketJoinedWidget(t, roomState()); t.mock.method(window, 'confirm', () => true); + client['setStatus']('reconnecting'); client.me!.role = 'controller'; element['render'](); assert.equal(element['roomCloseOwner'], undefined); + assert.equal(element['roomClose'].hidden, true); click(element['roomClose']); assert.equal(closeRequests(socket).length, 0); + client['setStatus']('live'); assert.ok(element['roomCloseOwner']); assert.equal(element['roomClose'].disabled, false); + }); + + for (const result of ['resolve', 'reject'] as const) it(`isolates old ${result} after same-client same-ID actual disconnect and rejoin`, async t => { + const { element, client, socket, sockets, tick, joinRequest } = await socketJoinedWidget(t, controllerState()); + const old = deferred(), active = deferred(); let calls = 0; + t.mock.method(client, 'closeRoom', () => (++calls === 1 ? old : active).promise); t.mock.method(window, 'confirm', () => true); + click(element['roomClose']); const owner = element['roomCloseOwner']; await client.disconnect(); assert.equal(element['roomCloseOwner'], undefined); + const connecting = client.connect(), next = sockets[1]!; next.hello(); await connecting; + const joining = client.join({ roomId: 'one', name: 'Same-ID controller' }); await settle(); await tick(100); const join = joinRequest(next); + next.deliver({ type: 'room:state', ...controllerState() }); next.deliver({ type: 'ok', requestId: join.requestId }); await joining; + assert.notEqual(element['roomCloseOwner'], owner); click(element['roomClose']); closeBusy(element, true); feedback(element, 'New tenure feedback'); + if (result === 'resolve') old.resolve(); else old.reject(new Error('Retired close failure')); await settle(); + closeBusy(element, true); assert.equal(element['error'].textContent, 'New tenure feedback'); assert.equal(calls, 2); assert.equal(closeRequests(socket).length, 0); + active.resolve(); await settle(); closeBusy(element, false); + }); + + for (const result of ['resolve', 'reject'] as const) it(`keeps a pending same-tenure attempt across temporary reconnect until exact ${result}`, async t => { + const { element, client, socket } = await socketJoinedWidget(t, controllerState()), pending = deferred(); + const close = t.mock.method(client, 'closeRoom', () => pending.promise), dialog = t.mock.method(window, 'confirm', () => true); + click(element['roomClose']); const owner = element['roomCloseOwner'], attempt = element['roomCloseAttempt']; + client['setStatus']('reconnecting'); assert.equal(element['roomCloseOwner'], owner); closeBusy(element, true); + socket.deliver({ type: 'room:state', ...controllerState() }); click(element['roomClose']); + assert.equal(element['roomCloseAttempt'], attempt); assert.equal(dialog.mock.callCount(), 1); assert.equal(close.mock.callCount(), 1); + feedback(element, 'Reconnect feedback'); if (result === 'resolve') pending.resolve(); else pending.reject(new Error('Obsolete request failure')); + await settle(); closeBusy(element, false); assert.equal(element['error'].textContent, 'Reconnect feedback'); assert.equal(element['roomClose'].disabled, false); + }); + + it('does not let an old render overwrite a nested replacement controller pending close', async t => { + const { element, client, socket } = await socketJoinedWidget(t, controllerState()), pending = deferred(); let replaced = false; + const close = t.mock.method(client, 'closeRoom', () => pending.promise); t.mock.method(window, 'confirm', () => true); + element['roomClose'].hidden = true; + controlSetter(t, element['roomClose'] as unknown as Node, 'hidden', () => { + if (replaced || element['roomClose'].hidden) return; replaced = true; + socket.deliver({ type: 'room:state', ...roomState() }); socket.deliver({ type: 'room:state', ...controllerState() }); click(element['roomClose']); + }); + element['syncRoomClose'](); assert.equal(replaced, true); assert.equal(close.mock.callCount(), 1); + closeBusy(element, true); assert.equal(element['roomClose'].disabled, true); assert.equal(element['roomCloseNotice'].hidden, false); + pending.resolve(); await settle(); closeBusy(element, false); + }); + + for (const terminal of ['room:closed', 'kicked'] as const) it(`stops a close renderer when a setter delivers ${terminal}`, async t => { + const { element, socket } = await socketJoinedWidget(t, controllerState()); let terminalSent = false; + element['roomClose'].disabled = true; + controlSetter(t, element['roomClose'] as unknown as Node, 'disabled', () => { + if (terminalSent) return; terminalSent = true; socket.deliver({ type: terminal, roomId: 'one' }); + }); + element['syncRoomClose'](); assert.equal(element['roomCloseOwner'], undefined); assert.equal(element['roomClose'].hidden, true); + assert.equal(element['roomClose'].disabled, true); assert.equal(element['roomCloseNotice'].hidden, true); closeBusy(element, false); + }); + + it('releases only its exact attempt even when reservation and finalizer DOM setters throw', async t => { + const { element, client } = await socketJoinedWidget(t, controllerState()), action = element['roomClose']; + const close = t.mock.method(client, 'closeRoom', async () => {}), dialog = t.mock.method(window, 'confirm', () => true); let fail = true; + controlSetter(t, action as unknown as Node, 'disabled', () => { if (fail) throw new Error('Controlled DOM failure'); }); + click(action); await settle(); assert.equal(element['roomCloseAttempt'], undefined); assert.equal(element['roomCloseDialog'], undefined); + assert.equal(dialog.mock.callCount(), 0); assert.equal(close.mock.callCount(), 0); + fail = false; element['render'](); click(action); await settle(); assert.equal(close.mock.callCount(), 1); closeBusy(element, false); + }); + + it('keeps synchronous dialog exclusion local to each widget instance', async t => { + const first = await subscribedWidget(t), second = await subscribedWidget(t); + await first.client['receive']({ type: 'room:state', ...controllerState() }); await second.client['receive']({ type: 'room:state', ...controllerState() }); + const firstClose = t.mock.method(first.client, 'closeRoom', async () => {}), secondClose = t.mock.method(second.client, 'closeRoom', async () => {}); let dialogs = 0; + t.mock.method(window, 'confirm', () => { if (++dialogs === 1) click(second.element['roomClose']); return true; }); + click(first.element['roomClose']); await settle(); assert.equal(dialogs, 2); assert.equal(firstClose.mock.callCount(), 1); assert.equal(secondClose.mock.callCount(), 1); + }); + + it('reconciles a retained direct handler after capability removal without transferring that click to a restored owner', async t => { + const { element, client } = await socketJoinedWidget(t, controllerState()), action = element['roomClose']; + const close = t.mock.method(client, 'closeRoom', async () => {}), method = client.closeRoom, dialog = t.mock.method(window, 'confirm', () => true); + Reflect.set(client, 'closeRoom', undefined); click(action); await settle(); + assert.equal(dialog.mock.callCount(), 0); assert.equal(close.mock.callCount(), 0); assert.equal(action.hidden, true); assert.equal(action.disabled, true); + Reflect.set(client, 'closeRoom', method); click(action); await settle(); assert.equal(dialog.mock.callCount(), 1); assert.equal(close.mock.callCount(), 1); + }); + + for (const boundary of ['role', 'connectivity', 'room', 'seat'] as const) it(`observes ${boundary} replacement state before an early audio setter can restore it inside confirmation`, async t => { + const { element, client, socket } = await socketJoinedWidget(t, controllerState()), close = t.mock.method(client, 'closeRoom', async () => {}); + let restoring = false, restored = false; + controlSetter(t, element['audioRetry'] as unknown as Node, 'disabled', () => { + if (!restoring || restored) return; restored = true; + client.me!.role = 'controller'; client.me!.connected = true; client.me!.participantId = 'me'; client.state!.roomId = 'one'; + click(element['roomClose']); + }); + const dialog = t.mock.method(window, 'confirm', () => { + restoring = true; const state = controllerState(); + if (boundary === 'role') state.me.role = 'audience'; + else if (boundary === 'connectivity') state.me.connected = false; + else if (boundary === 'room') state.roomId = 'other'; + else state.me.participantId = 'other'; + socket.deliver({ type: 'room:state', ...state }); return true; + }); + click(element['roomClose']); await settle(); assert.equal(restored, true); assert.equal(dialog.mock.callCount(), 1); assert.equal(close.mock.callCount(), 0); closeBusy(element, false); + }); + + for (const boundary of ['role', 'connectivity', 'room', 'seat'] as const) it(`fences a transfer's observed ${boundary} replacement state before an early setter restores the old controller`, async t => { + const { element, client, socket } = await socketJoinedWidget(t, controllerState()), action = transferButton(element); + const transfer = t.mock.method(client, 'transferControl', async () => {}); let restoring = false, restored = false, dialogs = 0; + controlSetter(t, element['audioRetry'] as unknown as Node, 'disabled', () => { + if (!restoring || restored) return; restored = true; + client.me!.role = 'controller'; client.me!.connected = true; client.me!.participantId = 'me'; client.state!.roomId = 'one'; + click(transferButton(element)); click(element['roomClose']); + }); + t.mock.method(window, 'confirm', () => { + if (++dialogs > 1) return true; + restoring = true; const state = controllerState(); + if (boundary === 'role') state.me.role = 'audience'; + else if (boundary === 'connectivity') state.me.connected = false; + else if (boundary === 'room') state.roomId = 'other'; + else state.me.participantId = 'other'; + socket.deliver({ type: 'room:state', ...state }); return true; + }); + click(action); await settle(); assert.equal(restored, true); assert.equal(dialogs, 1); assert.equal(transfer.mock.callCount(), 0); + assert.equal(element['stageManagementOwner']?.transferAwaitingState, true); click(transferButton(element)); await settle(); assert.equal(dialogs, 1); + socket.deliver({ type: 'room:state', ...controllerState() }); click(transferButton(element)); await settle(); assert.equal(dialogs, 2); assert.equal(transfer.mock.callCount(), 1); + }); + + it('permits waiting offstage controller closure with an empty stage and preserves independent Leave', async t => { + const state = controllerState(); state.status = 'waiting'; state.speakers = []; + const { element, client } = await socketJoinedWidget(t, state), close = t.mock.method(client, 'closeRoom', async () => {}); + t.mock.method(window, 'confirm', () => true); assert.equal(element['roomClose'].disabled, false); + assert.equal(element['roomAction'].disabled, false); assert.equal(element['stageManagementRows'].size, 0); + click(element['roomClose']); await settle(); assert.equal(close.mock.callCount(), 1); assert.equal(element['leftRoom'], false); + }); + + for (const status of ['reconnecting', 'disconnected'] as const) it(`invalidates active transfer consent on observed ${status} before an early setter restores live status`, async t => { + const { element, client } = await socketJoinedWidget(t, controllerState()), action = transferButton(element); + const transfer = t.mock.method(client, 'transferControl', async () => {}); let restoring = false, restored = false; + controlSetter(t, element['audioRetry'] as unknown as Node, 'disabled', () => { + if (!restoring || restored) return; restored = true; client['setStatus']('live'); + }); + const dialog = t.mock.method(window, 'confirm', () => { restoring = true; client['setStatus'](status); return true; }); + click(action); await settle(); assert.equal(restored, true); assert.equal(dialog.mock.callCount(), 1); assert.equal(transfer.mock.callCount(), 0); + }); + + for (const boundary of ['role', 'connectivity', 'room', 'seat'] as const) it(`fences a transfer's ${boundary} loss in a status-changing snapshot before the following state event`, async t => { + const { element, client, socket } = await socketJoinedWidget(t, controllerState()), transfer = t.mock.method(client, 'transferControl', async () => {}); + let restoring = false, restored = false; + controlSetter(t, element['audioRetry'] as unknown as Node, 'disabled', () => { + if (!restoring || restored) return; restored = true; + client.me!.role = 'controller'; client.me!.connected = true; client.me!.participantId = 'me'; client.state!.roomId = 'one'; + }); + const dialog = t.mock.method(window, 'confirm', () => { + restoring = true; const state = controllerState(); state.status = 'waiting'; + if (boundary === 'role') state.me.role = 'audience'; + else if (boundary === 'connectivity') state.me.connected = false; + else if (boundary === 'room') state.roomId = 'other'; + else state.me.participantId = 'other'; + socket.deliver({ type: 'room:state', ...state }); return true; + }); + click(transferButton(element)); await settle(); assert.equal(restored, true); assert.equal(dialog.mock.callCount(), 1); assert.equal(transfer.mock.callCount(), 0); + }); + + // Controlled browser-media surfaces for the complete widget/SDK/validator/Hub path. + // These do not establish native dialog, focus, layout, accessibility or hardware-audio behavior. + class StagePeer { + static instances: StagePeer[] = []; + connectionState = 'connected'; signalingState = 'stable'; + localDescription: { type: 'offer'; sdp: string } | null = null; + readonly transceiver = { direction: 'recvonly', sender: { track: null as unknown, + replaceTrack: async (track: unknown) => { this.transceiver.sender.track = track; } } }; + constructor() { StagePeer.instances.push(this); } + addTransceiver() { return this.transceiver; } + async createOffer() { return { type: 'offer' as const, sdp: `v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=${this.transceiver.direction}\r\n` }; } + async setLocalDescription(description: { type: 'offer'; sdp: string }): Promise { this.localDescription = description; this.signalingState = 'have-local-offer'; } + async setRemoteDescription(): Promise { this.signalingState = 'stable'; } + async addIceCandidate(): Promise {} + close(): void { this.connectionState = 'closed'; } + } + class StageContext { + static instances: StageContext[] = []; + closes = 0; + constructor() { StageContext.instances.push(this); } + createMediaStreamSource() { return { connect() {}, disconnect() {} }; } + createAnalyser() { return { fftSize: 2048, getFloatTimeDomainData() {}, disconnect() {} }; } + async resume(): Promise {} + async close(): Promise { this.closes++; } + } + + async function hubCloseWidget(t: TestContext, onStage = false, asController = true) { + const tracks: { enabled: boolean; stopped: boolean; readyState: string; stop(): void }[] = []; + const mediaGlobals = new Map(['navigator', 'RTCPeerConnection', 'AudioContext'].map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + Object.defineProperty(globalThis, 'RTCPeerConnection', { configurable: true, value: StagePeer }); + Object.defineProperty(globalThis, 'AudioContext', { configurable: true, value: StageContext }); + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { mediaDevices: { getUserMedia: async () => { + const track = Object.assign(new EventTarget(), { enabled: true, stopped: false, readyState: 'live', stop() { this.stopped = true; this.readyState = 'ended'; } }); + tracks.push(track); return { getTracks: () => [track], getAudioTracks: () => [track] }; + } } } }); + t.after(() => { for (const [key, descriptor] of mediaGlobals) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); } }); + const config = testConfig(c => { c.rooms.qualityIntervalMs = 0; c.rooms.controllerGraceMs = 60_000; }); + const server = harness(config), host = await server.create('Synthetic original controller'); + const sockets: Socket[] = [], elements: InstanceType[] = []; + const validated: ClientMessage[] = [], incoming: ServerMessage[] = [], held: ServerMessage[] = []; + const sessions = new Map(); + const closes: { socket: Socket; code: number | undefined }[] = []; + const peersStart = StagePeer.instances.length, contextsStart = StageContext.instances.length, tracksStart = tracks.length; + let serverWork = Promise.resolve(), sequence = 0, holdCloseAck = false, holdSnapshots = false, holdDeltas = false; + const heldDeltas: ServerMessage[] = []; + let beforeClose: (() => Promise) | undefined; + const heldSnapshots: ServerMessage[] = []; + class Socket { + static OPEN = 1; + readyState = 1; + onmessage: ((event: { data: string }) => void) | null = null; + onclose: (() => void) | null = null; + constructor() { + sockets.push(this); + const session: Session = { id: `widget-close-${++sequence}`, + send: message => { + incoming.push(message); + if (holdDeltas && (message.type === 'role:update' || message.type === 'control:transferred')) { heldDeltas.push(message); return; } + if (holdCloseAck && message.type === 'ok' && validated.some(request => request.type === 'room:close' && request.requestId === message.requestId)) { held.push(message); return; } + if (holdSnapshots && message.type === 'room:state') { heldSnapshots.push(message); return; } + if (this.readyState === Socket.OPEN) this.deliver(message); + }, + close: code => this.close(code), + }; + sessions.set(this, session); + queueMicrotask(() => server.hub.attach(session)); + } + send(data: string) { + assert.equal(this.readyState, Socket.OPEN); + const message = parseClientMessage(data, config.limits), session = sessions.get(this)!; + validated.push(message); + serverWork = serverWork.then(async () => { if (message.type === 'room:close') await beforeClose?.(); await server.hub.handle(session, message); }); + } + deliver(message: ServerMessage) { this.onmessage?.({ data: JSON.stringify(message) }); } + close(code?: number) { + if (this.readyState === 3) return; + closes.push({ socket: this, code }); this.readyState = 3; this.onclose?.(); + const session = sessions.get(this)!; + serverWork = serverWork.then(() => server.hub.detach(session, code === 1000)); + } + } + const oldSocket = Object.getOwnPropertyDescriptor(globalThis, 'WebSocket'); + Object.defineProperty(globalThis, 'WebSocket', { configurable: true, value: Socket }); + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); + let now = 1000; t.mock.method(performance, 'now', () => now); + const tick = async (ms = 200) => { now += ms; t.mock.timers.tick(ms); await settle(); await serverWork; await settle(); }; + const drain = async () => { for (let n = 0; n < 8; n++) await tick(); }; + const flushStates = async () => { + for (const timer of server.timers) if (!timer.cleared && (timer.ms === 0 || timer.ms === config.rooms.presenceBroadcastMs)) { timer.cleared = true; timer.fn(); } + await nextTurn(); await settle(); + }; + const mount = async (name: string) => { + const element = new StageElement(); elements.push(element); Object.assign(element, { isConnected: false }); + element.setAttribute('url', 'wss://synthetic.invalid/ws'); element.setAttribute('room', host.roomId); element.setAttribute('code', host.code); element.setAttribute('name', name); + Object.assign(element, { isConnected: true }); element.connectedCallback(); await settle(); await drain(); + assert.ok(element.client?.me, 'actual widget joined through actual SDK and Hub'); return element; + }; + t.after(async () => { + for (const element of elements) { Object.assign(element, { isConnected: false }); element.disconnectedCallback(); } + await settle(); await serverWork; await server.hub.shutdown(); await server.transport.close(); await settle(); + if (oldSocket) Object.defineProperty(globalThis, 'WebSocket', oldSocket); else Reflect.deleteProperty(globalThis, 'WebSocket'); + }); + const element = await mount('Synthetic widget controller'), client = element.client!, socket = sockets[0]!, session = sessions.get(socket)!, pid = client.me!.participantId; + if (asController) { assert.equal(await server.req(host.s, { type: 'control:transfer', targetId: pid }), 'ok'); await drain(); assert.equal(client.me!.role, 'controller'); } + if (onStage) { + const work = asController ? client.returnToStage() : client.raiseHand(); await drain(); await work; + if (!asController) { assert.equal(await server.req(host.s, { type: 'stage:approve', targetId: pid }), 'ok'); await drain(); } + assert.equal(client.me!.onStage, true); + } + return { config, server, host, sockets, sessions, closes, validated, incoming, held, heldSnapshots, heldDeltas, peersStart, contextsStart, tracksStart, tracks, + element, client, socket, session, pid, tick, drain, flushStates, mount, + holdAck: () => { holdCloseAck = true; }, beforeClose: (fn: () => Promise) => { beforeClose = fn; }, + holdSnapshots: () => { holdSnapshots = true; }, holdDeltas: () => { holdDeltas = true; }, + }; + } + + for (const onStage of [false, true]) it(`widget Leave onStage=${onStage} keeps controller grace, live guests and later succession without room:close`, async t => { + const f = await hubCloseWidget(t, onStage), guest = await f.mount('Synthetic guest'), guestSession = f.sessions.get(f.sockets.at(-1)!)!; + click(f.element['roomAction']); await f.drain(); await f.flushStates(); + assert.equal(f.closes.find(close => close.socket === f.socket)?.code, 1000); + assert.equal(f.element.client, null); assert.equal(f.client.status, 'disconnected'); + assert.equal(f.element['status'].textContent, '已離開房間。'); assert.equal(f.element['roomAction'].textContent, '重新加入'); + assert.equal(f.validated.some(message => message.type === 'room:close'), false); + assert.equal(f.server.hub.roomCount, 1); assert.equal(f.server.transport.rooms.has(f.host.roomId), true); + assert.equal(f.server.hub.isJoined(guestSession), true); assert.equal(guest.client!.state!.controllerId, f.pid); + assert.notEqual(guest.client!.status, 'disconnected'); assert.equal(f.incoming.some(message => message.type === 'room:closed'), false); + const late = await f.server.join(f.host.roomId, f.host.code, 'Synthetic late join'); assert.ok(late.id); + const grace = f.server.timers.find(timer => !timer.cleared && timer.ms === f.config.rooms.controllerGraceMs); assert.ok(grace); + grace.cleared = true; grace.fn(); await nextTurn(); await f.flushStates(); + assert.equal(f.server.hub.roomCount, 1); assert.notEqual(guest.client!.state!.controllerId, f.pid); + assert.equal(f.incoming.some(message => message.type === 'room:closed'), false); + }); + + it('ordinary audience Leave immediately removes only its own seat and leaves the room joinable', async t => { + const f = await hubCloseWidget(t, false, false); click(f.element['roomAction']); await f.drain(); await f.flushStates(); + assert.equal(f.server.hub.isJoined(f.session), false); assert.equal(f.server.hub.isJoined(f.host.s), true); + assert.equal(f.host.s.last('room:state')!.audience!.some(person => person.participantId === f.pid), false); + assert.equal(f.server.hub.roomCount, 1); assert.equal(f.validated.some(message => message.type === 'room:close'), false); + }); + + for (const onStage of [false, true]) it(`confirmed widget button onStage=${onStage} closes all seats/media, unlike Leave`, async t => { + const f = await hubCloseWidget(t, onStage), guest = await f.mount('Synthetic guest'), guestSession = f.sessions.get(f.sockets.at(-1)!)!; + const closed: unknown[] = []; f.element.addEventListener('stage-closed', event => closed.push((event as CustomEvent).detail)); + t.mock.method(window, 'confirm', () => true); click(f.element['roomClose']); await f.drain(); + assert.deepEqual(f.validated.filter(message => message.type === 'room:close').map(message => Object.keys(message).sort()), [['requestId', 'type']]); + assert.equal(f.server.hub.roomCount, 0); assert.equal(f.server.transport.rooms.has(f.host.roomId), false); + for (const session of [f.session, guestSession, f.host.s]) assert.equal(f.server.hub.isJoined(session), false); + assert.equal(f.client.me, null); assert.equal(guest.client!.me, null); assert.equal(f.client.status, 'disconnected'); + assert.equal(f.element['status'].textContent, '房間已關閉。'); assert.equal(guest['status'].textContent, '房間已關閉。'); + assert.equal(f.element['roomAction'].hidden, true); assert.deepEqual(closed, [{ roomId: f.host.roomId }]); + assert.ok(f.server.timers.every(timer => timer.cleared)); + assert.ok(StagePeer.instances.length > f.peersStart); + if (onStage) { assert.ok(f.tracks.length > f.tracksStart); assert.ok(StageContext.instances.length > f.contextsStart); } + assert.ok(StagePeer.instances.slice(f.peersStart).every(peer => peer.connectionState === 'closed')); + assert.ok(StageContext.instances.slice(f.contextsStart).every(context => context.closes > 0)); + assert.ok(f.tracks.slice(f.tracksStart).every(track => track.stopped)); + const late = new FakeSession(); f.server.hub.attach(late); + assert.equal(await f.server.req(late, { type: 'join', roomId: f.host.roomId, code: f.host.code, name: 'Cannot rejoin closed room' }), 'unauthorized'); + }); + + for (const onStage of [false, true]) it(`Hub rejects actual widget ${onStage ? 'speaker' : 'audience'} SDK closure without affecting room`, async t => { + const f = await hubCloseWidget(t, onStage, false), dialog = t.mock.method(window, 'confirm', () => true); + assert.equal(f.element['roomClose'].hidden, true); assert.equal(f.element['roomClose'].disabled, true); + click(f.element['roomClose']); await f.drain(); assert.equal(dialog.mock.callCount(), 0); + assert.equal(f.validated.some(message => message.type === 'room:close'), false); + const result = f.client.closeRoom().then(() => 'ok', error => error.code); + await f.drain(); assert.equal(await result, 'forbidden'); assert.equal(f.server.hub.roomCount, 1); + assert.equal(f.server.hub.isJoined(f.session), true); assert.equal(f.incoming.some(message => message.type === 'room:closed'), false); + assert.equal(f.element['error'].hidden, false); + }); + + it('terminal before withheld ACK rejects local close promise but complete widget shows terminal result and never retries', async t => { + const f = await hubCloseWidget(t); f.holdAck(); + t.mock.method(window, 'confirm', () => true); click(f.element['roomClose']); await f.drain(); + assert.equal(f.held.length, 1); assert.equal(f.client['pending'].size, 0); + assert.equal(f.element['status'].textContent, '房間已關閉。'); assert.equal(f.element['error'].hidden, true); + for (const ack of f.held) f.socket.deliver(ack); await f.tick(30_000); await f.drain(); + assert.equal(f.sockets.length, 1); assert.equal(f.validated.filter(message => message.type === 'room:close').length, 1); + assert.equal(f.element['status'].textContent, '房間已關閉。'); + }); + + it('server authority wins if control is transferred after close is queued but before Hub executes it', async t => { + const f = await hubCloseWidget(t); + // Inject the competing authoritative request before the queued close executes. + f.beforeClose(async () => { await f.server.hub.handle(f.session, { type: 'control:transfer', requestId: 'competing-transfer', targetId: f.host.id }); }); + t.mock.method(window, 'confirm', () => true); click(f.element['roomClose']); await f.drain(); + assert.ok(f.incoming.some(message => message.type === 'error' && message.code === 'forbidden')); assert.notEqual(f.client.me!.role, 'controller'); assert.equal(f.server.hub.roomCount, 1); + assert.equal(f.incoming.some(message => message.type === 'room:closed'), false); + }); + + it('actual transfer notifications precede authoritative snapshot and expose stale controller getter to host callbacks', async t => { + const f = await hubCloseWidget(t), observed: { type: string; role: string | undefined }[] = []; + for (const type of ['stage-role', 'stage-transferred']) f.element.addEventListener(type, () => observed.push({ type, role: f.client.me?.role })); + f.holdSnapshots(); await f.server.hub.handle(f.session, { type: 'control:transfer', requestId: 'notify-before-state', targetId: f.host.id }); await f.drain(); + assert.ok(observed.some(event => event.type === 'stage-transferred' && event.role === 'controller')); + assert.ok(f.heldSnapshots.length); assert.equal(f.client.me!.role, 'controller'); + const result = f.client.closeRoom().then(() => 'ok', error => error.code); await f.drain(); assert.equal(await result, 'forbidden'); + for (const state of f.heldSnapshots) f.socket.deliver(state); await settle(); assert.notEqual(f.client.me!.role, 'controller'); + }); + + it('validator cannot use extra roomId, targetId or role fields to confer closure authority', async t => { + const f = await hubCloseWidget(t, false, false); + const request = parseClientMessage(JSON.stringify({ type: 'room:close', requestId: 'spoof-close', roomId: 'another-room', targetId: f.host.id, role: 'controller' }), f.config.limits); + assert.deepEqual(request, { type: 'room:close', requestId: 'spoof-close' }); + await f.server.hub.handle(f.session, request); await settle(); + assert.ok(f.incoming.some(message => message.type === 'error' && message.requestId === 'spoof-close' && message.code === 'forbidden')); + assert.equal(f.server.hub.roomCount, 1); + }); + + it('Leave opt-out survives widget attribute changes and unmount/remount until explicit rejoin', async t => { + const f = await hubCloseWidget(t); click(f.element['roomAction']); await f.drain(); + f.element.setAttribute('name', 'New synthetic name'); f.element.setAttribute('code', f.host.code); + Object.assign(f.element, { isConnected: false }); f.element.disconnectedCallback(); + Object.assign(f.element, { isConnected: true }); f.element.connectedCallback(); await f.drain(); + assert.equal(f.sockets.length, 1); assert.equal(f.element.client, null); assert.equal(f.element['status'].textContent, '已離開房間。'); + click(f.element['roomAction']); await f.drain(); assert.equal(f.sockets.length, 2); assert.ok(f.element.client!.me); + assert.equal(f.validated.some(message => message.type === 'room:close'), false); + }); + +}); From 7d8659f7544b82b8d616ad0183018a20aac581ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=A5=E8=AA=9E=E3=81=BB=E3=81=97=E3=81=9E=E3=82=89?= <92282870+YueyuHoshizora@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:14:13 +0800 Subject: [PATCH 02/38] perf(widget): skip unchanged name editor attribute writes --- PLAN.md | 3 + README.md | 2 + packages/client/README.md | 1 + packages/client/src/element.ts | 32 ++-- packages/client/test/element.test.ts | 261 +++++++++++++++++++++++++++ 5 files changed, 287 insertions(+), 12 deletions(-) diff --git a/PLAN.md b/PLAN.md index 96f3cde..edc6d99 100644 --- a/PLAN.md +++ b/PLAN.md @@ -823,3 +823,6 @@ N 路混音管線(decode→mix→encode)、不含自己的台上混音、lim 216. **Widget 明確確認關房(第195批,2026-10-16):** 以PR22已合併main為parent,tree與immutable194基線相同;完整StageElement+公開SDK→validator→Hub十四例證明Leave保留controller grace/succession,而既有closeRoom只允許目前主控並終止全房。僅element原生獨立關房按鈕/說明與明確confirm,不改Leave、SDK或server。close自有client/room/pid/generation/tenure owner、exact attempt與error revision,實際shadow/actions/button containment及connected檢查防保留handler;觀測失格不讓同一次確認復活。own role/transferred先於state時,兩側pure authority notes在任何DOM sync前標記,避免舊getter讓同步重入越過資格;收到失格state或reconnecting/disconnected時也先撤銷舊確認,避免早期setter恢復snapshot使舊consent復活,原移交新tenure接納與target政策不變。關房與移交只互斥同步接納/確認,transfer使用可巢狀exact token集合,保留舊移交對話中換席後新移交的既有語義與preadmission reconciliation;不加等待ACK全命令鎖。錯誤限有效同owner/token且無較新feedback,舊finally不清新pending;不本機close、disconnect、成功提示或retry,terminal truth沿用原事件。五檔範圍為element源碼/測試及三份文件,無SDK/server/protocol/media/config/依賴變更、無真實房間操作。完整受控驗證不等於原生dialog/layout/keyboard/focus/AT或硬體音訊驗收。 + + +217. **Widget 名稱編輯器等值屬性寫入保護(第196批,2026-10-16):** published195完整元件、公開SDK/編輯按鈕→validator→Hub固定120次合法rename/roomName操作,兩元件各自本人/房名編輯器寫入5800→1560,省略4240次等值hidden/disabled/aria-busy,340次必要變更保持。僅四個既有sync/retire方法使用live DOM inequality guards,退役沿原鏈式順序先update.disabled再input.disabled;disabled與busy表達式在原本前序寫入之後求值。保留全部accepted textContent與draft value賦值、每次sync/owner/attempt/資格/authority fence、事件順序與error政策,不做signaturecache或skip sync。失敗必要寫入下次重試,宿主屬性破壞修復;pending ACK/錯誤/重連/失權與close/transfer早期fence回歸。等值setter呼叫減少且新增DOMread是明確可觀測差異,相關attribute mutation records可能減少但未量測native observer callback批次,不承諾任意宿主getter/setter副作用等價,也不宣稱native CPU/layout/focus/IME/AT或硬體音訊改善。無SDK/server/protocol/media/依賴變更。 diff --git a/README.md b/README.md index dd6ed1b..781ec8e 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,8 @@ Web Component 進房後另提供明確的麥克風選擇流程:手動重新整 Web Component 已進房後也可用內建名稱欄與「更新名稱」留席更正本人名稱,所有角色皆可操作;目前名稱依伺服器快照,草稿在同席更新/重連時保留。待回覆或重連時不能重複送出,離房/換席會退役舊操作。此編輯不反寫 `name` attribute,明確重新加入仍使用最新屬性;自動重連則沿用 SDK 已接受的名稱。沒有新增擷取、儲存空間或名稱日誌;原生輸入與輔助科技仍需實際驗收。 舉手、麥克風與個人舞台操作也會在初次加入 ACK 前失聯、僅剩舊快照時停用;在轉送狀態事件的同步回呼中點擊舊按鈕,不會先清除錯誤或進入等待。離開房間仍可用於清理。 +Web Component 本人名稱與房名編輯器的 hidden/disabled/aria-busy 依目前 DOM 值比較,只在不同時寫入;仍執行每次同步、權限與等待核對,保留所有接受文字與草稿 value 賦值。固定 120 次實際 SDK/編輯按鈕經 Hub 的操作,受控編輯器寫入由 5,800 降至 1,560,340 次必要變更保持;這是省略等值 setter 呼叫的計數,不是原生瀏覽器速度量測。等值 setter/attribute observer 通知可能減少,並新增 DOM 讀取;不承諾任意宿主 getter/setter 改寫的副作用等價。 + 主控也可在 Web Component 的房間名稱欄按「更新房間名稱」,使用既有 `setRoomName()`,台下主控同樣可用。原始草稿由伺服器驗證,另列的目前房名只依快照更新;同席普通快照與暫時重連保留草稿,等待回覆/重連時停用送出。失去主控、終止連線、離房或換席會清理草稿與舊操作;再次取得主控時從目前房名初始化。不改 room/name 屬性、不重新加入,也不新增媒體操作或儲存。 Web Component 另以普通文字顯示最近收到的發言者回報,按目前台上順序列出名稱與人數;尚未收到、明確空集合與無法對應台上成員分開呈現。改名或移出台上隨快照更新,控制連線寬限中仍在台上的成員不被排除;觀眾與未知 ID 不會洩露。重連、離房或換席清除舊集合,等待新回報。這是伺服器活動偵測,沒有時間戳,並非即時或可聽音訊保證,空集合也不證明沒有音訊;不增加輪詢、媒體工作或 live region,原生輔助科技與實際音訊尚未驗收。 diff --git a/packages/client/README.md b/packages/client/README.md index 53ebd84..dc38906 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -221,6 +221,7 @@ WebSocket 建構失敗、hello 逾時或尚未進房的連線關閉,也會釋 - 輸出裝置選擇:已進房時可在「輸出裝置選擇(草稿)」手動重新整理揚聲器/耳機清單,再明確送出選擇;選單變更本身不呼叫 setSinkId。空字串沿用既有 SDK 的系統預設輸出,不支援 outputDeviceSupported 的瀏覽器顯示說明並停用此組控制。名稱以 textContent 顯示,匿名裝置有通用名稱;重新整理後缺失的草稿保留為未列出項,不偷偷改用其他裝置。重新整理與送出共用等待鎖,送出完成只恢復控制,草稿不表示目前實際輸出、已套用或可聽音訊;不會自動 enumerate、play、開麥克風或儲存裝置 ID,也不另呼叫權限申請 API;setSinkId 的裝置存取與權限仍由瀏覽器決定。 輸出設定作用於同一 client 的 audio 元件,因此等待/草稿/清單按 client 生命週期保留,而非按 room/participant 重設。公開 client 斷線、關房、換席或重入相同/不同房間時,沒有有效房間便隱藏停用;重新取得快照仍要等原輸出操作完成才可再次送出。一般重連保留顯示但停用。真正更換 client 或移除元件才退役舊 UI,既有 setSinkId 無取消 API,不能取消已交給瀏覽器的操作;舊 client 的遲到完成不操作新 client 的控制。永不完成的操作持續等待,沒有新增 timeout/強制解除;目前有效連線且可見 owner 的失敗可明確重試,隱藏或重連等待期間的失敗不另冒出錯誤。宿主直接呼叫 element.client.setOutputDevice 不經此 UI 鎖,不能保證與宿主並行選擇的先後結果。可用 ::part(output-device) 設定外觀;驗證限公開 SDK 與受控 DOM/audio/socket,原生裝置、權限、鍵盤、AT 與實際播放未驗收。 - 內建介面:狀態列(房名.直播狀態.收聽中/已舉手/台上;舉手時顯示目前佇列第幾位)、錯誤列、`啟用音訊`(自動播放被擋時出現,失敗可重試、成功後隱藏)、觀眾的舉手/收回(含冷卻倒數)、台上的麥克風靜音/離開舞台、台下主控的返回舞台,以及所有已進房者的「靜音收聽/恢復收聽」。離開、關房與被踢後隱藏失效操作。除房名、舉手申請、舞台靜音/移下台、混音增益、控制權移交與關閉房間以外的主控管理功能請改用 `element.client`(底層 `StageClient`,尚未建立實例、明確離房或元件已移除時為 null)。 +- 本人名稱與房間名稱編輯器只略過等值 hidden/disabled/aria-busy 寫入;每次 sync、owner/attempt/資格與錯誤流程不變,不快取快照,也不跳過接受文字 textContent 或草稿 value 的原有賦值。宿主改壞布林/等待屬性會在後續同步修正,必要寫入失敗可下次重試;原退役清草稿與 accepted-text 子樹重建保持。受控完整 widget+實際 SDK/validator/Hub 的固定 120 次合法操作,寫入 5,800→1,560,340 次必要變更不變;保留每次同步、事件順序與原草稿賦值。等值 setter 呼叫減少,相關 attribute mutation records 也可能減少,且新增 live DOM reads,因此不是任意 monkey-patched getter/setter 相容保證,也不代表原生 CPU、layout、焦點、IME 或 AT 效能。 - 所有已收到房間快照的參與者另有「離開房間」,初次 ACK 尚未到達或信令重連中也可使用。先退休目前 client 與舊請求/事件,再沿用 SDK disconnect 清理本機擷取、收聽及重連;顯示「已離開房間。」與手動「重新加入」,cleanup 等待期間按鈕停用並標示 aria-busy。屬性與收聽偏好保留,重新加入採用當時最新值;沒有自動重新加入,也不保證還原先前 participant、台位或主控身分。主控的伺服器寬限政策不變,離房不是關閉房間,不保證底層 socket 立即完成關閉。與元件移除相同,先退休的 client 事件(含 disconnect status)不再轉送,不另外產生合成 stage-status;`element.client` 在這次 teardown 後為 null。直接呼叫公開 `element.client.disconnect()` 仍採原本事件/提示行為,不會建立這個手動重入狀態。關房/被踢亦不新增重入按鈕。 - 目前主控另有獨立「關閉房間」與全員離場提示,台上/台下皆可用。必須明確確認「確定關閉此房間並讓所有人離場?關閉後無法再加入此房間。」,才呼叫既有 `closeRoom()`;這不是 Leave,不本機設定離房/重新加入狀態。缺少 SDK 方法隱藏入口,缺少確認 API 不提供未確認替代。重連、斷線、失權、換房/席位、屬性重入、卸載或控制項脫離目前元件後停用/退役;確認中觀測到資格消失,即使立即恢復也需新確認。本人台上/台下、靜音與普通改名不撤銷房間層級資格。 - 關房保留獨立操作 token,先保留再重繪/確認,當前等待不能重複送出;舊 owner 的結果與 finally 不影響新席位。關房與控制權移交只在同步確認/接納期間互斥,標記跨舊 owner 退役保留至同步呼叫返回;不因等待 ACK 鎖住所有命令。原有舊移交確認中換席後的新移交仍可進行。取消、開始與 ACK 成功不清除原錯誤,不自動重試、關閉本機或以 ACK 推論全房終止;權威 `closed` 事件沿用原提示,終止先於 ACK 可使 promise 拒絕但仍顯示房間已關閉。新增 catch 只在同 owner/attempt、仍有效且 error revision 未變時回報,SDK 全域 wire error 仍沿用原政策。受控完整 widget/實際 SDK/validator/Hub 證據不代表原生 dialog、鍵盤、焦點、AT 或硬體音訊驗收。 diff --git a/packages/client/src/element.ts b/packages/client/src/element.ts index 146c165..1461a50 100644 --- a/packages/client/src/element.ts +++ b/packages/client/src/element.ts @@ -830,9 +830,10 @@ export class StageElement extends HTMLElement { private retireNameOwner(): void { this.nameOwner = undefined; this.nameAttempt = undefined; this.nameInput.value = ''; this.acceptedName.textContent = ''; - this.namePanel.hidden = true; - this.nameInput.disabled = this.nameUpdate.disabled = true; - this.nameUpdate.removeAttribute('aria-busy'); + if (!this.namePanel.hidden) this.namePanel.hidden = true; + if (!this.nameUpdate.disabled) this.nameUpdate.disabled = true; + if (!this.nameInput.disabled) this.nameInput.disabled = true; + if (this.nameUpdate.hasAttribute('aria-busy')) this.nameUpdate.removeAttribute('aria-busy'); } private syncNameOwner(): void { @@ -849,9 +850,12 @@ export class StageElement extends HTMLElement { } // A same-seat snapshot (including resume) must not replace an unsent or rejected draft. this.acceptedName.textContent = `目前名稱:${decodeName(me.name)}`; - this.namePanel.hidden = false; this.nameInput.disabled = false; - this.nameUpdate.disabled = client.status === 'reconnecting' || Boolean(this.nameAttempt); - this.nameUpdate.setAttribute('aria-busy', String(Boolean(this.nameAttempt))); + if (this.namePanel.hidden) this.namePanel.hidden = false; + if (this.nameInput.disabled) this.nameInput.disabled = false; + const disabled = client.status === 'reconnecting' || Boolean(this.nameAttempt); + if (this.nameUpdate.disabled !== disabled) this.nameUpdate.disabled = disabled; + const busy = String(Boolean(this.nameAttempt)); + if (this.nameUpdate.getAttribute('aria-busy') !== busy) this.nameUpdate.setAttribute('aria-busy', busy); } private updateName(): void { @@ -1225,9 +1229,10 @@ export class StageElement extends HTMLElement { private retireRoomNameOwner(): void { this.roomNameOwner = undefined; this.roomNameAttempt = undefined; this.roomNameInput.value = ''; this.acceptedRoomName.textContent = ''; - this.roomNamePanel.hidden = true; - this.roomNameInput.disabled = this.roomNameUpdate.disabled = true; - this.roomNameUpdate.removeAttribute('aria-busy'); + if (!this.roomNamePanel.hidden) this.roomNamePanel.hidden = true; + if (!this.roomNameUpdate.disabled) this.roomNameUpdate.disabled = true; + if (!this.roomNameInput.disabled) this.roomNameInput.disabled = true; + if (this.roomNameUpdate.hasAttribute('aria-busy')) this.roomNameUpdate.removeAttribute('aria-busy'); } private syncRoomNameOwner(): void { @@ -1244,9 +1249,12 @@ export class StageElement extends HTMLElement { } // Same-controller snapshots and reconnects update the accepted value, never the draft. this.acceptedRoomName.textContent = `目前房間名稱:${decodeName(state.name)}`; - this.roomNamePanel.hidden = false; this.roomNameInput.disabled = false; - this.roomNameUpdate.disabled = client.status === 'reconnecting' || Boolean(this.roomNameAttempt); - this.roomNameUpdate.setAttribute('aria-busy', String(Boolean(this.roomNameAttempt))); + if (this.roomNamePanel.hidden) this.roomNamePanel.hidden = false; + if (this.roomNameInput.disabled) this.roomNameInput.disabled = false; + const disabled = client.status === 'reconnecting' || Boolean(this.roomNameAttempt); + if (this.roomNameUpdate.disabled !== disabled) this.roomNameUpdate.disabled = disabled; + const busy = String(Boolean(this.roomNameAttempt)); + if (this.roomNameUpdate.getAttribute('aria-busy') !== busy) this.roomNameUpdate.setAttribute('aria-busy', busy); } private updateRoomName(): void { diff --git a/packages/client/test/element.test.ts b/packages/client/test/element.test.ts index 025ecd0..ff4fabd 100644 --- a/packages/client/test/element.test.ts +++ b/packages/client/test/element.test.ts @@ -11769,4 +11769,265 @@ describe('widget confirmed room closure', () => { assert.equal(f.validated.some(message => message.type === 'room:close'), false); }); + + /** Counts controlled DOM calls, not browser layout, focus, IME, or accessibility behavior. */ + function instrumentEditors(element: InstanceType) { + const counts = new Map(); + const restores: (() => void)[] = [], writes: string[] = []; let recording = true; + const bump = (key: string, kind: 'calls' | 'changes' | 'reads') => { + if (!recording) return; + if (kind === 'calls') writes.push(key); + const value = counts.get(key) ?? { calls: 0, changes: 0, reads: 0 }; value[kind]++; counts.set(key, value); + }; + for (const [key, property] of [ + ['namePanel', 'hidden'], ['nameInput', 'value'], ['nameInput', 'disabled'], ['acceptedName', 'textContent'], ['nameUpdate', 'disabled'], + ['roomNamePanel', 'hidden'], ['roomNameInput', 'value'], ['roomNameInput', 'disabled'], ['acceptedRoomName', 'textContent'], ['roomNameUpdate', 'disabled'], + ] as const) { + const node = element[key], original = Object.getOwnPropertyDescriptor(node, property); + let stored = Reflect.get(node, property); + Object.defineProperty(node, property, { configurable: true, get() { bump(`${key}.${property}`, 'reads'); return stored; }, set(value) { + bump(`${key}.${property}`, 'calls'); if (stored !== value) bump(`${key}.${property}`, 'changes'); stored = value; + } }); + restores.push(() => { if (original) Object.defineProperty(node, property, { ...original, value: stored }); else { Reflect.deleteProperty(node, property); Reflect.set(node, property, stored); } }); + } + for (const key of ['nameUpdate', 'roomNameUpdate'] as const) { + const node = element[key], set = node.setAttribute, remove = node.removeAttribute, get = node.getAttribute, has = node.hasAttribute; + node.setAttribute = function(name, value) { + if (name === 'aria-busy') { bump(`${key}.aria-busy`, 'calls'); if (get.call(this, name) !== value) bump(`${key}.aria-busy`, 'changes'); } + set.call(this, name, value); + }; + node.removeAttribute = function(name) { + if (name === 'aria-busy') { bump(`${key}.aria-busy`, 'calls'); if (has.call(this, name)) bump(`${key}.aria-busy`, 'changes'); } + remove.call(this, name); + }; + node.getAttribute = function(name) { if (name === 'aria-busy') bump(`${key}.aria-busy`, 'reads'); return get.call(this, name); }; + node.hasAttribute = function(name) { if (name === 'aria-busy') bump(`${key}.aria-busy`, 'reads'); return has.call(this, name); }; + restores.push(() => { node.setAttribute = set; node.removeAttribute = remove; node.getAttribute = get; node.hasAttribute = has; }); + } + const snapshot = () => { + const properties = Object.fromEntries([...counts].sort(([a], [b]) => a.localeCompare(b))); + return { calls: [...counts.values()].reduce((n, v) => n + v.calls, 0), changes: [...counts.values()].reduce((n, v) => n + v.changes, 0), properties, writes: [...writes] }; + }; + return { snapshot, reset: () => { counts.clear(); writes.length = 0; }, hostWrite(fn: () => void) { recording = false; try { fn(); } finally { recording = true; } }, restore: () => restores.forEach(fn => fn()) }; + } + + it('editor DOM guards counts full widget legal SDK validator Hub workloads with retained drafts and callback order', async t => { + const f = await hubCloseWidget(t), guest = await f.mount('Synthetic audience'); + await f.flushStates(); await f.drain(); + const controller = f.element, viewers = [controller, guest], meters = viewers.map(instrumentEditors); + t.after(() => meters.forEach(meter => meter.restore())); + const identities = viewers.map(element => ['namePanel', 'acceptedName', 'nameInput', 'nameUpdate', 'roomNamePanel', 'acceptedRoomName', 'roomNameInput', 'roomNameUpdate'].map(key => Reflect.get(element, key))); + const handlers = viewers.map(element => [element['nameUpdate'].onclick, element['roomNameUpdate'].onclick]); + controller['nameInput'].value = ' untouched controller draft '; controller['roomNameInput'].value = ' untouched room draft '; guest['nameInput'].value = ' untouched audience draft '; + for (const input of [controller['nameInput'], controller['roomNameInput'], guest['nameInput']]) Object.assign(input, { selectionStart: 2, selectionEnd: 7, selectionDirection: 'backward' }); + const methods = ['syncNameOwner', 'syncRoomNameOwner', 'noteRoomCloseEvent', 'noteStageTransferEvent'] as const; + const syncs = viewers.map(element => methods.map(method => t.mock.method(element as unknown as Record<(typeof methods)[number], (...args: unknown[]) => void>, method))); + const eventTrace: string[] = []; + for (const [index, element] of viewers.entries()) for (const type of ['state', 'status', 'role', 'transferred', 'error']) element.addEventListener(`stage-${type}`, () => eventTrace.push(`${index}:${type}`)); + type EditorCounts = ReturnType['snapshot']>; + type Workload = { + label: string; controller: EditorCounts; audience: EditorCounts; + callbacks: string[]; synchronizations: number[][]; commands: string[]; + }; + const results: Workload[] = []; + async function measure(label: string, work: () => Promise) { + meters.forEach(meter => meter.reset()); + const before = syncs.map(items => items.map(item => item.mock.callCount())), trace = eventTrace.length, request = f.validated.length; + await work(); await f.drain(); + results.push({ label, controller: meters[0]!.snapshot(), audience: meters[1]!.snapshot(), callbacks: eventTrace.slice(trace), + synchronizations: syncs.map((items, i) => items.map((item, j) => item.mock.callCount() - before[i]![j]!)), + commands: f.validated.slice(request).filter(message => message.type !== 'ping').map(message => message.type) }); + } + async function run(action: () => Promise) { const result = action(); await f.drain(); await result; } + await measure('20 audience participant rename changes', async () => { for (let i = 0; i < 20; i++) await run(() => guest.client!.setName(`Audience ${i}`)); }); + await measure('20 controller room rename changes', async () => { for (let i = 0; i < 20; i++) await run(() => f.client.setRoomName(`Room ${i}`)); }); + await measure('20 controller no-op room submissions', async () => { for (let i = 0; i < 20; i++) await run(() => f.client.setRoomName('Room 19')); }); + assert.equal(controller['nameInput'].value, ' untouched controller draft '); assert.equal(controller['roomNameInput'].value, ' untouched room draft '); assert.equal(guest['nameInput'].value, ' untouched audience draft '); + for (const input of [controller['nameInput'], controller['roomNameInput'], guest['nameInput']]) assert.deepEqual([input.selectionStart, input.selectionEnd, input.selectionDirection], [2, 7, 'backward']); + for (const [index, element] of viewers.entries()) { + for (const [position, key] of ['namePanel', 'acceptedName', 'nameInput', 'nameUpdate', 'roomNamePanel', 'acceptedRoomName', 'roomNameInput', 'roomNameUpdate'].entries()) assert.equal(Reflect.get(element, key), identities[index]![position]); + assert.equal(element['nameUpdate'].onclick, handlers[index]![0]); assert.equal(element['roomNameUpdate'].onclick, handlers[index]![1]); + } + await measure('20 controller participant editor button submissions', async () => { + for (let i = 0; i < 20; i++) { meters[0]!.hostWrite(() => { controller['nameInput'].value = `UI Controller ${i}`; }); click(controller['nameUpdate']); await f.drain(); assert.equal(controller['nameAttempt'], undefined); } + }); + await measure('20 controller room editor button submissions', async () => { + for (let i = 0; i < 20; i++) { meters[0]!.hostWrite(() => { controller['roomNameInput'].value = `UI Room ${i}`; }); click(controller['roomNameUpdate']); await f.drain(); assert.equal(controller['roomNameAttempt'], undefined); } + }); + await measure('20 audience participant editor button submissions', async () => { + for (let i = 0; i < 20; i++) { meters[1]!.hostWrite(() => { guest['nameInput'].value = `UI Audience ${i}`; }); click(guest['nameUpdate']); await f.drain(); assert.equal(guest['nameAttempt'], undefined); } + }); + // Per-property expectations retain every accepted-text and retired-draft write, including equal values. + const expected = [ + { calls: [80, 120], changes: [0, 20], command: 'participant:rename', events: ['1:state', '0:state'], syncs: [[40, 40, 20, 20], [40, 40, 20, 20]], changed: [null, 'name'], pending: [null, null] }, + { calls: [80, 120], changes: [20, 0], command: 'room:rename', events: ['0:state', '1:state'], syncs: [[40, 40, 20, 20], [40, 40, 20, 20]], changed: ['roomName', null], pending: [null, null] }, + { calls: [80, 0], changes: [0, 0], command: 'room:rename', events: ['0:state'], syncs: [[40, 40, 20, 20], [0, 0, 0, 0]], changed: [null, null], pending: [null, null] }, + { calls: [240, 120], changes: [100, 0], command: 'participant:rename', events: ['0:state', '1:state'], syncs: [[120, 40, 20, 20], [40, 40, 20, 20]], changed: ['name', null], pending: ['name', null] }, + { calls: [240, 120], changes: [100, 0], command: 'room:rename', events: ['0:state', '1:state'], syncs: [[40, 120, 20, 20], [40, 40, 20, 20]], changed: ['roomName', null], pending: ['roomName', null] }, + { calls: [80, 280], changes: [0, 100], command: 'participant:rename', events: ['1:state', '0:state'], syncs: [[40, 40, 20, 20], [120, 40, 20, 20]], changed: [null, 'name'], pending: [null, 'name'] }, + ] as const; + assert.equal(results.length, expected.length); + for (const [index, result] of results.entries()) { + const want = expected[index]!; + assert.deepEqual(result.commands, Array(20).fill(want.command), result.label); + assert.deepEqual(result.callbacks, Array.from({ length: 20 }, () => [...want.events]).flat(), result.label); + assert.deepEqual(result.synchronizations, want.syncs, result.label); + for (const [viewer, measured] of [result.controller, result.audience].entries()) { + assert.equal(measured.calls, want.calls[viewer], `${result.label}: viewer ${viewer} writes`); + assert.equal(measured.changes, want.changes[viewer], `${result.label}: viewer ${viewer} changed writes`); + for (const [editorIndex, editor] of (['name', 'roomName'] as const).entries()) { + const sync = want.syncs[viewer]![editorIndex]!, pending = want.pending[viewer] === editor; + const text = editor === 'name' ? 'acceptedName' : 'acceptedRoomName'; + const propertyCounts: Record = { + [`${text}.textContent`]: [sync, want.changed[viewer] === editor ? 20 : 0], + [`${editor}Input.value`]: [viewer === 1 && editor === 'roomName' ? sync : 0, 0], + [`${editor}Panel.hidden`]: [0, 0], [`${editor}Input.disabled`]: [0, 0], + [`${editor}Update.disabled`]: [pending ? 40 : 0, pending ? 40 : 0], + [`${editor}Update.aria-busy`]: [pending ? 40 : 0, pending ? 40 : 0], + }; + for (const [key, counts] of Object.entries(propertyCounts)) { + const actual = measured.properties[key]; + assert.deepEqual([actual?.calls ?? 0, actual?.changes ?? 0], counts, `${result.label}: viewer ${viewer} ${key}`); + } + } + } + } + assert.equal(results.reduce((sum, result) => sum + result.controller.calls + result.audience.calls, 0), 1560); + assert.equal(results.reduce((sum, result) => sum + result.controller.changes + result.audience.changes, 0), 340); + assert.equal(eventTrace.length, 220); + + }); + + for (const editor of ['name', 'roomName'] as const) it(`editor DOM guards ${editor} full Hub pending ACK, draft edits, validation failure and retry`, async t => { + const f = await hubCloseWidget(t), element = f.element, input = editor === 'name' ? element['nameInput'] : element['roomNameInput']; + const button = editor === 'name' ? element['nameUpdate'] : element['roomNameUpdate']; + const accepted = editor === 'name' ? element['acceptedName'] : element['acceptedRoomName']; + const command = editor === 'name' ? 'participant:rename' : 'room:rename'; + const attempt = editor === 'name' ? 'nameAttempt' : 'roomNameAttempt'; + let hold = true; const held: ServerMessage[] = [], deliver = f.socket.deliver.bind(f.socket); + t.mock.method(f.socket, 'deliver', (message: ServerMessage) => { + if (hold && message.type === 'ok' && f.validated.some(request => request.type === command && request.requestId === message.requestId)) { held.push(message); return; } + deliver(message); + }); + input.value = ' e\u0301 & '; click(button); const token = element[attempt]; assert.ok(token); + await f.drain(); assert.equal(held.length, 1); assert.equal(element[attempt], token); assert.equal(button.disabled, true); assert.equal(input.disabled, false); assert.match(accepted.textContent!, /é & /); + input.value = 'Next raw draft'; Object.assign(input, { selectionStart: 1, selectionEnd: 5 }); click(button); await f.drain(); + assert.equal(f.validated.filter(message => message.type === command).length, 1); assert.equal(input.value, 'Next raw draft'); + hold = false; for (const message of held.splice(0)) deliver(message); await f.drain(); + assert.equal(button.disabled, false); assert.equal(button.getAttribute('aria-busy'), 'false'); assert.equal(element[attempt], undefined); + assert.deepEqual([input.selectionStart, input.selectionEnd], [1, 5]); + input.value = ' '; click(button); await f.drain(); assert.equal(element['error'].hidden, false); assert.equal(input.value, ' '); assert.equal(button.disabled, false); + input.value = 'Successful retry'; click(button); await f.drain(); assert.equal(element['error'].hidden, true); assert.match(accepted.textContent!, /Successful retry/); assert.equal(f.validated.filter(message => message.type === command).length, 2); + }); + + for (const asController of [true, false]) it(`editor DOM guards repairs corrupted live DOM via legal Hub update controller=${asController}`, async t => { + const f = await hubCloseWidget(t, false, asController), element = f.element; + const nameDraft = element['nameInput'].value = 'Preserve personal raw', roomAccepted = element['acceptedRoomName'].textContent; + if (asController) element['roomNameInput'].value = 'Preserve room raw'; + element['namePanel'].hidden = true; element['nameInput'].disabled = true; element['nameUpdate'].disabled = true; element['nameUpdate'].setAttribute('aria-busy', 'corrupt'); element['acceptedName'].textContent = 'Corrupt accepted'; + element['roomNamePanel'].hidden = !asController; element['roomNameInput'].disabled = asController; element['roomNameUpdate'].disabled = asController; element['roomNameUpdate'].setAttribute('aria-busy', 'corrupt'); element['acceptedRoomName'].textContent = 'Corrupt room accepted'; + if (!asController) { element['roomNamePanel'].hidden = false; element['roomNameInput'].disabled = false; element['roomNameUpdate'].disabled = false; element['roomNameInput'].value = 'Stale retired draft'; } + const work = f.client.setName('Repair via authoritative state'); await f.drain(); await work; + assert.equal(element['namePanel'].hidden, false); assert.equal(element['nameInput'].disabled, false); assert.equal(element['nameUpdate'].disabled, false); assert.equal(element['nameUpdate'].getAttribute('aria-busy'), 'false'); assert.equal(element['nameInput'].value, nameDraft); assert.match(element['acceptedName'].textContent!, /Repair via authoritative state/); + assert.equal(element['roomNamePanel'].hidden, !asController); assert.equal(element['roomNameInput'].disabled, !asController); assert.equal(element['roomNameUpdate'].disabled, !asController); assert.equal(element['roomNameUpdate'].getAttribute('aria-busy'), asController ? 'false' : null); assert.equal(element['roomNameInput'].value, asController ? 'Preserve room raw' : ''); + assert.equal(element['acceptedRoomName'].textContent, roomAccepted); + }); + + for (const editor of ['name', 'roomName'] as const) for (const retired of [false, true]) for (const property of ['hidden', 'input-disabled', 'update-disabled', 'aria-busy'] as const) it(`editor DOM guards retries failed ${editor} ${property} setter retired=${retired}`, async t => { + const f = await hubCloseWidget(t), element = f.element; + if (retired) await f.client.disconnect(); + const panel = editor === 'name' ? element['namePanel'] : element['roomNamePanel']; + const input = editor === 'name' ? element['nameInput'] : element['roomNameInput']; + const button = editor === 'name' ? element['nameUpdate'] : element['roomNameUpdate']; + let failed = false, writes = 0; + if (property === 'aria-busy') { + if (retired) button.setAttribute('aria-busy', 'corrupt'); else button.removeAttribute('aria-busy'); + const method = retired ? 'removeAttribute' : 'setAttribute', original = button[method]; + Object.defineProperty(button, method, { configurable: true, value: function(...args: unknown[]) { + if (args[0] === 'aria-busy') { + writes++; + if (!failed) { failed = true; throw new Error('Synthetic editor write failure'); } + } + return Reflect.apply(original, this, args); + } }); + t.after(() => { Object.defineProperty(button, method, { configurable: true, value: original }); }); + } else { + const node = property === 'hidden' ? panel : property === 'input-disabled' ? input : button; + const key = property === 'hidden' ? 'hidden' : 'disabled'; let value = !retired; + Object.defineProperty(node, key, { configurable: true, get: () => value, set(next: boolean) { + writes++; + if (!failed) { failed = true; throw new Error('Synthetic editor write failure'); } value = next; + } }); + } + assert.throws(() => element['render'](), /Synthetic editor write failure/); assert.equal(failed, true); assert.equal(writes, 1); + element['render'](); assert.equal(writes, 2); + assert.equal(panel.hidden, retired); assert.equal(input.disabled, retired); assert.equal(button.disabled, retired); assert.equal(button.getAttribute('aria-busy'), retired ? null : 'false'); + element['render'](); assert.equal(writes, 2, 'the successful retry alone makes subsequent equal writes unnecessary'); + }); + + for (const action of ['close', 'transfer'] as const) it(`editor DOM guards preserves early ${action} authority notes across editor setter reentry and skipped equal writes`, async t => { + const f = await hubCloseWidget(t), element = f.element; + const originalState = structuredClone(f.client.state!), closeOwner = element['roomCloseOwner'], transferOwner = element['stageManagementOwner']; + const close = t.mock.method(f.client, 'closeRoom', async () => {}), transfer = t.mock.method(f.client, 'transferControl', async () => {}); + const transferAction = transferButton(element, f.host.id), closeAction = element['roomClose']; + f.holdSnapshots(); f.holdDeltas(); + await f.server.hub.handle(f.session, { type: 'control:transfer', requestId: 'editor-authority-loss', targetId: f.host.id }); await f.drain(); + const loss = f.heldSnapshots.find(message => message.type === 'room:state' && message.me.role !== 'controller'); assert.ok(loss); + let armed = false, reentered = false; + controlSetter(t, element['acceptedName'] as unknown as Node, 'textContent', () => { + if (!armed || reentered) return; reentered = true; + assert.notEqual(element['roomCloseOwner'], closeOwner, 'old close owner retired before editor text setter'); assert.equal(transferOwner?.transferAwaitingState, true, 'old transfer owner fenced before editor text setter'); + f.client.me!.role = 'controller'; f.client.me!.connected = true; + click(closeAction); click(transferAction); + }); + const dialog = t.mock.method(window, 'confirm', () => { + armed = true; f.socket.deliver(loss); f.socket.deliver({ type: 'room:state', ...originalState }); return true; + }); + click(action === 'close' ? closeAction : transferAction); await settle(); + assert.equal(reentered, true); assert.equal(dialog.mock.callCount(), 1); assert.equal(close.mock.callCount(), 0); assert.equal(transfer.mock.callCount(), 0); + }); + + it('editor DOM guards retain retirement write order and unconditional draft/text resets', async t => { + const f = await hubCloseWidget(t), element = f.element; + await f.client.disconnect(); + for (const editor of ['name', 'roomName'] as const) { + element[`${editor}Panel`].hidden = false; + element[`${editor}Input`].disabled = false; + element[`${editor}Update`].disabled = false; + element[`${editor}Update`].setAttribute('aria-busy', 'corrupt'); + } + const meter = instrumentEditors(element); t.after(() => meter.restore()); + element['render'](); + assert.deepEqual(meter.snapshot().writes, ['name', 'roomName'].flatMap(editor => [ + `${editor}Input.value`, `${editor === 'name' ? 'acceptedName' : 'acceptedRoomName'}.textContent`, + `${editor}Panel.hidden`, `${editor}Update.disabled`, `${editor}Input.disabled`, `${editor}Update.aria-busy`, + ])); + meter.reset(); element['render'](); + assert.deepEqual(meter.snapshot().writes, ['nameInput.value', 'acceptedName.textContent', 'roomNameInput.value', 'acceptedRoomName.textContent']); + }); + + for (const editor of ['name', 'roomName'] as const) it(`editor DOM guards evaluate ${editor} disabled and busy at their original setter boundaries`, async t => { + const f = await hubCloseWidget(t), element = f.element; + const panel = element[`${editor}Panel`], input = element[`${editor}Input`], button = element[`${editor}Update`]; + const attempt = editor === 'name' ? 'nameAttempt' : 'roomNameAttempt', calls: string[] = []; + let inspecting = true; + panel.hidden = true; input.disabled = true; button.disabled = false; button.setAttribute('aria-busy', 'corrupt'); + controlSetter(t, input as unknown as Node, 'disabled', () => { + if (!inspecting) return; + assert.equal(panel.hidden, false); calls.push('input'); element[attempt] = {}; + }); + controlSetter(t, button as unknown as Node, 'disabled', () => { + if (!inspecting) return; + assert.equal(input.disabled, false); calls.push('update'); element[attempt] = undefined; + }); + try { + element['render'](); + assert.deepEqual(calls, ['input', 'update']); + assert.equal(button.disabled, true, 'disabled observes the attempt created by the preceding input setter'); + assert.equal(button.getAttribute('aria-busy'), 'false', 'busy observes the attempt retired by the preceding update setter'); + assert.equal(element[attempt], undefined); + element['render'](); assert.equal(button.disabled, false); assert.equal(button.getAttribute('aria-busy'), 'false'); + } finally { inspecting = false; } + }); + }); From fe5dbeaec7e5b89047cf2b25a8de0f1646dd76c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=A5=E8=AA=9E=E3=81=BB=E3=81=97=E3=81=9E=E3=82=89?= <92282870+YueyuHoshizora@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:39:52 +0800 Subject: [PATCH 03/38] feat(widget): show snapshot-owned room occupancy --- PLAN.md | 3 + README.md | 2 + packages/client/README.md | 1 + packages/client/src/element.ts | 87 ++++- packages/client/test/element.test.ts | 464 +++++++++++++++++++++++++++ 5 files changed, 556 insertions(+), 1 deletion(-) diff --git a/PLAN.md b/PLAN.md index edc6d99..1665bfc 100644 --- a/PLAN.md +++ b/PLAN.md @@ -826,3 +826,6 @@ N 路混音管線(decode→mix→encode)、不含自己的台上混音、lim 217. **Widget 名稱編輯器等值屬性寫入保護(第196批,2026-10-16):** published195完整元件、公開SDK/編輯按鈕→validator→Hub固定120次合法rename/roomName操作,兩元件各自本人/房名編輯器寫入5800→1560,省略4240次等值hidden/disabled/aria-busy,340次必要變更保持。僅四個既有sync/retire方法使用live DOM inequality guards,退役沿原鏈式順序先update.disabled再input.disabled;disabled與busy表達式在原本前序寫入之後求值。保留全部accepted textContent與draft value賦值、每次sync/owner/attempt/資格/authority fence、事件順序與error政策,不做signaturecache或skip sync。失敗必要寫入下次重試,宿主屬性破壞修復;pending ACK/錯誤/重連/失權與close/transfer早期fence回歸。等值setter呼叫減少且新增DOMread是明確可觀測差異,相關attribute mutation records可能減少但未量測native observer callback批次,不承諾任意宿主getter/setter副作用等價,也不宣稱native CPU/layout/focus/IME/AT或硬體音訊改善。無SDK/server/protocol/media/依賴變更。 + + +218. **Widget 房間席位摘要(第197批,2026-10-16):** immutable196完整StageElement+實際SDK→validator→Hub五例證明無房間席位顯示且容量語義不對稱:maxAudience限制全部非主控成員,maxSpeakers只限非主控台位,audienceCount不含台下主控。新增單一只讀原生paragraph,以公開state計算非主控總席位/上限、非主控台位/上限、觀眾與另計主控位置;不取私有名單、名稱、代碼或tokens,hands不重複計數、offline保留席位不過濾。只從當前權威state接受驗證後primitive projection,role/stage/hand/quality通知不推算;重連標示同owner上次快照,status單獨回復不當fresh。終止/斷線/離房/卸載/換client或席位先清空且隱藏,守generation與同步回呼替換,無舊owner復活。非法數值/上限/controller結構或超cap僅隱藏摘要,不擴SDK/server驗證或改其他控制。無新公有API/part/live-region/timer/網路命令/media操作,五檔範圍element源碼/測試+三文件。席位快照可能合併延後,不代表在線/listeners/音訊可聽或即時入場承諾;原生layout/AT/硬體音訊未驗收。 diff --git a/README.md b/README.md index 781ec8e..fc0f4ff 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,8 @@ Web Component 本人名稱與房名編輯器的 hidden/disabled/aria-busy 主控也可在 Web Component 的房間名稱欄按「更新房間名稱」,使用既有 `setRoomName()`,台下主控同樣可用。原始草稿由伺服器驗證,另列的目前房名只依快照更新;同席普通快照與暫時重連保留草稿,等待回覆/重連時停用送出。失去主控、終止連線、離房或換席會清理草稿與舊操作;再次取得主控時從目前房名初始化。不改 room/name 屬性、不重新加入,也不新增媒體操作或儲存。 +Web Component 也顯示最近快照的房間席位:主控以外的總人數/房間上限、其中台上人數/台位上限、觀眾人數,以及另計的主控台上/台下席位。房間上限包含一般觀眾與講者,台位上限不含主控,不能直接把觀眾數或台上總數各自除以上限。舉手不增加人數,暫時離線但仍保留的席位照計;數字不是在線/收聽人數或立即加入保證。重連顯示上次快照,須新權威快照才更新;離房、斷線或終止時清空。只讀既有公開狀態,不顯示個人名單/識別碼/代碼,不新增請求、輪詢或音訊操作;原生版面與輔助科技未驗收。 + Web Component 另以普通文字顯示最近收到的發言者回報,按目前台上順序列出名稱與人數;尚未收到、明確空集合與無法對應台上成員分開呈現。改名或移出台上隨快照更新,控制連線寬限中仍在台上的成員不被排除;觀眾與未知 ID 不會洩露。重連、離房或換席清除舊集合,等待新回報。這是伺服器活動偵測,沒有時間戳,並非即時或可聽音訊保證,空集合也不證明沒有音訊;不增加輪詢、媒體工作或 live region,原生輔助科技與實際音訊尚未驗收。 Web Component 的台上者收到伺服器本人品質回報後,可看到最近收到的上/下行掉包率及 RTT;只顯示本人,觀眾/台下主控不顯示。缺失值為「—」,不代表 0;RTT 並非端到端音訊延遲。重連、下台、換席或麥克風錯誤清除舊摘要,後續新的本人報告才重新顯示,`micready` 不會還原。報告沒有時間戳或 peer 世代,因此數字不是即時健康或麥克風恢復證明;不增加輪詢或音訊操作。 diff --git a/packages/client/README.md b/packages/client/README.md index dc38906..26e690a 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -222,6 +222,7 @@ WebSocket 建構失敗、hello 逾時或尚未進房的連線關閉,也會釋 輸出設定作用於同一 client 的 audio 元件,因此等待/草稿/清單按 client 生命週期保留,而非按 room/participant 重設。公開 client 斷線、關房、換席或重入相同/不同房間時,沒有有效房間便隱藏停用;重新取得快照仍要等原輸出操作完成才可再次送出。一般重連保留顯示但停用。真正更換 client 或移除元件才退役舊 UI,既有 setSinkId 無取消 API,不能取消已交給瀏覽器的操作;舊 client 的遲到完成不操作新 client 的控制。永不完成的操作持續等待,沒有新增 timeout/強制解除;目前有效連線且可見 owner 的失敗可明確重試,隱藏或重連等待期間的失敗不另冒出錯誤。宿主直接呼叫 element.client.setOutputDevice 不經此 UI 鎖,不能保證與宿主並行選擇的先後結果。可用 ::part(output-device) 設定外觀;驗證限公開 SDK 與受控 DOM/audio/socket,原生裝置、權限、鍵盤、AT 與實際播放未驗收。 - 內建介面:狀態列(房名.直播狀態.收聽中/已舉手/台上;舉手時顯示目前佇列第幾位)、錯誤列、`啟用音訊`(自動播放被擋時出現,失敗可重試、成功後隱藏)、觀眾的舉手/收回(含冷卻倒數)、台上的麥克風靜音/離開舞台、台下主控的返回舞台,以及所有已進房者的「靜音收聽/恢復收聽」。離開、關房與被踢後隱藏失效操作。除房名、舉手申請、舞台靜音/移下台、混音增益、控制權移交與關閉房間以外的主控管理功能請改用 `element.client`(底層 `StageClient`,尚未建立實例、明確離房或元件已移除時為 null)。 - 本人名稱與房間名稱編輯器只略過等值 hidden/disabled/aria-busy 寫入;每次 sync、owner/attempt/資格與錯誤流程不變,不快取快照,也不跳過接受文字 textContent 或草稿 value 的原有賦值。宿主改壞布林/等待屬性會在後續同步修正,必要寫入失敗可下次重試;原退役清草稿與 accepted-text 子樹重建保持。受控完整 widget+實際 SDK/validator/Hub 的固定 120 次合法操作,寫入 5,800→1,560,340 次必要變更不變;保留每次同步、事件順序與原草稿賦值。等值 setter 呼叫減少,相關 attribute mutation records 也可能減少,且新增 live DOM reads,因此不是任意 monkey-patched getter/setter 相容保證,也不代表原生 CPU、layout、焦點、IME 或 AT 效能。 +- 房間席位摘要以最近收到的權威 state 顯示「主控以外 N / maxAudience 人,其中台上 S / maxSpeakers 人、觀眾 A 人;主控另計 1 人(台上/台下)」。N = A + 非主控台上席位,手部佇列不是額外人口;`maxAudience` 限制全部非主控成員,`maxSpeakers` 限制非主控台位,主控席位另計。暫時斷線保留席位仍包含,mute/媒體準備成功與否不改人口,因此不是在線人數、可聽講者或即時剩餘容量保證。重連只保留同一席位已接受的上次快照,單獨 status 回復不使它變成新資料;角色/舉手/發言/品質通知不自行加減。終止、離房、初次 ACK 前失聯或換席會隱藏並清空,舊回呼不復活顯示。只保留驗證後的數量/上限/主控位置,無需私有 audience/code/resumeToken;無效數值或相互矛盾的本地投影只隱藏摘要,不clamp、不報新全域錯誤、不改SDK狀態。原生文字段落沒有互動控制或live region,不新增API、timer、frame、capture或play;受控驗證不代表原生版面、AT或音訊驗收。 - 所有已收到房間快照的參與者另有「離開房間」,初次 ACK 尚未到達或信令重連中也可使用。先退休目前 client 與舊請求/事件,再沿用 SDK disconnect 清理本機擷取、收聽及重連;顯示「已離開房間。」與手動「重新加入」,cleanup 等待期間按鈕停用並標示 aria-busy。屬性與收聽偏好保留,重新加入採用當時最新值;沒有自動重新加入,也不保證還原先前 participant、台位或主控身分。主控的伺服器寬限政策不變,離房不是關閉房間,不保證底層 socket 立即完成關閉。與元件移除相同,先退休的 client 事件(含 disconnect status)不再轉送,不另外產生合成 stage-status;`element.client` 在這次 teardown 後為 null。直接呼叫公開 `element.client.disconnect()` 仍採原本事件/提示行為,不會建立這個手動重入狀態。關房/被踢亦不新增重入按鈕。 - 目前主控另有獨立「關閉房間」與全員離場提示,台上/台下皆可用。必須明確確認「確定關閉此房間並讓所有人離場?關閉後無法再加入此房間。」,才呼叫既有 `closeRoom()`;這不是 Leave,不本機設定離房/重新加入狀態。缺少 SDK 方法隱藏入口,缺少確認 API 不提供未確認替代。重連、斷線、失權、換房/席位、屬性重入、卸載或控制項脫離目前元件後停用/退役;確認中觀測到資格消失,即使立即恢復也需新確認。本人台上/台下、靜音與普通改名不撤銷房間層級資格。 - 關房保留獨立操作 token,先保留再重繪/確認,當前等待不能重複送出;舊 owner 的結果與 finally 不影響新席位。關房與控制權移交只在同步確認/接納期間互斥,標記跨舊 owner 退役保留至同步呼叫返回;不因等待 ACK 鎖住所有命令。原有舊移交確認中換席後的新移交仍可進行。取消、開始與 ACK 成功不清除原錯誤,不自動重試、關閉本機或以 ACK 推論全房終止;權威 `closed` 事件沿用原提示,終止先於 ACK 可使 promise 拒絕但仍顯示房間已關閉。新增 catch 只在同 owner/attempt、仍有效且 error revision 未變時回報,SDK 全域 wire error 仍沿用原政策。受控完整 widget/實際 SDK/validator/Hub 證據不代表原生 dialog、鍵盤、焦點、AT 或硬體音訊驗收。 diff --git a/packages/client/src/element.ts b/packages/client/src/element.ts index 1461a50..dd1dbc1 100644 --- a/packages/client/src/element.ts +++ b/packages/client/src/element.ts @@ -11,6 +11,7 @@ const STYLE = ` :host { display: block; font: inherit; color: var(--stage-fg, inherit); } [part=panel] { display: grid; gap: .5rem; padding: .75rem; border: 1px solid var(--stage-border, #8884); border-radius: var(--stage-radius, .5rem); background: var(--stage-bg, transparent); } [part=status] { margin: 0; } +.room-occupancy { margin: 0; overflow-wrap: anywhere; } [part=quality] { margin: 0; font-size: .875em; } [part=speaking] { margin: 0; font-size: .875em; } [part=hand-rejection] { margin: 0; } @@ -74,6 +75,12 @@ export class StageElement extends HTMLElement { static readonly observedAttributes = ['url', 'room', 'code', 'name', 'playback-muted', 'playback-volume']; private current: StageClient | null = null; private readonly status: HTMLParagraphElement; + private readonly occupancy: HTMLParagraphElement; + private occupancyOwner: { client: StageClient; roomId: string; participantId: string; generation: number } | undefined; + private occupancyReport: { room: number; stage: number; audience: number; maxAudience: number; maxSpeakers: number; controllerOnStage: boolean; last: boolean } | undefined; + // Weak identities fence re-emission of retired public snapshots without retaining their private data. + private readonly occupancySnapshots = new WeakSet(); + private occupancyRevision = 0; private readonly quality: HTMLParagraphElement; private qualityOwner: { client: StageClient; roomId: string; participantId: string; generation: number } | undefined; private qualityReport: Omit | undefined; @@ -187,6 +194,9 @@ export class StageElement extends HTMLElement { this.status.part.add('status'); this.status.setAttribute('role', 'status'); this.status.setAttribute('aria-live', 'polite'); + this.occupancy = document.createElement('p'); + this.occupancy.className = 'room-occupancy'; + this.occupancy.hidden = true; this.quality = document.createElement('p'); this.quality.part.add('quality'); this.quality.hidden = true; @@ -353,7 +363,7 @@ export class StageElement extends HTMLElement { this.retry = this.button('重試加入', () => { if (this.canRetry()) this.rejoin(); }); this.cancelJoin = this.button('取消加入', () => this.cancelInitial?.()); actions.append(this.unlock, this.listen, this.hand, this.mic, this.micRetry, this.audioRetry, this.leaveStage, this.roomAction, this.roomClose, this.retry, this.cancelJoin); - panel.append(this.status, this.quality, this.speaking, this.handRejection, this.error, this.errorDismiss, this.namePanel, this.roomNamePanel, this.handQueuePanel, this.stageManagementPanel, actions, this.roomCloseNotice, this.audioRecovery, this.microphonePanel, this.outputDevicePanel, this.playback); + panel.append(this.status, this.occupancy, this.quality, this.speaking, this.handRejection, this.error, this.errorDismiss, this.namePanel, this.roomNamePanel, this.handQueuePanel, this.stageManagementPanel, actions, this.roomCloseNotice, this.audioRecovery, this.microphonePanel, this.outputDevicePanel, this.playback); root.append(style, panel); this.render(); } @@ -454,6 +464,8 @@ export class StageElement extends HTMLElement { else if (type === 'state') this.stageManagementTerminalClient = undefined; if (type === 'closed' || type === 'kicked' || (type === 'status' && detail === 'disconnected')) this.audioRecoveryTerminalClient = client; else if (type === 'state') this.audioRecoveryTerminalClient = undefined; + this.syncOccupancy(type === 'state' ? detail as StageEventMap['state'] : undefined, + type === 'closed' || type === 'kicked' || (type === 'status' && detail === 'disconnected')); this.syncAudioRecovery(); // Revoke picker admission before any forwarded callback can click an old control. this.syncMicrophoneOwner(); @@ -509,7 +521,9 @@ export class StageElement extends HTMLElement { } // Revoke stale action admission before application event listeners can reenter. if (micLifecycle) this.syncMicRetry(); + this.syncOccupancy(); this.dispatchEvent(new CustomEvent(`stage-${type}`, { detail, bubbles: true, composed: true })); + this.syncOccupancy(); // Application callbacks may retire this seat or mutate its exposed snapshot. this.syncMicrophoneOwner(); this.syncOutputDeviceOwner(); @@ -558,6 +572,7 @@ export class StageElement extends HTMLElement { ++this.generation; const exit = {}; this.leftRoom = true; this.roomExit = exit; + this.retireOccupancy(); this.hideError(); // Retire the event lane before disconnect can synchronously invoke application callbacks. void this.leave().catch(error => { @@ -569,6 +584,7 @@ export class StageElement extends HTMLElement { } private async leave(): Promise { + this.retireOccupancy(); this.retireRoomClose(); this.roomCloseAuthority = undefined; this.roomCloseTerminalClient = undefined; this.joinPhase = undefined; @@ -600,6 +616,74 @@ export class StageElement extends HTMLElement { await client?.disconnect(); } + private retireOccupancy(): void { + ++this.occupancyRevision; + this.occupancyOwner = undefined; this.occupancyReport = undefined; + } + + /** Counts come only from new authoritative states, never status or membership deltas. */ + private syncOccupancy(snapshot?: StageEventMap['state'], terminal = false): void { + const revision = ++this.occupancyRevision; + const client = this.current, state = client?.state, me = state?.me; + const owner = this.occupancyOwner; + const sameOwner = owner && owner.client === client && owner.generation === this.generation + && owner.roomId === state?.roomId && owner.participantId === me?.participantId; + const active = client?.status === 'waiting' || client?.status === 'live'; + const joined = this.isConnected && client && state && typeof state.roomId === 'string' && state.roomId.trim() + && typeof me?.participantId === 'string' && me.participantId.trim(); + if (terminal || !joined || (!active && client.status !== 'reconnecting') || !sameOwner) { + this.occupancyOwner = undefined; this.occupancyReport = undefined; + } + const unseen = snapshot && !this.occupancySnapshots.has(snapshot); + if (snapshot) this.occupancySnapshots.add(snapshot); + if (!terminal && joined && active && snapshot === state && unseen) { + this.occupancyOwner = undefined; this.occupancyReport = undefined; + const limits = state.limits, audience = state.audienceCount; + const count = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; + if (limits && typeof limits === 'object' && !Array.isArray(limits) && count(limits.maxAudience) && limits.maxAudience > 0 + && count(limits.maxSpeakers) && limits.maxSpeakers > 0 && count(audience) + && typeof state.controllerId === 'string' && state.controllerId.trim() && Array.isArray(state.speakers)) { + const seen = new Set(); + let controllerOnStage = false, valid = true; + for (const person of state.speakers) { + if (!person || typeof person.participantId !== 'string' || !person.participantId.trim() + || seen.has(person.participantId) || person.onStage !== true + || (person.role !== 'controller' && person.role !== 'speaker') + || (person.participantId === state.controllerId) !== (person.role === 'controller')) { valid = false; break; } + seen.add(person.participantId); + if (person.role === 'controller') controllerOnStage = true; + } + const stage = state.speakers.length - Number(controllerOnStage), room = audience + stage; + if (valid && count(stage) && count(room) && stage <= limits.maxSpeakers && room <= limits.maxAudience) { + this.occupancyOwner = { client, roomId: state.roomId, participantId: me.participantId, generation: this.generation }; + this.occupancyReport = { room, stage, audience, maxAudience: limits.maxAudience, + maxSpeakers: limits.maxSpeakers, controllerOnStage, last: false }; + } + } + } + const report = this.occupancyReport; + if (report && client?.status === 'reconnecting') report.last = true; + const status = client?.status; + const current = (): boolean => { + if (revision !== this.occupancyRevision) return false; + const accepted = this.occupancyOwner; + if (report && (!accepted || !this.isConnected || this.current !== accepted.client + || this.generation !== accepted.generation || accepted.client.state?.roomId !== accepted.roomId + || accepted.client.me?.participantId !== accepted.participantId || accepted.client.status !== status)) { + this.syncOccupancy(); return false; + } + return true; + }; + const text = report ? `房間席位(${report.last ? client?.status === 'reconnecting' ? '重新連線中,上次快照' : '上次快照' : '最近快照'}):主控以外 ${report.room} / ${report.maxAudience} 人,其中台上 ${report.stage} / ${report.maxSpeakers} 人、觀眾 ${report.audience} 人;主控另計 1 人(${report.controllerOnStage ? '台上' : '台下'})。含斷線保留席位。` : ''; + if (!report && !this.occupancy.hidden) this.occupancy.hidden = true; + // A host setter can synchronously retire or replace the seat while this paragraph is painted. + if (!current()) return; + if (this.occupancy.textContent !== text) this.occupancy.textContent = text; + if (!current()) return; + if (report && this.occupancy.hidden) this.occupancy.hidden = false; + current(); + } + private retireRoomClose(): void { if (this.roomCloseAttempt) this.roomCloseAttempt.invalid = true; this.roomCloseOwner = undefined; this.roomCloseAttempt = undefined; @@ -1709,6 +1793,7 @@ export class StageElement extends HTMLElement { } private render(): void { + this.syncOccupancy(); this.syncAudioRecovery(); this.syncPlaybackOwner(); this.syncMicRetry(); diff --git a/packages/client/test/element.test.ts b/packages/client/test/element.test.ts index ff4fabd..3129722 100644 --- a/packages/client/test/element.test.ts +++ b/packages/client/test/element.test.ts @@ -12031,3 +12031,467 @@ describe('widget confirmed room closure', () => { }); }); + +describe('widget room occupancy summary', () => { + type Widget = InstanceType; + const text = (room: number, stage: number, audience: number, controllerOnStage: boolean, maxAudience = 300, maxSpeakers = 8, freshness = '最近快照') => + `房間席位(${freshness}):主控以外 ${room} / ${maxAudience} 人,其中台上 ${stage} / ${maxSpeakers} 人、觀眾 ${audience} 人;主控另計 1 人(${controllerOnStage ? '台上' : '台下'})。含斷線保留席位。`; + const hidden = (element: Widget) => { assert.equal(element['occupancy'].hidden, true); assert.equal(element['occupancy'].textContent, ''); }; + const shown = (element: Widget, expected: string) => { assert.equal(element['occupancy'].hidden, false); assert.equal(element['occupancy'].textContent, expected); }; + function state(audience = 2, stage = 1, controllerOnStage = true, viewer: 'audience' | 'speaker' | 'controller' = 'audience'): RoomStatePayload { + const value = roomState(); value.audienceCount = audience; + value.speakers = Array.from({ length: stage }, (_, i) => ({ ...value.me, participantId: `speaker-${i}`, name: 'Private stage name', role: 'speaker', onStage: true, connected: i !== 0 })); + const host = { ...value.me, participantId: value.controllerId, name: 'Private controller', role: 'controller' as const, onStage: controllerOnStage }; + if (controllerOnStage) value.speakers.push(host); + if (viewer === 'controller') { value.me = host; value.audience = [{ ...roomState().me, name: 'SECRET audience diff --git a/web/src/embed.ts b/web/src/embed.ts index 544640f..d1a637e 100644 --- a/web/src/embed.ts +++ b/web/src/embed.ts @@ -5,6 +5,7 @@ function input(id: string): HTMLInputElement { return document.querySelector(`#${id}`)!; } const status = document.querySelector('#status')!; +const roomOccupancy = document.querySelector('#room-occupancy')!; const errorNotice = document.querySelector('#error-notice')!; const lastError = document.querySelector('#last-error')!; const handRejectionNotice = document.querySelector('#hand-rejected')!; @@ -102,6 +103,76 @@ let outputOwner: { client: Client; roomId: string; participantId: string; genera let playbackGeneration = 0; let pendingUnlock: { client: Client | null; generation: number } | undefined; let entryGeneration = 0; +interface OccupancyReport { + client: Client; generation: number; roomId: string; participantId: string; + room: number; stage: number; audience: number; maxAudience: number; maxSpeakers: number; + controllerOnStage: boolean; stale: boolean; +} +let occupancyReport: OccupancyReport | undefined; +let occupancyRevision = 0; +let occupancyRetiredGeneration = -1; +// Retirement is pure: existing action authority must be reconciled before the new DOM surface runs. +function retireOccupancy(): void { occupancyReport = undefined; occupancyRevision++; } +function projectOccupancy(current: Client, generation: number, state: StageEventMap['state']): OccupancyReport | undefined { + const roomId = state.roomId, participantId = state.me?.participantId, controllerId = state.controllerId; + const audience = state.audienceCount, maxAudience = state.limits?.maxAudience, maxSpeakers = state.limits?.maxSpeakers; + if (typeof roomId !== 'string' || !roomId.trim() || typeof participantId !== 'string' || !participantId.trim() + || typeof controllerId !== 'string' || !controllerId.trim() || !Array.isArray(state.speakers) + || !state.limits || typeof state.limits !== 'object' || Array.isArray(state.limits) + || !Number.isSafeInteger(audience) || audience < 0 || !Number.isSafeInteger(maxAudience) || maxAudience <= 0 + || !Number.isSafeInteger(maxSpeakers) || maxSpeakers <= 0) return; + const ids = new Set(); + let controllerOnStage = false; + for (const person of state.speakers) { + if (!person || typeof person.participantId !== 'string' || !person.participantId.trim() || ids.has(person.participantId) + || person.onStage !== true || (person.role !== 'speaker' && person.role !== 'controller') + || (person.role === 'controller') !== (person.participantId === controllerId)) return; + ids.add(person.participantId); + if (person.participantId === controllerId) controllerOnStage = true; + } + const stage = ids.size - Number(controllerOnStage), room = audience + stage; + if (!Number.isSafeInteger(room) || room > maxAudience || stage > maxSpeakers) return; + return { client: current, generation, roomId, participantId, room, stage, audience, maxAudience, maxSpeakers, + controllerOnStage, stale: false }; +} +function reconcileOccupancy(): void { + const report = occupancyReport, current = client; + if (current?.status === 'disconnected') occupancyRetiredGeneration = entryGeneration; + if (!report) return; + if (current !== report.client || current !== controlsClient || report.generation !== entryGeneration + || current.state?.roomId !== report.roomId || current.state.me?.participantId !== report.participantId + || !['waiting', 'live', 'reconnecting'].includes(current.status)) { retireOccupancy(); return; } + if (current.status === 'reconnecting' && !report.stale) { report.stale = true; occupancyRevision++; } +} +function renderOccupancy(): void { + const revision = occupancyRevision, report = occupancyReport, current = client, controls = controlsClient; + const generation = entryGeneration, connectionStatus = current?.status; + const roomId = current?.state?.roomId, participantId = current?.state?.me?.participantId; + const valid = (): boolean => { + if (revision !== occupancyRevision || report !== occupancyReport || current !== client + || controls !== controlsClient || generation !== entryGeneration) return false; + if (connectionStatus === current?.status && roomId === current?.state?.roomId + && participantId === current?.state?.me?.participantId) return true; + // A setter can change public SDK identity/status without dispatching an event. + // Retire both pending consents before any corrective DOM can restore their eligibility. + reconcileOccupancy(); + if (pendingRoomClose && !eligibleRoomClose(roomCloseOwner)) pendingRoomClose.invalidated = true; + invalidateTransfer(); syncRoomClose(); syncStageManagement(); + renderOccupancy(); return false; + }; + if (!report) { + if (!roomOccupancy.hidden) { roomOccupancy.hidden = true; if (!valid()) return; } + if (roomOccupancy.textContent !== '') { roomOccupancy.textContent = ''; if (!valid()) return; } + return; + } + if (report.client !== current || current !== controls || report.generation !== generation + || report.roomId !== roomId || report.participantId !== participantId + || !['waiting', 'live', 'reconnecting'].includes(connectionStatus!)) return; + const freshness = connectionStatus === 'reconnecting' ? '重新連線中,上次快照' : report.stale ? '上次快照' : '最近快照'; + const text = `房間席位(${freshness}):主控以外 ${report.room} / ${report.maxAudience} 人,其中台上 ${report.stage} / ${report.maxSpeakers} 人、觀眾 ${report.audience} 人;主控另計 1 人(${report.controllerOnStage ? '台上' : '台下'})。含斷線保留席位。`; + if (roomOccupancy.textContent !== text) { roomOccupancy.textContent = text; if (!valid()) return; } + if (roomOccupancy.hidden) { roomOccupancy.hidden = false; if (!valid()) return; } +} let feedbackRevision = 0; let handRejectionOwner: { client: Client; roomId: string; participantId: string; generation: number } | undefined; function retireHandRejection(): void { @@ -137,9 +208,10 @@ function retireControls(): void { } /** Retire initial-join feedback before user or terminal callbacks can start another entry. */ function retireEntry(): number { + const generation = ++entryGeneration; retireOccupancy(); hideCancel(); submit.disabled = false; submit.setAttribute('aria-busy', 'false'); - return ++entryGeneration; + return generation; } let handTimer: ReturnType | undefined; let handTimerDue = 0; @@ -861,12 +933,15 @@ function signalingUrl(host: URL, value: string): URL { } join.onsubmit = (event) => { event.preventDefault(); + const previous = client, generation = ++entryGeneration; + retireOccupancy(); clearError(); const hostValue = input('host').value, pathValue = input('ws-path').value; const joinOptions = { roomId: input('room').value.trim(), code: input('code').value.trim() || undefined, name: input('name').value.trim() }; retirePlayback(); scheduleHandCooldown(0); - const generation = ++entryGeneration, feedback = feedbackRevision; + const feedback = feedbackRevision; let progress = true, receivedState = false, cancelledEntry = false; + const occupancyReceipts = new WeakSet(); let entryClient: Client | undefined; const showProgress = (message: string): void => { if (generation === entryGeneration && feedback === feedbackRevision && progress) status.textContent = message; @@ -880,6 +955,8 @@ join.onsubmit = (event) => { retirePlayback(); scheduleHandCooldown(0); retireControls(); hand.disabled = leave.disabled = true; hand.setAttribute('aria-busy', 'false'); syncMic(); syncStage(); syncMicRetry(); + renderOccupancy(); + if (cancelled !== entryGeneration) return; try { const stopping = entryClient?.disconnect(); if (cancelled === entryGeneration) status.textContent = '已取消連線。'; @@ -894,7 +971,9 @@ join.onsubmit = (event) => { stage.hidden = stage.disabled = true; stage.setAttribute('aria-busy', 'false'); syncMicRetry(); hand.setAttribute('aria-busy', 'false'); hand.disabled = leave.disabled = true; - await client?.disconnect(); + renderOccupancy(); + if (generation !== entryGeneration) return; + await previous?.disconnect(); if (generation !== entryGeneration) return; const host = new URL(hostValue); if (!['https:', 'http:'].includes(host.protocol)) throw new Error('請輸入 HTTP 或 HTTPS 主機網址。'); @@ -911,7 +990,15 @@ join.onsubmit = (event) => { ? library.MAX_GAIN_DB : undefined; syncUnlock(); client.on('state', ({ detail }: CustomEvent) => { - if (client !== current || cancelledEntry) return; + if (client !== current || cancelledEntry || generation !== entryGeneration || occupancyRetiredGeneration === generation || controlsClient !== current) return; + // SDK installs state before status; only its actual, previously unseen state receipt is fresh. + const unseen = detail && typeof detail === 'object' && !occupancyReceipts.has(detail); + if (detail && typeof detail === 'object') occupancyReceipts.add(detail); + reconcileOccupancy(); + if (detail === current.state && unseen + && (current.status === 'waiting' || current.status === 'live')) { + occupancyReport = projectOccupancy(current, generation, detail); occupancyRevision++; + } if (generation === entryGeneration && controlsClient === current && stageManagementOwner?.client === current) stageManagementOwner.transferFenced = false; if (generation === entryGeneration && controlsClient === current) { @@ -926,7 +1013,9 @@ join.onsubmit = (event) => { progress = false; status.textContent = `${library.decodeName(detail.name)} · ${library.decodeName(detail.me.name)} · ${detail.me.onStage ? '已上台' : '收聽中'}`; syncHand(); syncMic(); syncStage(); syncMicRetry(); syncRename(); syncRoomName(); syncHandQueue(); syncStageManagement(); syncRoomClose(); syncMicrophone(); syncAudioRetry(); syncOutput(); - if (renderingEntry === entryGeneration && client === current && controlsClient === current) leave.disabled = false; + if (renderingEntry === entryGeneration && client === current && controlsClient === current) { + leave.disabled = false; renderOccupancy(); + } }); const retireOwnHandRejection = (participantId: string): void => { if (client === current && controlsClient === current && !cancelledEntry && generation === entryGeneration @@ -980,7 +1069,9 @@ join.onsubmit = (event) => { if (retrying) { progress = false; status.textContent = '已取得麥克風。'; } }); client.on('status', ({ detail }) => { - if (client !== current || cancelledEntry) return; + if (client !== current || cancelledEntry || generation !== entryGeneration) return; + if (detail === 'disconnected') { occupancyRetiredGeneration = generation; retireOccupancy(); } + else reconcileOccupancy(); const renderingEntry = entryGeneration; syncRoomClose(); if (renderingEntry !== entryGeneration || client !== current) return; @@ -988,11 +1079,26 @@ join.onsubmit = (event) => { if (detail === 'reconnecting') { retireHandRejection(); progress = false; status.textContent = '重新連線中'; hand.disabled = true; } else if (detail === 'disconnected') { progress = false; retirePlayback(); retireControls(); status.textContent = '未連線'; hand.disabled = leave.disabled = true; } syncHand(); syncMic(); syncStage(); syncMicRetry(); syncRename(); syncRoomName(); syncHandQueue(); syncStageManagement(); syncRoomClose(); syncMicrophone(); syncAudioRetry(); syncOutput(); + if (renderingEntry === entryGeneration && client === current) renderOccupancy(); }); client.on('error', ({ detail }) => { if (client !== current || cancelledEntry || generation !== entryGeneration) return; progress = false; report(detail); }); client.on('audioblocked', () => { if (cancelledEntry) return; progress = false; status.textContent = '請點擊啟用音訊。'; }); - client.on('closed', ({ detail }) => { if (client !== current || cancelledEntry || generation !== entryGeneration) return; retireEntry(); clearError(); retirePlayback(); retireControls(); syncHand(); syncMic(); syncStage(); syncMicRetry(); syncRename(); syncRoomName(); status.textContent = detail.reason === 'shutdown' ? '伺服器維護或重新啟動,房間已關閉。' : '房間已關閉。'; hand.disabled = leave.disabled = true; }); - client.on('kicked', () => { if (client !== current || cancelledEntry || generation !== entryGeneration) return; retireEntry(); clearError(); retirePlayback(); retireControls(); syncHand(); syncMic(); syncStage(); syncMicRetry(); syncRename(); syncRoomName(); status.textContent = '你已被主控移出房間。'; hand.disabled = leave.disabled = true; }); + client.on('closed', ({ detail }) => { + if (client !== current || cancelledEntry || generation !== entryGeneration) return; + const retired = retireEntry(); + clearError(); retirePlayback(); retireControls(); syncHand(); syncMic(); syncStage(); syncMicRetry(); syncRename(); syncRoomName(); + renderOccupancy(); + if (retired !== entryGeneration || client !== current) return; + status.textContent = detail.reason === 'shutdown' ? '伺服器維護或重新啟動,房間已關閉。' : '房間已關閉。'; hand.disabled = leave.disabled = true; + }); + client.on('kicked', () => { + if (client !== current || cancelledEntry || generation !== entryGeneration) return; + const retired = retireEntry(); + clearError(); retirePlayback(); retireControls(); syncHand(); syncMic(); syncStage(); syncMicRetry(); syncRename(); syncRoomName(); + renderOccupancy(); + if (retired !== entryGeneration || client !== current) return; + status.textContent = '你已被主控移出房間。'; hand.disabled = leave.disabled = true; + }); showProgress('連線中…'); await current.connect(); if (generation !== entryGeneration) return; @@ -1198,10 +1304,12 @@ micRetry.onclick = () => { } }; leave.onclick = () => { - const generation = retireEntry(), current = client; + const current = client, generation = retireEntry(); clearError(); retirePlayback(); scheduleHandCooldown(0); - retireControls(); hand.setAttribute('aria-busy', 'false'); syncMic(); syncStage(); syncMicRetry(); + retireControls(); hand.setAttribute('aria-busy', 'false'); syncHand(); syncMic(); syncStage(); syncMicRetry(); + renderOccupancy(); + if (generation !== entryGeneration || client !== current) return; void current?.disconnect().then(() => { if (generation !== entryGeneration || client !== current) return; status.textContent = '已離開。'; hand.disabled = leave.disabled = true; From f205a68fd9ce11bc3d578c9f74376edf99da870f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=A5=E8=AA=9E=E3=81=BB=E3=81=97=E3=81=9E=E3=82=89?= <92282870+YueyuHoshizora@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:37:58 +0800 Subject: [PATCH 05/38] feat(embed): show own latest-received connection quality Add a read-only own publisher quality summary with receipt and entry lifecycle isolation, safe primitive metrics and guarded rendering. Preserve transport history across microphone errors without implying measurement freshness or audible playback. Validated with 5326 tests, four typechecks, build and independent review. --- PLAN.md | 3 + README.md | 2 + test/fixtures/embedApp.ts | 4 +- test/fixtures/embedSdkApp.ts | 4 +- test/webEmbedEntry.test.ts | 4 +- test/webEmbedMicrophone.test.ts | 4 +- test/webEmbedQuality.test.ts | 449 ++++++++++++++++++++++++++++++++ test/webEmbedWsPath.test.ts | 4 +- web/embed.html | 2 +- web/src/embed.ts | 131 +++++++++- 10 files changed, 589 insertions(+), 18 deletions(-) create mode 100644 test/webEmbedQuality.test.ts diff --git a/PLAN.md b/PLAN.md index f56257b..29c6422 100644 --- a/PLAN.md +++ b/PLAN.md @@ -832,3 +832,6 @@ N 路混音管線(decode→mix→encode)、不含自己的台上混音、lim 219. **最小 embed 房間席位摘要(第198批,2026-10-16):** immutable197完整embed+公開SDK→validator→Hub五例確認缺少席位顯示、容量非對稱與舊terminal在新entry之後抵達。新增單一原生paragraph,非主控room=A+S、stage=S,各比既有maxAudience/maxSpeakers;主控額外席位與台上/下分列,hands不重算、offline grace不濾除。只在當前entry/client/controls/generation且detail等於實際state時接受安全primitive投影,status/delta/ACK不造fresh;重連保留同owner上次快照,receipt identity防舊object重放。取消/離房/新entry/終止先pure retirement;新paragraph setter必須位於close/transfer與entry權限撤銷/核對之後,每次setter後守revision/client/controls/generation/status/seat,舊continuation不得清新entry。僅局部必要entry/terminal排序,不作通用生命週期重寫。非法count/cap/controller結構清空摘要、不clamp或更改SDK/state/其他controls。10檔限定HTML/embed源碼/新測試/兩sharedfixtures/三inline fixture exactnode addition/README/PLAN,舊assertions不弱化;無SDK/server/widget/styles/media/config/依賴變更、無新command/timer/network。數字是可能延後的席位快照,非online/listeners/audible或入場保證;native layout/keyboard/AT與硬體音訊未驗收。 + + +220. **最小 embed 本人連線品質摘要(第199批,2026-10-16):** immutable198 完整 embed 與實際 SDK→validator→Hub 證明品質回報已抵達但無呈現入口。新增單一普通 paragraph,僅複製目前 entry/client/controls/room/participant 已入台觀察窗口的本人數值;非法或缺失欄位顯示破折號,缺少本人或重複本人項清空,不讀他人指標或私有資料。state 與 quality receipt identity 記錄包含拒絕事件,舊 object 不重放;重連、本人 audience role/stage:left、離房/終止/替換先 pure retirement,distinct 當前權威 state 與後續新 quality 才重新顯示。status/stagejoined/role 不自行恢復;同席位普通快照與保持台位的移交保留歷史,micerror/micready/mute/retry 不冒充傳輸歷史失效或恢復。新 DOM 位於既有 close/transfer 權限核對之後,每次變更 setter 後驗證 revision/owner/entry/status/identity/onStage,較新同步回呼優先,靜默資格變化先撤銷兩種 consent 再修正顯示。10 檔限定 embed HTML/源碼/新測試/五處 fixture 與 README/PLAN,不改 SDK/server/widget/protocol/media/styles/config/依賴;無新輪詢、timer、命令或媒體操作。回報無 timestamp/epoch,「最近收到」不保證量測新鮮度、擷取/播放成功或可聽,RTT 非端到端延遲。原生瀏覽器/鍵盤/AT/硬體音訊尚未驗收。 diff --git a/README.md b/README.md index 570ba4b..b4d68f5 100644 --- a/README.md +++ b/README.md @@ -307,6 +307,8 @@ SDK 的應用層 keepalive 會與控制請求共用送出間隔,避免合法 最小 embed 也顯示房間席位快照:主控以外總人數/房間上限、其中台上人數/台位上限、觀眾數與另計的主控台上/台下位置。房間上限包含一般觀眾與講者,台位上限不含主控;舉手不另計、斷線保留席位仍包含,因此不是在線/收聽人數或即時入場保證。只有目前進房流程的新權威 state 更新數字;重連保留同席位上次快照,單獨 status 恢復不標新資料。取消、離房、斷線、終止或替換 entry 清空,舊client的晚到狀態/終止事件不覆寫新加入。僅保存驗證後數量、上限與位置,不讀私有名單/代碼/token,不新增輪詢、命令或媒體操作;無效投影僅隱藏摘要。原生版面、鍵盤/AT與實體音訊尚未驗收。 +最小 embed 的台上使用者可查看「最近收到」的本人連線品質:上行/下行掉包與 RTT;RTT 不是端到端音訊延遲。資料只來自既有伺服器品質回報,不另輪詢或操作媒體。缺少或非法欄位顯示「—」,沒有本人回報便隱藏;不顯示其他人的數值,台下主控也不借用別人的回報。重連、本人下台、離房或替換進房流程會清除,重新取得權威台上快照後,仍須再收到品質回報才顯示。相同席位的麥克風失敗/準備完成、靜音或重試不抹除已收到的傳輸歷史,也不產生新數字。回報沒有量測時間或媒體世代,因此「最近收到」不代表最新量測、音訊已恢復或可聽;原生 layout、鍵盤/AT 與實體音訊未驗收。 + 最小 embed 的目前主控另可明確「關閉房間」;台上與台下主控皆可用。按下先確認「確定關閉此房間並讓所有人離場?關閉後無法再加入此房間。」,同意後才呼叫既有 `closeRoom()`。這與「離開房間」不同:離開只斷線,房間仍可能保留主控寬限並交接;關閉會使所有人離場。重連或缺少確認能力時不能送出,缺少 SDK 方法時不提供替代關房方式。確認途中曾失權、換房/席位、重連或能力消失,即使隨即恢復也需重新確認;與控制權移交只互斥同時開啟的確認流程,不新增所有命令的全域鎖。取消、送出與 ACK 本身不清其他錯誤,不自動重試、離房或本機宣告成功;既有終止事件仍依原流程清理舊錯誤並顯示「房間已關閉」。終止事件先於 ACK 時請求可因斷線拒絕,但不應以過期拒絕覆蓋關閉結果;舊席位結果也不污染新加入。伺服器仍裁決權限,SDK 全域錯誤政策不變。驗證限受控完整頁面/實際 SDK/Hub,原生確認視窗、版面、鍵盤/焦點/AT 與硬體音訊未驗收。 最小 embed 的目前主控可展開「舉手申請」,依伺服器快照順序查看具名申請並明確「核准上台」或「婉拒」;經控制權移交或自動接任的台下主控也可使用。每個目標的兩個動作共用等待鎖,其他目標仍可操作;快照先移除申請不代表請求已完成,同 ID 再出現也不提前解鎖。重連保留上次名單、展開與等待但停用,失權、離房、終止或新加入會清理舊席位結果。離線申請保留在原順序,滿台/離線的核准錯誤仍由伺服器裁決,不因此封鎖婉拒;沒有樂觀移除、自動主持或額外媒體呼叫。舉手面板只顯示申請名單,不加入完整觀眾名單。SDK 全域錯誤與通知先於快照的既有界線保持,原生 details/鍵盤/焦點/AT 與實體音訊尚未驗收。 diff --git a/test/fixtures/embedApp.ts b/test/fixtures/embedApp.ts index 22932fe..e36abfc 100644 --- a/test/fixtures/embedApp.ts +++ b/test/fixtures/embedApp.ts @@ -161,9 +161,9 @@ export class EmbedSdk { export const flush = async (): Promise => { for (let i = 0; i < 16; i++) await Promise.resolve(); }; export function embedApp(options: { libraryLoad?: Promise | ((url: string, attempt: number) => Promise); source?: string; search?: string; maxGainDb?: unknown; confirm?: ((message: string) => boolean) | null } = {}) { - const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); + const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); const node = (id: string): EmbedNode => { const found = nodes.get(id); assert.ok(found, `unmodeled node: ${id}`); return found; }; - node('error-notice').hidden = true; node('hand-rejected').hidden = true; node('room-occupancy').hidden = true; + node('error-notice').hidden = true; node('hand-rejected').hidden = true; node('room-occupancy').hidden = true; node('own-connection-quality').hidden = true; const cancel = node('cancel-join'); cancel.hidden = cancel.disabled = true; cancel.textContent = '取消連線'; const hand = node('hand'), mic = node('mic'), stage = node('stage'), micRetry = node('mic-retry'), leave = node('leave'), form = node('join'), status = node('status'); hand.disabled = leave.disabled = true; hand.textContent = '舉手發言'; status.textContent = '尚未加入'; diff --git a/test/fixtures/embedSdkApp.ts b/test/fixtures/embedSdkApp.ts index d54b4c6..becc176 100644 --- a/test/fixtures/embedSdkApp.ts +++ b/test/fixtures/embedSdkApp.ts @@ -107,9 +107,9 @@ export async function actualApp(t: TestContext, options: { deferCapture?: boolea const originals = new Map(Object.keys(globals).map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value }); t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); let now = 1000; t.mock.method(performance, 'now', () => now); - const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); + const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); const node = (id: string): EmbedNode => { const found = nodes.get(id); assert.ok(found, id); return found; }; - node('error-notice').hidden = true; node('hand-rejected').hidden = true; node('room-occupancy').hidden = true; + node('error-notice').hidden = true; node('hand-rejected').hidden = true; node('room-occupancy').hidden = true; node('own-connection-quality').hidden = true; const cancel = node('cancel-join'); cancel.hidden = cancel.disabled = true; cancel.textContent = '取消連線'; const form = node('join'), stage = node('stage'), status = node('status'), mic = node('mic'), micRetry = node('mic-retry'), leave = node('leave'); form.submitButton = new EmbedNode(); node('name').value = '測試主控'; diff --git a/test/webEmbedEntry.test.ts b/test/webEmbedEntry.test.ts index a89e05e..791eedc 100644 --- a/test/webEmbedEntry.test.ts +++ b/test/webEmbedEntry.test.ts @@ -60,9 +60,9 @@ async function fixture(t: TestContext, options: { deferLibrary?: boolean } = {}) t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); let now = 1000; t.mock.method(performance, 'now', () => now); - const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); + const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); const node = (id: string): EmbedNode => { const found = nodes.get(id); assert.ok(found); return found; }; - node('error-notice').hidden = true; node('hand-rejected').hidden = true; node('room-occupancy').hidden = true; + node('error-notice').hidden = true; node('hand-rejected').hidden = true; node('room-occupancy').hidden = true; node('own-connection-quality').hidden = true; const cancel = node('cancel-join'); cancel.hidden = cancel.disabled = true; cancel.textContent = '取消連線'; const form = node('join'), submit = new EmbedNode(), status = node('status'), leave = node('leave'), hand = node('hand'); form.submitButton = submit; node('name').value = ' 測試聽眾 '; diff --git a/test/webEmbedMicrophone.test.ts b/test/webEmbedMicrophone.test.ts index b7be65e..13f21b1 100644 --- a/test/webEmbedMicrophone.test.ts +++ b/test/webEmbedMicrophone.test.ts @@ -228,9 +228,9 @@ async function actualApp(t: TestContext) { for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value }); MicSocket.instances = []; t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); let now = 1000; t.mock.method(performance, 'now', () => now); - const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); + const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); const node = (id: string): EmbedNode => { const found = nodes.get(id); assert.ok(found); return found; }; - node('error-notice').hidden = true; node('hand-rejected').hidden = true; node('room-occupancy').hidden = true; + node('error-notice').hidden = true; node('hand-rejected').hidden = true; node('room-occupancy').hidden = true; node('own-connection-quality').hidden = true; const cancel = node('cancel-join'); cancel.hidden = cancel.disabled = true; cancel.textContent = '取消連線'; const form = node('join'), mic = node('mic'), status = node('status'), leave = node('leave'); form.submitButton = new EmbedNode(); node('name').value = '測試聽眾'; mic.setAttribute('aria-label', '麥克風靜音'); diff --git a/test/webEmbedQuality.test.ts b/test/webEmbedQuality.test.ts new file mode 100644 index 0000000..5c1f11c --- /dev/null +++ b/test/webEmbedQuality.test.ts @@ -0,0 +1,449 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { it, type TestContext } from 'node:test'; +import { setImmediate as nextTurn } from 'node:timers/promises'; +import { StageClient } from '../packages/client/src/index.ts'; +import type { ClientMessage, ServerMessage, RoomStatePayload, ParticipantView } from '../shared/protocol.ts'; +import type { Session } from '../src/ws/hub.ts'; +import { parseClientMessage } from '../src/ws/validate.ts'; +import { embedApp, EmbedNode, EmbedSdk, flush, Deferred } from './fixtures/embedApp.ts'; +import { actualApp, actualState, StageSocket, StagePeer, StageContext } from './fixtures/embedSdkApp.ts'; +import { harness, testConfig, SENDRECV_OFFER } from './helpers.ts'; + +function person(id: string, role: ParticipantView['role'] = 'speaker', onStage = role !== 'audience'): ParticipantView { + return { participantId: id, name: `Private ${id}`, role, onStage, handRaised: false, muted: false, + forceMuted: false, connected: true, gainDb: 0, joinedAt: 1 }; +} +function state(me: Partial = {}): RoomStatePayload { + const own = { ...person('controller', 'controller'), ...me }; + return { roomId: 'same-room', name: 'Synthetic room', controllerId: 'controller', + me: own, speakers: [...(own.onStage ? [own] : []), person('target')], hands: [], audienceCount: 0, + limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', codeRequired: true, + code: 'PRIVATE-CODE', resumeToken: 'PRIVATE-TOKEN' }; +} +function publish(sdk: EmbedSdk, snapshot = state()): void { sdk.state = snapshot; sdk.me = snapshot.me; sdk.emit('state', snapshot); } +function report(rttMs = 37, participantId = 'controller') { + return { participants: [{ participantId, uplinkLossPercent: 8, downlinkLossPercent: 2, rttMs }] }; +} +function summary(uplink = '8.0%', downlink = '2.0%', rtt = '37 ms'): string { + return `你的連線品質(最近收到):上行掉包 ${uplink} · 下行掉包 ${downlink} · RTT ${rtt}(非端到端音訊延遲)`; +} +function shown(node: EmbedNode, text = summary()): void { assert.equal(node.hidden, false); assert.equal(node.textContent, text); } +function empty(node: EmbedNode): void { assert.equal(node.hidden, true); assert.equal(node.textContent, ''); } +function instrument(node: EmbedNode, hook: (property: 'textContent' | 'hidden') => void = () => {}) { + const writes = { textContent: 0, hidden: 0 }; + for (const property of ['textContent', 'hidden'] as const) { + let value = node[property]; + Object.defineProperty(node, property, { configurable: true, get: () => value, set(next: typeof value) { + assert.notEqual(next, value, `equal ${property} must not be written`); + value = next; writes[property]++; hook(property); + } }); + } + return writes; +} +async function setup(t: TestContext, options: Parameters[0] = {}) { + const app = embedApp(options); t.after(() => app.cleanup()); const sdk = await app.join(); + publish(sdk); return { app, sdk, node: app.node('own-connection-quality') }; +} +function transfer(app: ReturnType): EmbedNode { + const button = app.node('stage-management-list').children[0]?.children[1]?.children[2]; assert.ok(button); return button; +} + +it('embed quality authors one empty hidden ordinary paragraph and no automatic announcement', async t => { + const html = readFileSync(new URL('../web/embed.html', import.meta.url), 'utf8'); + assert.equal(html.match(/id="own-connection-quality"/g)?.length, 1); + assert.match(html, / -

尚未加入

關閉房間會讓所有人離場,且無法再加入此房間。離開房間只會讓你離場,房間可能保留並由其他人接任主控。

+

尚未加入

關閉房間會讓所有人離場,且無法再加入此房間。離開房間只會讓你離場,房間可能保留並由其他人接任主控。

diff --git a/web/src/embed.ts b/web/src/embed.ts index d1a637e..0fe64ca 100644 --- a/web/src/embed.ts +++ b/web/src/embed.ts @@ -6,6 +6,7 @@ function input(id: string): HTMLInputElement { } const status = document.querySelector('#status')!; const roomOccupancy = document.querySelector('#room-occupancy')!; +const ownConnectionQuality = document.querySelector('#own-connection-quality')!; const errorNotice = document.querySelector('#error-notice')!; const lastError = document.querySelector('#last-error')!; const handRejectionNotice = document.querySelector('#hand-rejected')!; @@ -173,6 +174,83 @@ function renderOccupancy(): void { if (roomOccupancy.textContent !== text) { roomOccupancy.textContent = text; if (!valid()) return; } if (roomOccupancy.hidden) { roomOccupancy.hidden = false; if (!valid()) return; } } +interface QualitySeat { + client: Client; generation: number; roomId: string; participantId: string; +} +interface QualityReport { + uplink: number | undefined; downlink: number | undefined; rtt: number | undefined; +} +let qualitySeat: QualitySeat | undefined; +let qualityReport: QualityReport | undefined; +let qualityRevision = 0; +// A receipt window is local presentation ownership, not a measurement timestamp or SDK stage epoch. +function retireQuality(): void { qualitySeat = undefined; qualityReport = undefined; qualityRevision++; } +function eligibleQualitySeat(current: Client): boolean { + const state = current.state, me = state?.me; + return current === client && current === controlsClient && occupancyRetiredGeneration !== entryGeneration + && (current.status === 'waiting' || current.status === 'live') + && typeof state?.roomId === 'string' && Boolean(state.roomId.trim()) + && typeof me?.participantId === 'string' && Boolean(me.participantId.trim()) + && me.onStage === true && (me.role === 'controller' || me.role === 'speaker'); +} +function reconcileQuality(): void { + const seat = qualitySeat; + if (client === controlsClient && client?.status === 'disconnected') occupancyRetiredGeneration = entryGeneration; + if (seat && (seat.generation !== entryGeneration || !eligibleQualitySeat(seat.client) + || seat.client.state?.roomId !== seat.roomId || seat.client.state.me.participantId !== seat.participantId)) retireQuality(); +} +function projectQuality(detail: unknown, participantId: string): QualityReport | undefined { + if (!detail || typeof detail !== 'object' || Array.isArray(detail)) return; + const participants: unknown = Reflect.get(detail, 'participants'); + if (!Array.isArray(participants)) return; + let own: object | undefined; + for (const item of participants) { + if (!item || typeof item !== 'object' || Array.isArray(item) || item.participantId !== participantId) continue; + if (own) return; // Multiple own entries are an ambiguous full replacement. + own = item; + } + if (!own) return; + const loss = (value: unknown): number | undefined => typeof value === 'number' && Number.isFinite(value) + && value >= 0 && value <= 100 ? value : undefined; + const rtt: unknown = Reflect.get(own, 'rttMs'); + return { uplink: loss(Reflect.get(own, 'uplinkLossPercent')), downlink: loss(Reflect.get(own, 'downlinkLossPercent')), + rtt: typeof rtt === 'number' && Number.isFinite(rtt) && rtt >= 0 ? rtt : undefined }; +} +function renderQuality(): void { + reconcileQuality(); + const revision = qualityRevision, seat = qualitySeat, report = qualityReport; + const current = client, controls = controlsClient, generation = entryGeneration; + const owns = (): boolean => revision === qualityRevision && seat === qualitySeat && report === qualityReport + && current === client && controls === controlsClient && generation === entryGeneration; + const valid = (): boolean => { + // A nested receipt may win before the same setter silently changes its SDK eligibility. + if (!owns()) { renderQuality(); return false; } + reconcileQuality(); + // Both consents are invalidated before any corrective DOM can restore public SDK eligibility. + const closeInvalid = Boolean(pendingRoomClose && !pendingRoomClose.invalidated + && (!roomCloseOwner || pendingRoomClose.revision !== roomCloseOwner.revision || !eligibleRoomClose(roomCloseOwner))); + const transferInvalid = Boolean(pendingTransfer && !pendingTransfer.invalidated + && (!stageManagementOwner || pendingTransfer.revision !== stageManagementOwner.revision + || !eligibleTransfer(stageManagementOwner, pendingTransfer.id, pendingTransfer.row))); + if (closeInvalid) pendingRoomClose!.invalidated = true; + if (transferInvalid) pendingTransfer!.invalidated = true; + if (!owns() || closeInvalid || transferInvalid) { + syncRoomClose(); syncStageManagement(); + renderQuality(); return false; + } + return true; + }; + if (!valid()) return; + if (!seat || !report) { + if (!ownConnectionQuality.hidden) { ownConnectionQuality.hidden = true; if (!valid()) return; } + if (ownConnectionQuality.textContent !== '') { ownConnectionQuality.textContent = ''; if (!valid()) return; } + return; + } + const percent = (value: number | undefined): string => value === undefined ? '—' : `${value.toFixed(1)}%`; + const text = `你的連線品質(最近收到):上行掉包 ${percent(report.uplink)} · 下行掉包 ${percent(report.downlink)} · RTT ${report.rtt === undefined ? '—' : `${Math.round(report.rtt)} ms`}(非端到端音訊延遲)`; + if (ownConnectionQuality.textContent !== text) { ownConnectionQuality.textContent = text; if (!valid()) return; } + if (ownConnectionQuality.hidden) { ownConnectionQuality.hidden = false; if (!valid()) return; } +} let feedbackRevision = 0; let handRejectionOwner: { client: Client; roomId: string; participantId: string; generation: number } | undefined; function retireHandRejection(): void { @@ -208,7 +286,7 @@ function retireControls(): void { } /** Retire initial-join feedback before user or terminal callbacks can start another entry. */ function retireEntry(): number { - const generation = ++entryGeneration; retireOccupancy(); + const generation = ++entryGeneration; retireOccupancy(); retireQuality(); hideCancel(); submit.disabled = false; submit.setAttribute('aria-busy', 'false'); return generation; @@ -934,7 +1012,7 @@ function signalingUrl(host: URL, value: string): URL { join.onsubmit = (event) => { event.preventDefault(); const previous = client, generation = ++entryGeneration; - retireOccupancy(); + retireOccupancy(); retireQuality(); clearError(); const hostValue = input('host').value, pathValue = input('ws-path').value; const joinOptions = { roomId: input('room').value.trim(), code: input('code').value.trim() || undefined, name: input('name').value.trim() }; @@ -942,6 +1020,7 @@ join.onsubmit = (event) => { const feedback = feedbackRevision; let progress = true, receivedState = false, cancelledEntry = false; const occupancyReceipts = new WeakSet(); + const qualityReceipts = new WeakSet(); let entryClient: Client | undefined; const showProgress = (message: string): void => { if (generation === entryGeneration && feedback === feedbackRevision && progress) status.textContent = message; @@ -956,6 +1035,7 @@ join.onsubmit = (event) => { hand.disabled = leave.disabled = true; hand.setAttribute('aria-busy', 'false'); syncMic(); syncStage(); syncMicRetry(); renderOccupancy(); + if (cancelled === entryGeneration) renderQuality(); if (cancelled !== entryGeneration) return; try { const stopping = entryClient?.disconnect(); @@ -972,6 +1052,7 @@ join.onsubmit = (event) => { hand.setAttribute('aria-busy', 'false'); hand.disabled = leave.disabled = true; renderOccupancy(); + if (generation === entryGeneration) renderQuality(); if (generation !== entryGeneration) return; await previous?.disconnect(); if (generation !== entryGeneration) return; @@ -990,10 +1071,16 @@ join.onsubmit = (event) => { ? library.MAX_GAIN_DB : undefined; syncUnlock(); client.on('state', ({ detail }: CustomEvent) => { - if (client !== current || cancelledEntry || generation !== entryGeneration || occupancyRetiredGeneration === generation || controlsClient !== current) return; - // SDK installs state before status; only its actual, previously unseen state receipt is fresh. + // Record rejected receipts too: their mutable objects cannot later become a new admission. const unseen = detail && typeof detail === 'object' && !occupancyReceipts.has(detail); if (detail && typeof detail === 'object') occupancyReceipts.add(detail); + if (client !== current || cancelledEntry || generation !== entryGeneration || occupancyRetiredGeneration === generation || controlsClient !== current) return; + // SDK installs state before status; only its actual, previously unseen state receipt is fresh. + reconcileQuality(); + if (detail === current.state && unseen && eligibleQualitySeat(current) && !qualitySeat) { + qualitySeat = { client: current, generation, roomId: detail.roomId, participantId: detail.me.participantId }; + qualityRevision++; + } reconcileOccupancy(); if (detail === current.state && unseen && (current.status === 'waiting' || current.status === 'live')) { @@ -1015,7 +1102,23 @@ join.onsubmit = (event) => { syncHand(); syncMic(); syncStage(); syncMicRetry(); syncRename(); syncRoomName(); syncHandQueue(); syncStageManagement(); syncRoomClose(); syncMicrophone(); syncAudioRetry(); syncOutput(); if (renderingEntry === entryGeneration && client === current && controlsClient === current) { leave.disabled = false; renderOccupancy(); + if (renderingEntry === entryGeneration && client === current && controlsClient === current) renderQuality(); + } + }); + client.on('quality', ({ detail }) => { + const unseen = !detail || typeof detail !== 'object' || !qualityReceipts.has(detail); + if (detail && typeof detail === 'object') qualityReceipts.add(detail); + if (client !== current || cancelledEntry || generation !== entryGeneration || controlsClient !== current) return; + reconcileQuality(); + const seat = qualitySeat, revision = qualityRevision; + if (unseen && seat) { + let projection: QualityReport | undefined; + try { projection = projectQuality(detail, seat.participantId); } catch { /* Malformed public events fail closed. */ } + if (revision !== qualityRevision || seat !== qualitySeat || client !== current || generation !== entryGeneration) { renderQuality(); return; } + reconcileQuality(); + if (seat === qualitySeat) { qualityReport = projection; qualityRevision++; } } + renderQuality(); }); const retireOwnHandRejection = (participantId: string): void => { if (client === current && controlsClient === current && !cancelledEntry && generation === entryGeneration @@ -1033,8 +1136,12 @@ join.onsubmit = (event) => { || (stageManagementRows.has(detail.participantId) && detail.role !== 'speaker')); // Both confirmations lose authority before either control can synchronously reenter from render. if (transferFenced) fenceTransfer(false); + const ownDeparture = client === current && controlsClient === current && !cancelledEntry && generation === entryGeneration + && detail.participantId === current.me?.participantId && detail.role === 'audience'; + if (ownDeparture) retireQuality(); if (closeFenced) syncRoomClose(); if (transferFenced) syncStageManagement(); + if (ownDeparture && client === current && generation === entryGeneration) renderQuality(); }); client.on('transferred', ({ detail }) => { const closeFenced = fenceRoomClose(current, generation, detail.fromId); @@ -1044,7 +1151,13 @@ join.onsubmit = (event) => { if (transferFenced) syncStageManagement(); }); client.on('stageleft', ({ detail }) => { - if (ownsTransferEvents() && stageManagementRows.has(detail.participantId)) fenceTransfer(); + const ownDeparture = client === current && controlsClient === current && !cancelledEntry && generation === entryGeneration + && detail.participantId === current.me?.participantId; + if (ownDeparture) retireQuality(); + const transferFenced = ownsTransferEvents() && stageManagementRows.has(detail.participantId); + if (transferFenced) fenceTransfer(false); + if (transferFenced) syncStageManagement(); + if (ownDeparture && client === current && generation === entryGeneration) renderQuality(); }); client.on('handrejected', ({ detail }) => { // No per-raise identity is provided; feedback belongs only to the current eligible seat. @@ -1070,8 +1183,8 @@ join.onsubmit = (event) => { }); client.on('status', ({ detail }) => { if (client !== current || cancelledEntry || generation !== entryGeneration) return; - if (detail === 'disconnected') { occupancyRetiredGeneration = generation; retireOccupancy(); } - else reconcileOccupancy(); + if (detail === 'disconnected') { occupancyRetiredGeneration = generation; retireOccupancy(); retireQuality(); } + else { reconcileOccupancy(); if (detail === 'reconnecting') retireQuality(); else reconcileQuality(); } const renderingEntry = entryGeneration; syncRoomClose(); if (renderingEntry !== entryGeneration || client !== current) return; @@ -1080,6 +1193,7 @@ join.onsubmit = (event) => { else if (detail === 'disconnected') { progress = false; retirePlayback(); retireControls(); status.textContent = '未連線'; hand.disabled = leave.disabled = true; } syncHand(); syncMic(); syncStage(); syncMicRetry(); syncRename(); syncRoomName(); syncHandQueue(); syncStageManagement(); syncRoomClose(); syncMicrophone(); syncAudioRetry(); syncOutput(); if (renderingEntry === entryGeneration && client === current) renderOccupancy(); + if (renderingEntry === entryGeneration && client === current) renderQuality(); }); client.on('error', ({ detail }) => { if (client !== current || cancelledEntry || generation !== entryGeneration) return; progress = false; report(detail); }); client.on('audioblocked', () => { if (cancelledEntry) return; progress = false; status.textContent = '請點擊啟用音訊。'; }); @@ -1088,6 +1202,7 @@ join.onsubmit = (event) => { const retired = retireEntry(); clearError(); retirePlayback(); retireControls(); syncHand(); syncMic(); syncStage(); syncMicRetry(); syncRename(); syncRoomName(); renderOccupancy(); + if (retired === entryGeneration && client === current) renderQuality(); if (retired !== entryGeneration || client !== current) return; status.textContent = detail.reason === 'shutdown' ? '伺服器維護或重新啟動,房間已關閉。' : '房間已關閉。'; hand.disabled = leave.disabled = true; }); @@ -1096,6 +1211,7 @@ join.onsubmit = (event) => { const retired = retireEntry(); clearError(); retirePlayback(); retireControls(); syncHand(); syncMic(); syncStage(); syncMicRetry(); syncRename(); syncRoomName(); renderOccupancy(); + if (retired === entryGeneration && client === current) renderQuality(); if (retired !== entryGeneration || client !== current) return; status.textContent = '你已被主控移出房間。'; hand.disabled = leave.disabled = true; }); @@ -1309,6 +1425,7 @@ leave.onclick = () => { retirePlayback(); scheduleHandCooldown(0); retireControls(); hand.setAttribute('aria-busy', 'false'); syncHand(); syncMic(); syncStage(); syncMicRetry(); renderOccupancy(); + if (generation === entryGeneration && client === current) renderQuality(); if (generation !== entryGeneration || client !== current) return; void current?.disconnect().then(() => { if (generation !== entryGeneration || client !== current) return; From 13a692bf8b888d52f19234fd4fb1c40e0ca84680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=A5=E8=AA=9E=E3=81=BB=E3=81=97=E3=81=9E=E3=82=89?= <92282870+YueyuHoshizora@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:05:10 +0800 Subject: [PATCH 06/38] fix(widget): fence retired own-quality observation windows Require a new authoritative state and quality receipt after seat or connection retirement, while preserving microphone report-only resets, legitimate same-client rejoins and existing rendering behavior. Validated with 5403 tests, four typechecks, build and independent review. --- PLAN.md | 3 + README.md | 2 +- packages/client/README.md | 2 +- packages/client/src/element.ts | 148 +++++++-- packages/client/test/element.test.ts | 467 +++++++++++++++++++++++++++ 5 files changed, 588 insertions(+), 34 deletions(-) diff --git a/PLAN.md b/PLAN.md index 29c6422..90435f6 100644 --- a/PLAN.md +++ b/PLAN.md @@ -835,3 +835,6 @@ N 路混音管線(decode→mix→encode)、不含自己的台上混音、lim 220. **最小 embed 本人連線品質摘要(第199批,2026-10-16):** immutable198 完整 embed 與實際 SDK→validator→Hub 證明品質回報已抵達但無呈現入口。新增單一普通 paragraph,僅複製目前 entry/client/controls/room/participant 已入台觀察窗口的本人數值;非法或缺失欄位顯示破折號,缺少本人或重複本人項清空,不讀他人指標或私有資料。state 與 quality receipt identity 記錄包含拒絕事件,舊 object 不重放;重連、本人 audience role/stage:left、離房/終止/替換先 pure retirement,distinct 當前權威 state 與後續新 quality 才重新顯示。status/stagejoined/role 不自行恢復;同席位普通快照與保持台位的移交保留歷史,micerror/micready/mute/retry 不冒充傳輸歷史失效或恢復。新 DOM 位於既有 close/transfer 權限核對之後,每次變更 setter 後驗證 revision/owner/entry/status/identity/onStage,較新同步回呼優先,靜默資格變化先撤銷兩種 consent 再修正顯示。10 檔限定 embed HTML/源碼/新測試/五處 fixture 與 README/PLAN,不改 SDK/server/widget/protocol/media/styles/config/依賴;無新輪詢、timer、命令或媒體操作。回報無 timestamp/epoch,「最近收到」不保證量測新鮮度、擷取/播放成功或可聽,RTT 非端到端延遲。原生瀏覽器/鍵盤/AT/硬體音訊尚未驗收。 + + +221. **Widget 本人品質摘要生命週期隔離(第200批,2026-10-16):** immutable199 真 StageElement/SDK→Hub 證明本人 role→stage:left→state 前序通知中舊品質仍可見/被重放復活,且 reconnect retained state 可藉 status-only 與拒絕事件重放恢復。限定 widget quality 的 pure admission retirement、report-only clear 與 rendering 分離;本人 audience role/stageleft、重連/斷線/終止、離房換席撤銷資格,只有 distinct 當前權威 state 再加新 quality 才顯示,拒絕與接納 receipt identity 均記錄。保持 micerror 只清報告、下一份新 quality 不必等 state;保持同 client 合法 terminal 後同 ID 重入、stage-preserving transfer、duplicate-own 最後完整項、數值格式/原始 stage-quality 轉送及本人 primitive 隔離。保留原品質段落 text replacement 與同文字宿主子節點修復,不作等值寫入效能優化。manual leave 先 pure retirement,等 current=null 與既有權限撤銷後才 paint;每個 DOM 邊界驗證當前 ownership,含 nested 新 report 後又靜默失權的 tail,較新合法 entry 不被舊 continuation 覆寫。兩種 close/transfer consent 在修正 DOM 前撤銷,不作一般 pending/SDK 生命週期重寫。五檔 element 源碼/測試+三份文件,無 SDK/server/protocol/media/API/依賴變更或額外 timer/poll/request。事件無 timestamp/peer epoch,object replay 防護不代表線路量測 freshness;原生瀏覽器/AT/硬體音訊未驗收。 diff --git a/README.md b/README.md index b4d68f5..fa5ef42 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,7 @@ Web Component 也顯示最近快照的房間席位:主控以外的總人數/ Web Component 另以普通文字顯示最近收到的發言者回報,按目前台上順序列出名稱與人數;尚未收到、明確空集合與無法對應台上成員分開呈現。改名或移出台上隨快照更新,控制連線寬限中仍在台上的成員不被排除;觀眾與未知 ID 不會洩露。重連、離房或換席清除舊集合,等待新回報。這是伺服器活動偵測,沒有時間戳,並非即時或可聽音訊保證,空集合也不證明沒有音訊;不增加輪詢、媒體工作或 live region,原生輔助科技與實際音訊尚未驗收。 -Web Component 的台上者收到伺服器本人品質回報後,可看到最近收到的上/下行掉包率及 RTT;只顯示本人,觀眾/台下主控不顯示。缺失值為「—」,不代表 0;RTT 並非端到端音訊延遲。重連、下台、換席或麥克風錯誤清除舊摘要,後續新的本人報告才重新顯示,`micready` 不會還原。報告沒有時間戳或 peer 世代,因此數字不是即時健康或麥克風恢復證明;不增加輪詢或音訊操作。 +Web Component 的台上者收到伺服器本人品質回報後,可看到最近收到的上/下行掉包率及 RTT;只顯示本人,觀眾/台下主控不顯示。缺失值為「—」,不代表 0;RTT 並非端到端音訊延遲。重連、本人下台/轉為觀眾、換席或終止會清除舊摘要;須先收到目前 client 的新權威台上 state,再收到新的本人報告才重新顯示。單獨 status 恢復、角色通知或重放已接收/拒絕的事件物件不能還原。相同 client 合法離房重入仍可由新 state 與新報告恢復。麥克風錯誤僅清除報告,保留合格台位的接收資格,因此不必另等 state,後續新本人報告可顯示;`micready` 不會還原。報告沒有時間戳或 peer 世代,因此數字不是即時健康或麥克風恢復證明;不增加輪詢或音訊操作。 Web Component 的「離開房間」會停止本機收聽/擷取及自動重連,保留屬性與音量/收聽靜音偏好;同一元素更換屬性或移除再掛載後仍等待手動「重新加入」。重入採用最新屬性,不保證原台位/主控身分;關房/被踢不提供這個重入操作。離房先退休 client,因此與元件移除一樣不轉送其晚到事件(含 disconnect status),也不新增合成 SDK 狀態。此 UI 使用既有 disconnect,不代表伺服器房間立即關閉;受控 DOM/SDK 假媒體測試仍不等於真實瀏覽器音訊驗收。 diff --git a/packages/client/README.md b/packages/client/README.md index 26e690a..8663917 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -216,7 +216,7 @@ WebSocket 建構失敗、hello 逾時或尚未進房的連線關閉,也會釋 - 發言者摘要:所有已進房者可看到最近收到的伺服器發言偵測回報,按目前台上順序列出名稱與人數。尚未收到時顯示等待,明確空集合才顯示「未偵測到發言」;回報中的 ID 無法對應台上成員時另作說明,不公開未知 ID 或觀眾名稱。名稱依目前快照安全顯示,改名與移出台上會同步更新;已移除或當下無法對應的 ID 不會因之後上台而復活舊活動,須等新回報;控制連線暫時斷開但仍在台上的寬限席位不因此排除。重連、終止連線、離房或換席清除舊集合,恢復快照後須再收到新回報,不從 SDK 保留的 getter 還原。這是伺服器活動偵測(VAD),沒有時間戳,不保證即時或實際可聽見音訊;空集合也不是無音訊的證明。本機收聽靜音、音量、自動播放與傳輸狀態可能不同。不增加輪詢或媒體操作,原 `stage-speaking` detail 仍原樣轉送;摘要為普通文字,未新增頻繁宣讀的 live region。可用 `::part(speaking)` 設定外觀;原生畫面、輔助科技與實體音訊仍需驗收。 -- 本人連線品質:台上者收到自己的 `quality` 報告後,顯示最近收到的上/下行掉包率及 RTT;缺失或無效值顯示「—」,0 仍顯示 0,不據此推斷健康。只取本人 ID,不顯示其他發言者資料;觀眾與台下主控隱藏。重連、下台、離房/換席及房間麥克風錯誤會清除,`micready` 不會還原舊值,必須再收到本人報告;一般同席快照保留已收到的數字。這是歷史回報而非即時狀態,RTT 不是端到端音訊延遲;事件沒有時間戳/peer 世代,麥克風錯誤後才到達的報告也不證明已恢復。伺服器無新報告時不自動推測過期或良好。沒有額外輪詢、媒體操作或控制請求,`stage-quality` 照原物件轉送;原生畫面、輔助科技與音訊仍需驗收。 +- 本人連線品質:台上者收到自己的 `quality` 報告後,顯示最近收到的上/下行掉包率及 RTT;缺失或無效值顯示「—」,0 仍顯示 0,不據此推斷健康。只取本人 ID,不顯示其他發言者資料;觀眾與台下主控隱藏。重連、本人下台/轉為觀眾、離房/換席或終止會撤銷舊接收資格並清空;新的本人報告須在目前 client 的新權威台上 state 之後才可採用。status、角色/上台通知與任意重繪不開啟資格,已接收或拒絕的 state/quality 事件物件重放也不算新收件。公開 client 合法斷線後再加入同房同身分仍支援,新 state 加新報告即可顯示,並非永久封鎖該 client。房間麥克風錯誤僅清空報告,仍合格時可直接接收下一份新本人報告而不需另等 state;`micready` 不還原舊值。一般同席快照及保留台位的主控/講者移交保留已收到的數字。這是歷史回報而非即時狀態,RTT 不是端到端音訊延遲;事件沒有時間戳/peer 世代,麥克風錯誤後才到達的報告也不證明已恢復。伺服器無新報告時不自動推測過期或良好。沒有額外輪詢、媒體操作或控制請求,`stage-quality` 照原物件轉送;原生畫面、輔助科技與音訊仍需驗收。 - 舉手佇列位置來自目前 `state.hands` 的本人 ID;快照更新時重算,未找到本人則只顯示「已舉手」,重連期間標示「上次佇列」。保留寬限期的斷線者仍在佇列中;主控可任意核准人選,因此這不是保證的發言順序或等待時間。 - 輸出裝置選擇:已進房時可在「輸出裝置選擇(草稿)」手動重新整理揚聲器/耳機清單,再明確送出選擇;選單變更本身不呼叫 setSinkId。空字串沿用既有 SDK 的系統預設輸出,不支援 outputDeviceSupported 的瀏覽器顯示說明並停用此組控制。名稱以 textContent 顯示,匿名裝置有通用名稱;重新整理後缺失的草稿保留為未列出項,不偷偷改用其他裝置。重新整理與送出共用等待鎖,送出完成只恢復控制,草稿不表示目前實際輸出、已套用或可聽音訊;不會自動 enumerate、play、開麥克風或儲存裝置 ID,也不另呼叫權限申請 API;setSinkId 的裝置存取與權限仍由瀏覽器決定。 輸出設定作用於同一 client 的 audio 元件,因此等待/草稿/清單按 client 生命週期保留,而非按 room/participant 重設。公開 client 斷線、關房、換席或重入相同/不同房間時,沒有有效房間便隱藏停用;重新取得快照仍要等原輸出操作完成才可再次送出。一般重連保留顯示但停用。真正更換 client 或移除元件才退役舊 UI,既有 setSinkId 無取消 API,不能取消已交給瀏覽器的操作;舊 client 的遲到完成不操作新 client 的控制。永不完成的操作持續等待,沒有新增 timeout/強制解除;目前有效連線且可見 owner 的失敗可明確重試,隱藏或重連等待期間的失敗不另冒出錯誤。宿主直接呼叫 element.client.setOutputDevice 不經此 UI 鎖,不能保證與宿主並行選擇的先後結果。可用 ::part(output-device) 設定外觀;驗證限公開 SDK 與受控 DOM/audio/socket,原生裝置、權限、鍵盤、AT 與實際播放未驗收。 diff --git a/packages/client/src/element.ts b/packages/client/src/element.ts index dd1dbc1..8ebdbdb 100644 --- a/packages/client/src/element.ts +++ b/packages/client/src/element.ts @@ -84,6 +84,13 @@ export class StageElement extends HTMLElement { private readonly quality: HTMLParagraphElement; private qualityOwner: { client: StageClient; roomId: string; participantId: string; generation: number } | undefined; private qualityReport: Omit | undefined; + private readonly qualitySnapshots = new WeakSet(); + private readonly qualityReceipts = new WeakSet(); + private qualityEventRevision = 0; + private qualityRevision = 0; + // Extra callback reconciliation repaints only changed projection; original paint triggers stay unconditional. + private qualityPaintedRevision = -1; + private qualityClearFirst = true; private readonly speaking: HTMLParagraphElement; private speakingOwner: { client: StageClient; roomId: string; participantId: string; generation: number } | undefined; private speakingReport: { ids: Set; empty: boolean } | undefined; @@ -453,9 +460,11 @@ export class StageElement extends HTMLElement { for (const type of EVENTS) { client.on(type, ({ detail }) => { if (generation !== this.generation || this.current !== client) return; + const qualityEvent = this.rememberQualityEvent(type, detail); // Own-role deltas precede their snapshot. Fence both dialogs before any DOM setter can reenter. this.noteRoomCloseEvent(type, detail); this.noteStageTransferEvent(type, detail); + this.noteQualityEvent(client, generation, type, detail, qualityEvent); if (type === 'closed' || type === 'kicked') this.microphoneTerminalClient = client; else if (type === 'state') this.microphoneTerminalClient = undefined; if (type === 'closed' || type === 'kicked') this.outputDeviceTerminalClient = client; @@ -487,11 +496,9 @@ export class StageElement extends HTMLElement { || (type === 'role' && (detail as StageEventMap['role']).role !== 'audience' && (detail as StageEventMap['role']).participantId === ownId) || (type === 'transferred' && (detail as StageEventMap['transferred']).toId === ownId)) this.retireHandRejection(); else this.syncHandRejection(type === 'handrejected' ? detail as StageEventMap['handrejected'] : undefined); - if (type === 'closed' || type === 'kicked' || type === 'micerror' - || (type === 'status' && (detail === 'reconnecting' || detail === 'disconnected')) - || (type === 'stageleft' && (detail as StageEventMap['stageleft']).participantId === client.state?.me.participantId)) this.retireQualityOwner(); - else if (type === 'quality') this.syncQualityOwner((detail as StageEventMap['quality']).participants); - else if (type === 'state' || type === 'status') this.syncQualityOwner(); + this.syncQualityOwner(type === 'quality' || type === 'state' || type === 'status' || type === 'closed' + || type === 'kicked' || type === 'micerror' + || (type === 'stageleft' && (detail as StageEventMap['stageleft']).participantId === ownId)); if (type === 'closed' || type === 'kicked' || (type === 'status' && (detail === 'reconnecting' || detail === 'disconnected'))) this.retireSpeakingOwner(); else if (type === 'speaking') this.syncSpeakingOwner({ participantIds: (detail as StageEventMap['speaking'] | null | undefined)?.participantIds }); @@ -522,8 +529,10 @@ export class StageElement extends HTMLElement { // Revoke stale action admission before application event listeners can reenter. if (micLifecycle) this.syncMicRetry(); this.syncOccupancy(); + this.syncQualityOwner(false); this.dispatchEvent(new CustomEvent(`stage-${type}`, { detail, bubbles: true, composed: true })); this.syncOccupancy(); + this.syncQualityOwner(false); // Application callbacks may retire this seat or mutate its exposed snapshot. this.syncMicrophoneOwner(); this.syncOutputDeviceOwner(); @@ -573,6 +582,7 @@ export class StageElement extends HTMLElement { const exit = {}; this.leftRoom = true; this.roomExit = exit; this.retireOccupancy(); + this.retireQualityOwner(); this.hideError(); // Retire the event lane before disconnect can synchronously invoke application callbacks. void this.leave().catch(error => { @@ -585,6 +595,7 @@ export class StageElement extends HTMLElement { private async leave(): Promise { this.retireOccupancy(); + this.retireQualityOwner(); this.retireRoomClose(); this.roomCloseAuthority = undefined; this.roomCloseTerminalClient = undefined; this.joinPhase = undefined; @@ -600,7 +611,6 @@ export class StageElement extends HTMLElement { this.retireStageManagement(); this.audioRecoveryTerminalClient = undefined; this.retireAudioRecovery(); - this.retireQualityOwner(); this.retireSpeakingOwner(); this.retireHandRejection(); this.hideCancel(); @@ -812,8 +822,8 @@ export class StageElement extends HTMLElement { } private retireQualityOwner(): void { - this.qualityOwner = undefined; this.qualityReport = undefined; - this.quality.textContent = ''; this.quality.hidden = true; + ++this.qualityRevision; + this.qualityOwner = undefined; this.qualityReport = undefined; this.qualityClearFirst = true; } private retireSpeakingOwner(): void { @@ -880,35 +890,109 @@ export class StageElement extends HTMLElement { : '最近收到的發言回報:沒有可對應的台上成員。'; } - /** Reports have no timestamp or media epoch; only a new event may supply this seat's values. */ - private syncQualityOwner(reports?: readonly ConnectionQuality[]): void { - const client = this.current, state = client?.state, me = state?.me; - if (!this.isConnected || !client || !state || !me?.onStage - || client.status === 'reconnecting' || client.status === 'disconnected') { - this.retireQualityOwner(); return; - } - const owner = this.qualityOwner; - if (!owner || owner.client !== client || owner.generation !== this.generation - || owner.roomId !== state.roomId || owner.participantId !== me.participantId) { + /** Retirement is pure: a corrective DOM write must never precede the dialog authority fences. */ + private reconcileQualityOwner(): void { + for (;;) { + const owner = this.qualityOwner; + if (!owner) return; + const revision = this.qualityRevision, client = owner.client; + const status = client.status, state = client.state; + // Public getters can finish nested work before returning. Recheck the live owner, not just this painter. + const currentState = client.state, currentStatus = client.status; + if (owner !== this.qualityOwner || revision !== this.qualityRevision) continue; + if (state !== currentState || status !== currentStatus) continue; + if (this.isConnected && this.current === client && this.generation === owner.generation + && state?.roomId === owner.roomId && state.me.participantId === owner.participantId + && state.me.onStage && (status === 'waiting' || status === 'live')) return; this.retireQualityOwner(); - this.qualityOwner = { client, roomId: state.roomId, participantId: me.participantId, generation: this.generation }; + this.noteRoomCloseEvent('status', currentStatus); + this.noteStageTransferEvent('status', currentStatus); + // Either authority getter may have admitted a newer entry; reconcile that entry too. } - if (reports !== undefined) { - let report: ConnectionQuality | undefined; - for (const candidate of reports) if (candidate.participantId === me.participantId) report = candidate; - // The event remains public and mutable; retain only primitive values from its full replacement set. - this.qualityReport = report ? { - uplinkLossPercent: typeof report.uplinkLossPercent === 'number' ? report.uplinkLossPercent : undefined, - downlinkLossPercent: typeof report.downlinkLossPercent === 'number' ? report.downlinkLossPercent : undefined, - rttMs: typeof report.rttMs === 'number' ? report.rttMs : undefined, - } : undefined; + } + + private rememberQualityEvent(type: keyof StageEventMap, detail: StageEventMap[keyof StageEventMap]): { revision: number; unseen: boolean } { + const revision = ++this.qualityEventRevision; + const identities = type === 'state' ? this.qualitySnapshots : type === 'quality' ? this.qualityReceipts : undefined; + const unseen = Boolean(identities && detail && !identities.has(detail as object)); + // Consume before any public getter: even rejected or superseded receipts cannot gain authority on replay. + if (identities && detail) identities.add(detail as object); + const ownId = this.qualityOwner?.participantId; + // Known loss must precede even authority getters: a nested report alone cannot outrun retirement. + if (type === 'closed' || type === 'kicked' + || (type === 'status' && (detail === 'reconnecting' || detail === 'disconnected')) + || (ownId !== undefined && type === 'stageleft' && (detail as StageEventMap['stageleft']).participantId === ownId) + || (ownId !== undefined && type === 'role' && (detail as StageEventMap['role']).participantId === ownId + && (detail as StageEventMap['role']).role === 'audience')) this.retireQualityOwner(); + else if (type === 'micerror') { + ++this.qualityRevision; this.qualityReport = undefined; this.qualityClearFirst = true; + } + return { revision, unseen }; + } + + private noteQualityEvent(client: StageClient, generation: number, type: keyof StageEventMap, + detail: StageEventMap[keyof StageEventMap], event: { revision: number; unseen: boolean }): void { + if (event.revision !== this.qualityEventRevision || this.current !== client || this.generation !== generation) return; + const snapshot = type === 'state' ? detail as StageEventMap['state'] : undefined; + const receipt = type === 'quality' ? detail as StageEventMap['quality'] : undefined; + this.reconcileQualityOwner(); + const state = client.state; + if (event.revision !== this.qualityEventRevision || this.current !== client || this.generation !== generation) return; + if (snapshot && event.unseen && snapshot === state && this.isConnected && state.me.onStage + && (client.status === 'waiting' || client.status === 'live') + && snapshot === client.state && this.current === client && this.generation === generation + && event.revision === this.qualityEventRevision) { + if (!this.qualityOwner) { + this.qualityOwner = { client, roomId: state.roomId, participantId: state.me.participantId, generation }; + ++this.qualityRevision; this.qualityClearFirst = false; + } } - const report = this.qualityReport; - this.quality.hidden = !report; - if (!report) { this.quality.textContent = ''; return; } + this.reconcileQualityOwner(); + const owner = this.qualityOwner; + if (!receipt || !event.unseen || !owner || event.revision !== this.qualityEventRevision) return; + let report: ConnectionQuality | undefined; + for (const candidate of receipt.participants) if (candidate.participantId === owner.participantId) report = candidate; + // Preserve the last whole own item, and copy only its primitives before forwarding the raw detail. + const up = report?.uplinkLossPercent, down = report?.downlinkLossPercent, rtt = report?.rttMs; + const copied = report ? { + uplinkLossPercent: typeof up === 'number' ? up : undefined, + downlinkLossPercent: typeof down === 'number' ? down : undefined, + rttMs: typeof rtt === 'number' ? rtt : undefined, + } : undefined; + this.reconcileQualityOwner(); + if (owner !== this.qualityOwner || event.revision !== this.qualityEventRevision) return; + ++this.qualityRevision; this.qualityReport = copied; this.qualityClearFirst = false; + } + + /** Reports have no timestamp or media epoch; only a new admitted receipt supplies values. */ + private syncQualityOwner(force = true): void { + this.reconcileQualityOwner(); + if (!force && this.qualityPaintedRevision === this.qualityRevision) return; + const revision = this.qualityRevision, report = this.qualityReport; + const current = (): boolean => { + const before = this.qualityRevision; + // Even a superseded painter must observe silent ownership loss after nested newer work. + this.reconcileQualityOwner(); + if (before !== this.qualityRevision) { this.syncQualityOwner(); return false; } + return revision === this.qualityRevision; + }; const percent = (value: number | undefined) => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 100 ? `${value.toFixed(1)}%` : '—'; const rtt = (value: number | undefined) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? `${Math.round(value)} ms` : '—'; - this.quality.textContent = `你的連線品質(最近收到):上行掉包 ${percent(report.uplinkLossPercent)} · 下行掉包 ${percent(report.downlinkLossPercent)} · RTT ${rtt(report.rttMs)}(非端到端音訊延遲)`; + const text = report ? `你的連線品質(最近收到):上行掉包 ${percent(report.uplinkLossPercent)} · 下行掉包 ${percent(report.downlinkLossPercent)} · RTT ${rtt(report.rttMs)}(非端到端音訊延遲)` : ''; + // Keep unconditional text replacement, including repair of same-text host-added descendants. + if (this.qualityClearFirst) { + this.quality.textContent = text; + if (!current()) return; + this.quality.hidden = !report; + } else { + this.quality.hidden = !report; + if (!current()) return; + this.quality.textContent = text; + } + if (current()) { + this.qualityPaintedRevision = revision; + if (this.qualityOwner) this.qualityClearFirst = false; + } } private retireNameOwner(): void { diff --git a/packages/client/test/element.test.ts b/packages/client/test/element.test.ts index 3129722..e84b81d 100644 --- a/packages/client/test/element.test.ts +++ b/packages/client/test/element.test.ts @@ -12495,3 +12495,470 @@ async function occupancyHub(t: TestContext, maxSpeakers = 1, maxAudience = 3) { assert.equal(confirm.mock.callCount(), 0); assert.equal(close.mock.callCount(), 0); assert.equal(transfer.mock.callCount(), 0); }); }); + +/** Complete widget and SDK regression coverage; controlled DOM does not model browser paint or audio. */ +describe('widget own quality observation window', () => { + const report = (rttMs = 46) => ({ participants: [{ participantId: 'me', rttMs }] }); + const emit = (client: StageClient, type: string, detail: unknown) => client.dispatchEvent(new CustomEvent(type, { detail })); + const view = (element: InstanceType) => ({ hidden: element['quality'].hidden, text: element['quality'].textContent }); + function hidden(element: InstanceType): void { assert.deepEqual(view(element), { hidden: true, text: '' }); } + function shown(element: InstanceType, rtt = 46): void { assert.equal(view(element).hidden, false); assert.match(view(element).text, new RegExp(`RTT ${rtt} ms`)); } + const state = async (client: StageClient, value = roomState(true)) => { await client['receive']({ type: 'room:state', ...value }); return client.state!; }; + function setter(node: object, property: string, action: () => void): () => void { + let value = Reflect.get(node, property), armed = true; + Object.defineProperty(node, property, { configurable: true, get: () => value, set: next => { value = next; if (armed) { armed = false; action(); } } }); + return () => { Object.defineProperty(node, property, { configurable: true, writable: true, value }); }; + } + + + for (const boundary of ['role', 'stageleft', 'reconnect', 'terminal'] as const) { + it(`${boundary} requires unseen current state, and records new reports rejected before it`, async t => { + const { element, client } = await subscribedWidget(t, true); + const accepted = report(46), rejected = report(88), oldState = client.state!; + emit(client, 'quality', accepted); shown(element); + if (boundary === 'role') emit(client, 'role', { participantId: 'me', role: 'audience' }); + else if (boundary === 'stageleft') emit(client, 'stageleft', { participantId: 'me' }); + else if (boundary === 'terminal') emit(client, 'closed', { roomId: 'one' }); + else client['setStatus']('reconnecting'); + hidden(element); + client['setStatus']('live'); + emit(client, 'quality', rejected); hidden(element); + emit(client, 'state', oldState); emit(client, 'quality', report(99)); hidden(element); + await state(client); emit(client, 'quality', accepted); hidden(element); + emit(client, 'quality', rejected); hidden(element); + emit(client, 'quality', report(123)); shown(element, 123); + }); + } + + it('a state identity first rejected during reconnect cannot reopen after status-only recovery', async t => { + const { element, client } = await subscribedWidget(t, true); emit(client, 'quality', report()); + client['setStatus']('reconnecting'); + const rejected = roomState(true); client['snapshot'] = rejected; emit(client, 'state', rejected); hidden(element); + client['setStatus']('live'); emit(client, 'state', rejected); emit(client, 'quality', report(77)); hidden(element); + await state(client); emit(client, 'quality', report(78)); shown(element, 78); + }); + + it('an unseen noncurrent state event is consumed, not retained for later owner admission', async t => { + const { element, client } = await subscribedWidget(t, true); emit(client, 'quality', report()); + emit(client, 'stageleft', { participantId: 'me' }); + const rejected = roomState(true); emit(client, 'state', rejected); hidden(element); + client['snapshot'] = rejected; emit(client, 'state', rejected); emit(client, 'quality', report(79)); hidden(element); + await state(client); emit(client, 'quality', report(80)); shown(element, 80); + }); + + it('SDK status-before-state cannot admit a receipt before the replacement state event', async t => { + const { element, client } = await subscribedWidget(t, true); const rejected = report(97); + client['setStatus']('reconnecting'); let observed: ReturnType | undefined; + element.addEventListener('stage-status', event => { + if ((event as CustomEvent).detail === 'live') { emit(client, 'quality', rejected); observed = view(element); } + }, { once: true }); + await state(client); assert.deepEqual(observed, { hidden: true, text: '' }); hidden(element); + emit(client, 'quality', rejected); hidden(element); + emit(client, 'quality', report(98)); shown(element, 98); + }); + + it('micerror clears the receipt without requiring a new authoritative state', async t => { + const { element, client } = await subscribedWidget(t, true); const accepted = report(); emit(client, 'quality', accepted); + emit(client, 'micerror', { name: 'NotAllowedError', message: 'Unavailable' }); hidden(element); + emit(client, 'micready', undefined); emit(client, 'quality', accepted); hidden(element); + emit(client, 'quality', report(81)); shown(element, 81); + }); + + it('preserves baseline onstage admission despite retained disconnected flag', async t => { + const { element, client } = await subscribedWidget(t, true); + const next = roomState(true); next.me.connected = false; + await state(client, next); emit(client, 'quality', report(90)); shown(element, 90); + }); + + it('replaying accepted quality identity cannot overwrite newer same-owner metrics', async t => { + const { element, client } = await subscribedWidget(t, true); const old = report(91); + emit(client, 'quality', old); emit(client, 'quality', report(92)); shown(element, 92); + old.participants[0]!.rttMs = 999; emit(client, 'quality', old); shown(element, 92); + }); + + it('same-owner state and stage-preserving role transfer preserve copied whole last-own-entry policy', async t => { + const { element, client } = await subscribedWidget(t, true); + const detail = { participants: [{ participantId: 'me', rttMs: 1 }, { participantId: 'me', downlinkLossPercent: 0 }] }; + let forwarded: unknown; element.addEventListener('stage-quality', event => { forwarded = (event as CustomEvent).detail; }, { once: true }); + emit(client, 'quality', detail); assert.equal(forwarded, detail); + const original = view(element); assert.match(original.text, /下行掉包 0.0% · RTT —/); + detail.participants[1]!.downlinkLossPercent = 99; + await state(client); emit(client, 'role', { participantId: 'me', role: 'controller' }); + emit(client, 'transferred', { fromId: 'host', toId: 'me' }); emit(client, 'stageleft', { participantId: 'other' }); + assert.deepEqual(view(element), original); + }); + + for (const property of ['hidden', 'textContent']) { + it(`quality ${property} setter cannot restore a retired receipt after nested ownership loss`, async t => { + const { element, client } = await subscribedWidget(t, true); + const pending = report(82); let calls = 0; + const restore = setter(element['quality'], property, () => { + calls++; emit(client, 'stageleft', { participantId: 'me' }); + emit(client, 'quality', pending); + }); t.after(restore); + emit(client, 'quality', pending); assert.equal(calls, 1); hidden(element); + await state(client); emit(client, 'quality', pending); hidden(element); + emit(client, 'quality', report(83)); shown(element, 83); + }); + + it(`quality ${property} setter keeps newer nested same-owner metrics`, async t => { + const { element, client } = await subscribedWidget(t, true); + const restore = setter(element['quality'], property, () => emit(client, 'quality', report(85))); t.after(restore); + emit(client, 'quality', report(84)); shown(element, 85); + }); + } + + for (const property of ['hidden', 'textContent']) { + it(`quality ${property} setter reconciles a silent ownership loss after completing a newer nested report`, async t => { + const { element, client } = await subscribedWidget(t, true); + const restore = setter(element['quality'], property, () => { emit(client, 'quality', report(94)); client.state!.me.onStage = false; }); t.after(restore); + emit(client, 'quality', report(93)); hidden(element); + client.state!.me.onStage = true; emit(client, 'quality', report(95)); hidden(element); + await state(client); emit(client, 'quality', report(96)); shown(element, 96); + }); + } + + it('forwarded quality mutation of current authority is reconciled without another event', async t => { + const { element, client } = await subscribedWidget(t, true); + element.addEventListener('stage-quality', () => { client.state!.me.onStage = false; }, { once: true }); + emit(client, 'quality', report(86)); hidden(element); + client.state!.me.onStage = true; emit(client, 'quality', report(87)); hidden(element); + await state(client); emit(client, 'quality', report(88)); shown(element, 88); + }); + + it('equal-value quality updates preserve baseline removal of host-added same-text descendants', async t => { + const { element, client } = await subscribedWidget(t, true); emit(client, 'quality', report(89)); + const node = element['quality'] as unknown as Node; let value = node.textContent, writes = 0; + node.children.push(new Node()); + Object.defineProperty(node, 'textContent', { configurable: true, get: () => value, set: next => { value = next; writes++; node.children.splice(0); } }); + t.after(() => Object.defineProperty(node, 'textContent', { configurable: true, writable: true, value })); + emit(client, 'quality', report(89)); shown(element, 89); + assert.ok(writes > 0); assert.equal(node.children.length, 0); + }); + + for (const key of ['hidden', 'textContent']) { + it(`own-role loss fences close and transfer before quality ${key} setter reentry`, async t => { + const { element, client } = await subscribedWidget(t, true); + const value = roomState(true); value.me.role = 'controller'; value.controllerId = 'me'; + const target = { ...value.me, participantId: 'target', role: 'speaker' as const }; value.speakers = [value.me, target]; + await state(client, value); emit(client, 'quality', report()); + const row = element['stageManagementRows'].get('target')!; assert.ok(row); + const close = t.mock.method(client, 'closeRoom', async () => {}), transfer = t.mock.method(client, 'transferControl', async () => {}); + const confirm = t.mock.method(window, 'confirm', () => true); + let calls = 0; const restore = setter(element['quality'], key, () => { calls++; click(element['roomClose']); click(row.transfer); }); t.after(restore); + emit(client, 'role', { participantId: 'me', role: 'audience' }); hidden(element); + assert.equal(calls, 1); assert.equal(confirm.mock.callCount(), 0); assert.equal(close.mock.callCount(), 0); assert.equal(transfer.mock.callCount(), 0); + }); + + it(`manual leave fences close and transfer before quality ${key} setter reentry`, async t => { + const { element, client } = await subscribedWidget(t, true); + const value = roomState(true); value.me.role = 'controller'; value.controllerId = 'me'; + const target = { ...value.me, participantId: 'target', role: 'speaker' as const }; value.speakers = [value.me, target]; + await state(client, value); emit(client, 'quality', report()); + const row = element['stageManagementRows'].get('target')!; assert.ok(row); + const close = t.mock.method(client, 'closeRoom', async () => {}), transfer = t.mock.method(client, 'transferControl', async () => {}); + const confirm = t.mock.method(window, 'confirm', () => true); + let calls = 0; const restore = setter(element['quality'], key, () => { calls++; click(element['roomClose']); click(row.transfer); }); t.after(restore); + click(element['roomAction']); await settle(); hidden(element); + assert.equal(calls, 1); assert.equal(confirm.mock.callCount(), 0); assert.equal(close.mock.callCount(), 0); assert.equal(transfer.mock.callCount(), 0); + }); + } + + for (const transition of ['speaker leave', 'controller removal', 'controller leave'] as const) { + it(`actual Hub ${transition} retires before every pre-state callback and only new state plus new quality reopens`, async t => { + const config = testConfig(c => { c.rooms.presenceBroadcastMs = 0; }); + const h = harness(config); t.after(async () => { await h.hub.shutdown(); }); + const host = await h.create(), speaker = await h.join(host.roomId, host.code, 'Speaker'); + assert.equal(await h.req(host.s, { type: 'rtc:offer', payload: SENDRECV_OFFER }), 'ok'); + assert.equal(await h.req(speaker.s, { type: 'hand:raise' }), 'ok'); + assert.equal(await h.req(host.s, { type: 'stage:approve', targetId: speaker.id }), 'ok'); + assert.equal(await h.req(speaker.s, { type: 'rtc:offer', payload: SENDRECV_OFFER }), 'ok'); + const own = transition === 'controller leave' ? host : speaker; + const { element, client, socket } = await socketJoinedWidget(t, own.s.last('room:state')!); + h.transport.getStats = async () => ({ inbound: { packetsReceived: 988, packetsLost: 12 }, outbound: { fractionLost: 0.035 }, rttMs: 46 }); + const timer = h.timers.find(timer => !timer.cleared && timer.ms === config.rooms.qualityIntervalMs)!; + assert.ok(timer); timer.cleared = true; timer.fn(); await nextTurn(); + const wireReport = own.s.last('quality'); assert.ok(wireReport); + let accepted: unknown; + element.addEventListener('stage-quality', event => { accepted = (event as CustomEvent).detail; }, { once: true }); + socket.deliver(wireReport); shown(element); + const retained = client.state!, rejected = { participants: [{ participantId: own.id, rttMs: 88 }] }; + let callbacks = 0; + const observe = () => { + callbacks++; assert.equal(client.me!.onStage, true); hidden(element); + for (const detail of [accepted, rejected, { participants: [{ participantId: own.id, rttMs: 89 }] }]) { + emit(client, 'quality', detail); hidden(element); + } + emit(client, 'state', retained); emit(client, 'stagejoined', { participantId: own.id, role: 'speaker' }); + emit(client, 'role', { participantId: own.id, role: 'controller' }); + socket.deliver({ type: 'status', state: 'waiting' }); element['render'](); hidden(element); + }; + if (transition !== 'controller leave') element.addEventListener('stage-role', observe, { once: true }); + element.addEventListener('stage-stageleft', observe, { once: true }); + own.s.clear(); + assert.equal(await h.req(transition === 'controller removal' ? host.s : own.s, + transition === 'controller removal' ? { type: 'stage:remove', targetId: own.id } : { type: 'stage:leave' }), 'ok'); + const events = own.s.inbox.filter(message => ['role:update', 'stage:left', 'room:state'].includes(message.type)); + assert.deepEqual(events.map(message => message.type), transition === 'controller leave' + ? ['stage:left', 'room:state'] : ['role:update', 'stage:left', 'room:state']); + for (const message of events) socket.deliver(message); + hidden(element); assert.equal(callbacks, transition === 'controller leave' ? 1 : 2); + own.s.clear(); + if (own === speaker) assert.equal(await h.req(own.s, { type: 'hand:raise' }), 'ok'); + assert.equal(await h.req(host.s, { type: 'stage:approve', targetId: own.id }), 'ok'); + assert.equal(await h.req(own.s, { type: 'rtc:offer', payload: SENDRECV_OFFER }), 'ok'); + for (const message of own.s.inbox) socket.deliver(message); + assert.equal(client.me!.onStage, true); hidden(element); + emit(client, 'quality', accepted); emit(client, 'quality', rejected); hidden(element); + socket.deliver(wireReport); shown(element); + }); + } + + it('actual SDK reconnect consumes status-only and retained-state reports until a distinct resume state', async t => { + const { element, client, socket, sockets, hello, tick } = await socketJoinedWidget(t, roomState(true)); + const accepted = report(), rejected = report(90), statusOnly = report(91); + emit(client, 'quality', accepted); shown(element); const retained = client.state!; + socket.close(); hidden(element); assert.equal(client.state, retained); + await tick(500); const next = sockets[1]!, join = await hello(next); + emit(client, 'quality', rejected); hidden(element); + next.deliver({ type: 'status', state: 'live' }); emit(client, 'quality', statusOnly); hidden(element); + emit(client, 'state', retained); emit(client, 'quality', report(92)); hidden(element); + next.deliver({ type: 'room:state', ...roomState(true), resumeToken: 'resume-me' }); + next.deliver({ type: 'ok', requestId: join.requestId }); await settle(); hidden(element); + for (const detail of [accepted, rejected, statusOnly]) { emit(client, 'quality', detail); hidden(element); } + emit(client, 'quality', report(91)); shown(element, 91); + }); + + it('a receipt rejected before the first authoritative state stays rejected after admission', async t => { + const { element, sockets, hello } = socketJoiningWidget(t); await settle(); + const socket = sockets[0]!, join = await hello(socket), client = element.client!, rejected = report(90); + emit(client, 'quality', rejected); hidden(element); + socket.deliver({ type: 'room:state', ...roomState(true) }); socket.deliver({ type: 'ok', requestId: join.requestId }); await settle(); + emit(client, 'quality', rejected); hidden(element); emit(client, 'quality', report(90)); shown(element, 90); + }); + + for (const property of ['hidden', 'textContent'] as const) { + for (const loss of ['status', 'identity'] as const) { + it(`${property} setter reconciles ${loss} loss after a newer report and cannot reopen from restored getters`, async t => { + const { element, client } = await subscribedWidget(t, true); + const restore = setter(element['quality'], property, () => { + emit(client, 'quality', report(102)); + if (loss === 'status') client['currentStatus'] = 'reconnecting'; else client.state!.roomId = 'different'; + }); t.after(restore); + emit(client, 'quality', report(101)); hidden(element); + client['currentStatus'] = 'live'; client.state!.roomId = 'one'; + emit(client, 'quality', report(103)); hidden(element); + await state(client); emit(client, 'quality', report(104)); shown(element, 104); + }); + } + + it(`${property} setter preserves a newer admitted replacement entry`, async t => { + const { element, client } = await subscribedWidget(t, true); + const next = roomState(true, 'replacement'); next.me.participantId = 'replacement'; + const restore = setter(element['quality'], property, () => { + void state(client, next); + emit(client, 'quality', { participants: [{ participantId: 'replacement', rttMs: 106 }] }); + }); t.after(restore); + emit(client, 'quality', report(105)); shown(element, 106); + assert.equal(client.state!.roomId, 'replacement'); + }); + } + + for (const getter of ['state', 'status'] as const) { + for (const loss of ['onstage', 'identity', 'status'] as const) { + it(`${getter} getter tail reconciles silent ${loss} loss after a completed newer report`, async t => { + const { element, client } = await subscribedWidget(t, true); emit(client, 'quality', report(107)); + const read = Object.getOwnPropertyDescriptor(StageClient.prototype, getter)!.get!; + let armed = true; + Object.defineProperty(client, getter, { configurable: true, get() { + const result: unknown = read.call(client); + if (armed) { + armed = false; emit(client, 'quality', report(108)); + if (loss === 'onstage') client['snapshot']!.me.onStage = false; + else if (loss === 'identity') client['snapshot']!.roomId = 'different'; + else client['currentStatus'] = 'reconnecting'; + } + return result; + } }); + t.after(() => Reflect.deleteProperty(client, getter)); + element['syncQualityOwner'](); hidden(element); + client['snapshot']!.me.onStage = true; client['snapshot']!.roomId = 'one'; client['currentStatus'] = 'live'; + emit(client, 'quality', report(109)); hidden(element); + await state(client); emit(client, 'quality', report(110)); shown(element, 110); + }); + } + } + + it('consumes an outer receipt before a public authority getter admits a newer report', async t => { + const { element, client } = await subscribedWidget(t, true); emit(client, 'quality', report(111)); + const read = Object.getOwnPropertyDescriptor(StageClient.prototype, 'state')!.get!; + const old = report(112); let armed = true; + Object.defineProperty(client, 'state', { configurable: true, get() { + const result: unknown = read.call(client); + if (armed) { armed = false; emit(client, 'quality', report(113)); } + return result; + } }); + t.after(() => Reflect.deleteProperty(client, 'state')); + emit(client, 'quality', old); shown(element, 113); + emit(client, 'quality', old); shown(element, 113); + }); + + for (const action of ['close', 'transfer'] as const) { + for (const loss of ['status', 'identity'] as const) { + for (const property of ['hidden', 'textContent'] as const) { + it(`corrective quality ${property} paint cannot restore pending ${action} consent after silent ${loss} loss`, async t => { + const { element, client } = await subscribedWidget(t, true); + const value = roomState(true); value.me.role = 'controller'; value.controllerId = 'me'; + const target = { ...value.me, participantId: 'target', role: 'speaker' as const }; value.speakers = [value.me, target]; + await state(client, value); emit(client, 'quality', report()); + const row = element['stageManagementRows'].get('target')!; + const close = t.mock.method(client, 'closeRoom', async () => {}), transfer = t.mock.method(client, 'transferControl', async () => {}); + let stored = Reflect.get(element['quality'], property), armed = false, lost = false; + Object.defineProperty(element['quality'], property, { configurable: true, get: () => stored, set: next => { + stored = next; + if (armed) { + armed = false; emit(client, 'quality', report(115)); lost = true; + if (loss === 'status') client['currentStatus'] = 'reconnecting'; else client.state!.me.participantId = 'different'; + } else if (lost) { + lost = false; client['currentStatus'] = 'live'; client.state!.me.participantId = 'me'; + } + } }); + t.after(() => Object.defineProperty(element['quality'], property, { configurable: true, writable: true, value: stored })); + const confirm = t.mock.method(window, 'confirm', () => { + armed = true; emit(client, 'quality', report(114)); hidden(element); return true; + }); + click(action === 'close' ? element['roomClose'] : row.transfer); await settle(); + assert.equal(confirm.mock.callCount(), 1); assert.equal(close.mock.callCount(), 0); assert.equal(transfer.mock.callCount(), 0); + emit(client, 'quality', report(116)); hidden(element); + await state(client, value); emit(client, 'quality', report(117)); shown(element, 117); + }); + } + } + } + + it('keeps unconditional normal hidden-then-text writes even when metrics are unchanged', async t => { + const { element, client } = await subscribedWidget(t, true); emit(client, 'quality', report(118)); + const writes: string[] = [], values = { hidden: element['quality'].hidden, textContent: element['quality'].textContent }; + for (const property of ['hidden', 'textContent'] as const) { + let stored = values[property]; + Object.defineProperty(element['quality'], property, { configurable: true, get: () => stored, set: next => { stored = next; writes.push(property); } }); + t.after(() => Object.defineProperty(element['quality'], property, { configurable: true, writable: true, value: stored })); + } + emit(client, 'quality', report(118)); shown(element, 118); + assert.ok(writes.length >= 2); + for (let i = 0; i < writes.length; i += 2) assert.deepEqual(writes.slice(i, i + 2), ['hidden', 'textContent']); + }); + + for (const property of ['hidden', 'textContent'] as const) { + it(`${property} setter cannot repaint through nested terminal teardown or retire a later valid tenure`, async t => { + const { element, client } = await subscribedWidget(t, true); + const accepted = report(120); + const restore = setter(element['quality'], property, () => { void client['receive']({ type: 'room:closed', roomId: 'one' }); }); t.after(restore); + emit(client, 'quality', accepted); hidden(element); assert.equal(client.state, null); await settle(); + await state(client); emit(client, 'quality', accepted); hidden(element); + emit(client, 'quality', report(121)); shown(element, 121); + }); + + it(`${property} setter entry replacement stays clear and old terminal callbacks cannot touch its new report`, async t => { + const { element, client, socket, sockets, hello } = await socketJoinedWidget(t, roomState(true)); + const restore = setter(element['quality'], property, () => { element.setAttribute('room', 'replacement'); }); t.after(restore); + socket.deliver({ type: 'quality', ...report(122) }); hidden(element); await settle(); await settle(); + const next = sockets[1]!, join = await hello(next); + next.deliver({ type: 'room:state', ...roomState(true, 'replacement') }); next.deliver({ type: 'ok', requestId: join.requestId }); await settle(); + next.deliver({ type: 'quality', ...report(123) }); shown(element, 123); + emit(client, 'closed', { roomId: 'one' }); emit(client, 'kicked', { roomId: 'one' }); emit(client, 'status', 'disconnected'); + shown(element, 123); assert.notEqual(element.client, client); + }); + } + + for (const getter of ['state', 'status'] as const) { + it(`${getter} getter tail preserves a newer authoritative entry rather than retiring it with the old owner`, async t => { + const { element, client } = await subscribedWidget(t, true); emit(client, 'quality', report(124)); + const read = Object.getOwnPropertyDescriptor(StageClient.prototype, getter)!.get!; + let armed = true; + Object.defineProperty(client, getter, { configurable: true, get() { + const result: unknown = read.call(client); + if (armed) { + armed = false; const replacement = roomState(true, 'replacement'); replacement.me.participantId = 'replacement'; + void state(client, replacement); emit(client, 'quality', { participants: [{ participantId: 'replacement', rttMs: 125 }] }); + } + return result; + } }); + t.after(() => Reflect.deleteProperty(client, getter)); + element['syncQualityOwner'](); shown(element, 125); assert.equal(client.state!.roomId, 'replacement'); + }); + } + + it('a quality receipt rejected for an offstage seat cannot be replayed after promotion', async t => { + const { element, client } = await subscribedWidget(t, false), rejected = report(126); + emit(client, 'quality', rejected); hidden(element); await state(client); + emit(client, 'quality', rejected); hidden(element); emit(client, 'quality', report(126)); shown(element, 126); + }); + + for (const participants of [[], [{ participantId: 'other', rttMs: 1000 }]]) { + it(`replaying a previously accepted ${participants.length ? 'foreign-only' : 'empty'} full replacement set cannot clear a newer report`, async t => { + const { element, client } = await subscribedWidget(t, true), empty = { participants }; + emit(client, 'quality', report(127)); shown(element, 127); emit(client, 'quality', empty); hidden(element); + emit(client, 'quality', report(128)); shown(element, 128); emit(client, 'quality', empty); shown(element, 128); + }); + } + + for (const event of ['quality', 'state', 'status', 'micerror', 'micready', 'role', 'transferred', 'speaking', 'error'] as const) { + it(`preserves ordinary ${event} quality setter trace without additional paints`, async t => { + const { element, client } = await subscribedWidget(t, true); emit(client, 'quality', report(100)); + const writes: string[] = [], node = element['quality']; + for (const property of ['hidden', 'textContent'] as const) { + let value = Reflect.get(node, property); + Object.defineProperty(node, property, { configurable: true, get: () => value, set: next => { value = next; writes.push(property); } }); + t.after(() => Object.defineProperty(node, property, { configurable: true, writable: true, value })); + } + if (event === 'quality') emit(client, event, report(101)); + else if (event === 'state') await state(client); + else if (event === 'status') client['setStatus']('waiting'); + else if (event === 'micerror') emit(client, event, { name: 'NotFoundError', message: 'Unavailable' }); + else if (event === 'micready') emit(client, event, undefined); + else if (event === 'role') emit(client, event, { participantId: 'me', role: 'controller' }); + else if (event === 'transferred') emit(client, event, { fromId: 'host', toId: 'me' }); + else if (event === 'speaking') emit(client, event, { participantIds: ['me'] }); + else emit(client, event, new Error('Unrelated')); + const expected = event === 'quality' ? ['hidden', 'textContent'] + : event === 'state' || event === 'status' ? ['hidden', 'textContent', 'hidden', 'textContent'] + : event === 'micerror' ? ['textContent', 'hidden'] : []; + assert.deepEqual(writes, expected); + }); + } + + + for (const boundary of ['closed', 'status', 'role', 'stageleft'] as const) { + for (const getter of ['state', 'status'] as const) { + it(`${boundary} pure retirement precedes ${getter} getter reentry without preventing a later new tenure`, async t => { + const { element, client } = await subscribedWidget(t, true); emit(client, 'quality', report(129)); + const read = Object.getOwnPropertyDescriptor(StageClient.prototype, getter)!.get!, rejected = report(130); + let armed = true; + Object.defineProperty(client, getter, { configurable: true, get() { + const result: unknown = read.call(client); + if (armed) { armed = false; emit(client, 'quality', rejected); } + return result; + } }); + t.after(() => Reflect.deleteProperty(client, getter)); + emit(client, boundary, boundary === 'closed' ? { roomId: 'one' } : boundary === 'status' ? 'reconnecting' + : { participantId: 'me', ...(boundary === 'role' ? { role: 'audience' } : {}) }); + hidden(element); await state(client); emit(client, 'quality', rejected); hidden(element); + emit(client, 'quality', report(131)); shown(element, 131); + }); + } + } + + it('early pure terminal retirement preserves a genuine new state and report admitted by a getter callback', async t => { + const { element, client } = await subscribedWidget(t, true); emit(client, 'quality', report(132)); + const read = Object.getOwnPropertyDescriptor(StageClient.prototype, 'state')!.get!; let armed = true; + Object.defineProperty(client, 'state', { configurable: true, get() { + const result: unknown = read.call(client); + if (armed) { armed = false; void state(client); emit(client, 'quality', report(133)); } + return result; + } }); + t.after(() => Reflect.deleteProperty(client, 'state')); + emit(client, 'closed', { roomId: 'one' }); shown(element, 133); + }); + +}); From 49ff0b478decf728fe660afbf4b40f84d2b73ddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=A5=E8=AA=9E=E3=81=BB=E3=81=97=E3=81=9E=E3=82=89?= <92282870+YueyuHoshizora@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:28:48 +0800 Subject: [PATCH 07/38] feat(embed): display latest public speaking reports Show safely mapped public stage names and counts for all room roles. Keep empty, unmatched and waiting reports distinct, suppress departed IDs until authoritative state reconciliation, and isolate entry lifetimes. Validated with 5580 tests, four typechecks, build and independent review. --- PLAN.md | 3 + README.md | 2 + test/fixtures/embedApp.ts | 4 +- test/fixtures/embedSdkApp.ts | 4 +- test/webEmbedEntry.test.ts | 4 +- test/webEmbedMicrophone.test.ts | 4 +- test/webEmbedSpeaking.test.ts | 665 ++++++++++++++++++++++++++++++++ test/webEmbedWsPath.test.ts | 4 +- web/embed.html | 2 +- web/src/embed.ts | 179 ++++++++- 10 files changed, 856 insertions(+), 15 deletions(-) create mode 100644 test/webEmbedSpeaking.test.ts diff --git a/PLAN.md b/PLAN.md index 90435f6..8ccd7d3 100644 --- a/PLAN.md +++ b/PLAN.md @@ -838,3 +838,6 @@ N 路混音管線(decode→mix→encode)、不含自己的台上混音、lim 221. **Widget 本人品質摘要生命週期隔離(第200批,2026-10-16):** immutable199 真 StageElement/SDK→Hub 證明本人 role→stage:left→state 前序通知中舊品質仍可見/被重放復活,且 reconnect retained state 可藉 status-only 與拒絕事件重放恢復。限定 widget quality 的 pure admission retirement、report-only clear 與 rendering 分離;本人 audience role/stageleft、重連/斷線/終止、離房換席撤銷資格,只有 distinct 當前權威 state 再加新 quality 才顯示,拒絕與接納 receipt identity 均記錄。保持 micerror 只清報告、下一份新 quality 不必等 state;保持同 client 合法 terminal 後同 ID 重入、stage-preserving transfer、duplicate-own 最後完整項、數值格式/原始 stage-quality 轉送及本人 primitive 隔離。保留原品質段落 text replacement 與同文字宿主子節點修復,不作等值寫入效能優化。manual leave 先 pure retirement,等 current=null 與既有權限撤銷後才 paint;每個 DOM 邊界驗證當前 ownership,含 nested 新 report 後又靜默失權的 tail,較新合法 entry 不被舊 continuation 覆寫。兩種 close/transfer consent 在修正 DOM 前撤銷,不作一般 pending/SDK 生命週期重寫。五檔 element 源碼/測試+三份文件,無 SDK/server/protocol/media/API/依賴變更或額外 timer/poll/request。事件無 timestamp/peer epoch,object replay 防護不代表線路量測 freshness;原生瀏覽器/AT/硬體音訊未驗收。 + + +222. **最小 embed 公開發言回報摘要(第201批,2026-10-16):** immutable200 完整 embed/真 SDK→validator→Hub 證明無 speaking 呈現、所有角色皆收既有回報、初次/恢復 state→speaking→ACK 與 reconnect retained getter。新增單一普通 speaking-summary paragraph,公開台上名稱/人數依 roster 順序顯示,waiting/raw empty/nonempty unmatched 分開,不依 connected/mute/capture 推斷發言。只保留安全 public ID/name primitives、已匹配 IDs 與 raw-empty 標誌,未知/離台 IDs 不延後復活;新 state 可更新名稱/刪除但不重加舊活動。本人或他人 stageleft/audience role 純移除並暫時抑制 ID,distinct 當前有效權威 state 才核對解除,仍須之後新 speaking 才重加;不撤銷本人作為觀眾的閱讀資格或其他發言者。state/report 拒絕 receipt 也記錄,status/getter/舊 object 不重開;本摘要的 malformed envelope/roster/getter projection failure 回 waiting,不能冒充沉默;不承諾既有其他控制能處理畸形公開 snapshot,其原有例外邊界不擴修。projection 完成且 revision 有效才套用;entry/terminal/reconnect pure retirement 先於 DOM,close/transfer 權限先核對,每次 setter/getter tail 依目前 owner 調和,nested 新回報後靜默失權不留下舊資料或復活 consent,新合法 entry 優先。10 檔 embed HTML/源碼/新測試/五處 fixture 加兩文件,不改 SDK/server/widget/media/styles/protocol/config/依賴,不增 timer/poll/command/media。無 timestamp/epoch,最近收到非即時可聽或音訊恢復保證;native layout/keyboard/AT/硬體音訊未驗收。 diff --git a/README.md b/README.md index fa5ef42..f35537f 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,8 @@ SDK 的應用層 keepalive 會與控制請求共用送出間隔,避免合法 最小 embed 的台上使用者可查看「最近收到」的本人連線品質:上行/下行掉包與 RTT;RTT 不是端到端音訊延遲。資料只來自既有伺服器品質回報,不另輪詢或操作媒體。缺少或非法欄位顯示「—」,沒有本人回報便隱藏;不顯示其他人的數值,台下主控也不借用別人的回報。重連、本人下台、離房或替換進房流程會清除,重新取得權威台上快照後,仍須再收到品質回報才顯示。相同席位的麥克風失敗/準備完成、靜音或重試不抹除已收到的傳輸歷史,也不產生新數字。回報沒有量測時間或媒體世代,因此「最近收到」不代表最新量測、音訊已恢復或可聽;原生 layout、鍵盤/AT 與實體音訊未驗收。 +最小 embed 的所有房內角色也可查看「最近收到的發言回報」:依目前公開台上名單顯示名稱與人數,觀眾與台下主控同樣可看,斷線寬限內仍在台上的成員不因控制連線離線而被濾除。尚未收到報告、明確空回報,以及非空回報但沒有可對應台上成員會分開顯示;未知/離台 ID 不顯示或留待未來自動復活。下台/轉觀眾通知先移除該 ID,直到新的權威快照核對前,新回報也不借用舊台上名單;重新出現仍需後續新回報。重連、離房或替換進房流程清空,status 恢復與 retained speaking getter 不當新資料。名稱只取公開名單並安全顯示文字,不讀私有觀眾/代碼/token;相同名稱的不同成員仍各計一人。這是歷史偵測回報,不保證此刻發言、可聽播放、麥克風恢復或零音訊;不增加輪詢、計時器或媒體操作,原生瀏覽器/鍵盤/AT 與硬體音訊未驗收。 + 最小 embed 的目前主控另可明確「關閉房間」;台上與台下主控皆可用。按下先確認「確定關閉此房間並讓所有人離場?關閉後無法再加入此房間。」,同意後才呼叫既有 `closeRoom()`。這與「離開房間」不同:離開只斷線,房間仍可能保留主控寬限並交接;關閉會使所有人離場。重連或缺少確認能力時不能送出,缺少 SDK 方法時不提供替代關房方式。確認途中曾失權、換房/席位、重連或能力消失,即使隨即恢復也需重新確認;與控制權移交只互斥同時開啟的確認流程,不新增所有命令的全域鎖。取消、送出與 ACK 本身不清其他錯誤,不自動重試、離房或本機宣告成功;既有終止事件仍依原流程清理舊錯誤並顯示「房間已關閉」。終止事件先於 ACK 時請求可因斷線拒絕,但不應以過期拒絕覆蓋關閉結果;舊席位結果也不污染新加入。伺服器仍裁決權限,SDK 全域錯誤政策不變。驗證限受控完整頁面/實際 SDK/Hub,原生確認視窗、版面、鍵盤/焦點/AT 與硬體音訊未驗收。 最小 embed 的目前主控可展開「舉手申請」,依伺服器快照順序查看具名申請並明確「核准上台」或「婉拒」;經控制權移交或自動接任的台下主控也可使用。每個目標的兩個動作共用等待鎖,其他目標仍可操作;快照先移除申請不代表請求已完成,同 ID 再出現也不提前解鎖。重連保留上次名單、展開與等待但停用,失權、離房、終止或新加入會清理舊席位結果。離線申請保留在原順序,滿台/離線的核准錯誤仍由伺服器裁決,不因此封鎖婉拒;沒有樂觀移除、自動主持或額外媒體呼叫。舉手面板只顯示申請名單,不加入完整觀眾名單。SDK 全域錯誤與通知先於快照的既有界線保持,原生 details/鍵盤/焦點/AT 與實體音訊尚未驗收。 diff --git a/test/fixtures/embedApp.ts b/test/fixtures/embedApp.ts index e36abfc..33fa0f5 100644 --- a/test/fixtures/embedApp.ts +++ b/test/fixtures/embedApp.ts @@ -161,9 +161,9 @@ export class EmbedSdk { export const flush = async (): Promise => { for (let i = 0; i < 16; i++) await Promise.resolve(); }; export function embedApp(options: { libraryLoad?: Promise | ((url: string, attempt: number) => Promise); source?: string; search?: string; maxGainDb?: unknown; confirm?: ((message: string) => boolean) | null } = {}) { - const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); + const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'speaking-summary', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); const node = (id: string): EmbedNode => { const found = nodes.get(id); assert.ok(found, `unmodeled node: ${id}`); return found; }; - node('error-notice').hidden = true; node('hand-rejected').hidden = true; node('room-occupancy').hidden = true; node('own-connection-quality').hidden = true; + node('error-notice').hidden = true; node('hand-rejected').hidden = true; node('room-occupancy').hidden = true; node('own-connection-quality').hidden = true; node('speaking-summary').hidden = true; const cancel = node('cancel-join'); cancel.hidden = cancel.disabled = true; cancel.textContent = '取消連線'; const hand = node('hand'), mic = node('mic'), stage = node('stage'), micRetry = node('mic-retry'), leave = node('leave'), form = node('join'), status = node('status'); hand.disabled = leave.disabled = true; hand.textContent = '舉手發言'; status.textContent = '尚未加入'; diff --git a/test/fixtures/embedSdkApp.ts b/test/fixtures/embedSdkApp.ts index becc176..c004db3 100644 --- a/test/fixtures/embedSdkApp.ts +++ b/test/fixtures/embedSdkApp.ts @@ -107,9 +107,9 @@ export async function actualApp(t: TestContext, options: { deferCapture?: boolea const originals = new Map(Object.keys(globals).map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value }); t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); let now = 1000; t.mock.method(performance, 'now', () => now); - const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); + const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'speaking-summary', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); const node = (id: string): EmbedNode => { const found = nodes.get(id); assert.ok(found, id); return found; }; - node('error-notice').hidden = true; node('hand-rejected').hidden = true; node('room-occupancy').hidden = true; node('own-connection-quality').hidden = true; + node('error-notice').hidden = true; node('hand-rejected').hidden = true; node('room-occupancy').hidden = true; node('own-connection-quality').hidden = true; node('speaking-summary').hidden = true; const cancel = node('cancel-join'); cancel.hidden = cancel.disabled = true; cancel.textContent = '取消連線'; const form = node('join'), stage = node('stage'), status = node('status'), mic = node('mic'), micRetry = node('mic-retry'), leave = node('leave'); form.submitButton = new EmbedNode(); node('name').value = '測試主控'; diff --git a/test/webEmbedEntry.test.ts b/test/webEmbedEntry.test.ts index 791eedc..19dfe44 100644 --- a/test/webEmbedEntry.test.ts +++ b/test/webEmbedEntry.test.ts @@ -60,9 +60,9 @@ async function fixture(t: TestContext, options: { deferLibrary?: boolean } = {}) t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); let now = 1000; t.mock.method(performance, 'now', () => now); - const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); + const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'speaking-summary', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); const node = (id: string): EmbedNode => { const found = nodes.get(id); assert.ok(found); return found; }; - node('error-notice').hidden = true; node('hand-rejected').hidden = true; node('room-occupancy').hidden = true; node('own-connection-quality').hidden = true; + node('error-notice').hidden = true; node('hand-rejected').hidden = true; node('room-occupancy').hidden = true; node('own-connection-quality').hidden = true; node('speaking-summary').hidden = true; const cancel = node('cancel-join'); cancel.hidden = cancel.disabled = true; cancel.textContent = '取消連線'; const form = node('join'), submit = new EmbedNode(), status = node('status'), leave = node('leave'), hand = node('hand'); form.submitButton = submit; node('name').value = ' 測試聽眾 '; diff --git a/test/webEmbedMicrophone.test.ts b/test/webEmbedMicrophone.test.ts index 13f21b1..ca3fa74 100644 --- a/test/webEmbedMicrophone.test.ts +++ b/test/webEmbedMicrophone.test.ts @@ -228,9 +228,9 @@ async function actualApp(t: TestContext) { for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value }); MicSocket.instances = []; t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); let now = 1000; t.mock.method(performance, 'now', () => now); - const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); + const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'speaking-summary', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); const node = (id: string): EmbedNode => { const found = nodes.get(id); assert.ok(found); return found; }; - node('error-notice').hidden = true; node('hand-rejected').hidden = true; node('room-occupancy').hidden = true; node('own-connection-quality').hidden = true; + node('error-notice').hidden = true; node('hand-rejected').hidden = true; node('room-occupancy').hidden = true; node('own-connection-quality').hidden = true; node('speaking-summary').hidden = true; const cancel = node('cancel-join'); cancel.hidden = cancel.disabled = true; cancel.textContent = '取消連線'; const form = node('join'), mic = node('mic'), status = node('status'), leave = node('leave'); form.submitButton = new EmbedNode(); node('name').value = '測試聽眾'; mic.setAttribute('aria-label', '麥克風靜音'); diff --git a/test/webEmbedSpeaking.test.ts b/test/webEmbedSpeaking.test.ts new file mode 100644 index 0000000..30bdb0d --- /dev/null +++ b/test/webEmbedSpeaking.test.ts @@ -0,0 +1,665 @@ +/// +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { it, type TestContext } from 'node:test'; +import { StageClient } from '../packages/client/src/index.ts'; +import type { ClientMessage, ServerMessage, RoomStatePayload, ParticipantView } from '../shared/protocol.ts'; +import type { Session } from '../src/ws/hub.ts'; +import { parseClientMessage } from '../src/ws/validate.ts'; +import { embedApp, EmbedNode, EmbedSdk, flush, Deferred } from './fixtures/embedApp.ts'; +import { actualApp, actualState, StageSocket, StagePeer, StageContext } from './fixtures/embedSdkApp.ts'; +import { harness, sine, testConfig, SENDRECV_OFFER } from './helpers.ts'; + +const WAITING = '發言偵測:等待伺服器回報。'; +const SILENT = '最近收到的發言回報:未偵測到發言。'; +const UNMATCHED = '最近收到的發言回報:沒有可對應的台上成員。'; +function summary(...names: string[]): string { return `最近收到的發言回報:${names.join('、')}(${names.length} 人)。`; } +function person(id: string, name = id === 'controller' ? '甲' : '乙', role: ParticipantView['role'] = 'speaker', onStage = role !== 'audience'): ParticipantView { + return { participantId: id, name, role, onStage, handRaised: false, muted: false, + forceMuted: false, connected: true, gainDb: 0, joinedAt: 1 }; +} +function state(me: Partial = {}, speakers?: ParticipantView[]): RoomStatePayload { + const own = { ...person('controller', '甲', 'controller'), ...me }; + return { roomId: 'same-room', name: 'Synthetic room', controllerId: 'controller', me: own, + speakers: speakers ?? [...(own.onStage ? [own] : []), person('target')], hands: [], audienceCount: 0, + limits: { maxSpeakers: 8, maxAudience: 300 }, status: 'live', codeRequired: true, + code: 'PRIVATE-CODE', resumeToken: 'PRIVATE-TOKEN' }; +} +function audience(speakers = [person('controller', '甲', 'controller'), person('target')]): RoomStatePayload { + return state({ participantId: 'reader', name: 'Private reader', role: 'audience', onStage: false }, speakers); +} +function publish(sdk: EmbedSdk, snapshot = state()): void { sdk.state = snapshot; sdk.me = snapshot.me; sdk.emit('state', snapshot); } +function report(...participantIds: string[]) { return { participantIds }; } +function shown(node: EmbedNode, text = summary('甲', '乙')): void { assert.equal(node.hidden, false); assert.equal(node.textContent, text); } +function empty(node: EmbedNode): void { assert.equal(node.hidden, true); assert.equal(node.textContent, ''); } +function instrument(node: EmbedNode, hook: (property: 'textContent' | 'hidden') => void = () => {}) { + const writes = { textContent: 0, hidden: 0 }; + for (const property of ['textContent', 'hidden'] as const) { + let value = node[property]; + Object.defineProperty(node, property, { configurable: true, get: () => value, set(next: typeof value) { + assert.notEqual(next, value, `equal ${property} must not be written`); + value = next; writes[property]++; hook(property); + } }); + } + return writes; +} +async function setup(t: TestContext, options: Parameters[0] = {}, snapshot = state()) { + const app = embedApp(options); t.after(() => app.cleanup()); const sdk = await app.join(); + publish(sdk, snapshot); return { app, sdk, node: app.node('speaking-summary') }; +} +function transfer(app: ReturnType): EmbedNode { + const button = app.node('stage-management-list').children[0]?.children[1]?.children[2]; assert.ok(button); return button; +} + +it('embed speaking authors one initially empty hidden ordinary paragraph beside occupancy and quality', async t => { + const html = readFileSync(new URL('../web/embed.html', import.meta.url), 'utf8'); + assert.equal(html.match(/id="speaking-summary"/g)?.length, 1); + assert.match(html, / -

尚未加入

關閉房間會讓所有人離場,且無法再加入此房間。離開房間只會讓你離場,房間可能保留並由其他人接任主控。

+

尚未加入

關閉房間會讓所有人離場,且無法再加入此房間。離開房間只會讓你離場,房間可能保留並由其他人接任主控。

diff --git a/web/src/embed.ts b/web/src/embed.ts index 0fe64ca..525bcd3 100644 --- a/web/src/embed.ts +++ b/web/src/embed.ts @@ -7,6 +7,7 @@ function input(id: string): HTMLInputElement { const status = document.querySelector('#status')!; const roomOccupancy = document.querySelector('#room-occupancy')!; const ownConnectionQuality = document.querySelector('#own-connection-quality')!; +const speakingSummary = document.querySelector('#speaking-summary')!; const errorNotice = document.querySelector('#error-notice')!; const lastError = document.querySelector('#last-error')!; const handRejectionNotice = document.querySelector('#hand-rejected')!; @@ -251,6 +252,141 @@ function renderQuality(): void { if (ownConnectionQuality.textContent !== text) { ownConnectionQuality.textContent = text; if (!valid()) return; } if (ownConnectionQuality.hidden) { ownConnectionQuality.hidden = false; if (!valid()) return; } } +interface SpeakingSeat { + client: Client; generation: number; roomId: string; participantId: string; +} +interface SpeakingReport { ids: Set; rawEmpty: boolean } +let speakingSeat: SpeakingSeat | undefined; +let speakingRoster = new Map(); +let speakingSuppressed = new Set(); +let speakingReport: SpeakingReport | undefined; +let speakingRevision = 0; +function retireSpeaking(): void { + speakingSeat = undefined; speakingRoster = new Map(); speakingSuppressed = new Set(); + speakingReport = undefined; speakingRevision++; +} +function speakingIdentity(current: Client, generation: number): SpeakingSeat | undefined { + const state = current.state, roomId = state?.roomId, participantId = state?.me?.participantId; + if (current !== client || current !== controlsClient || generation !== entryGeneration + || occupancyRetiredGeneration === generation || (current.status !== 'waiting' && current.status !== 'live') + || typeof roomId !== 'string' || !roomId.trim() || typeof participantId !== 'string' || !participantId.trim()) return; + return { client: current, generation, roomId, participantId }; +} +function reconcileSpeaking(): void { + if (client === controlsClient && client?.status === 'disconnected') occupancyRetiredGeneration = entryGeneration; + const seat = speakingSeat; + if (!seat) return; + let identity: SpeakingSeat | undefined; + try { identity = speakingIdentity(seat.client, seat.generation); } catch { /* Invalid public identity retires its reader. */ } + if (seat === speakingSeat && (!identity || identity.roomId !== seat.roomId || identity.participantId !== seat.participantId)) retireSpeaking(); +} +// Invalidate both consents before a summary setter can restore an observed loss of authority. +// Ordinary reports need no control rendering, and unrelated target loss must leave Close intact. +function reconcileSpeakingConsents(): void { + const close = pendingRoomClose, closeOwner = roomCloseOwner; + if (close && !close.invalidated) { + try { + if (!closeOwner || close.revision !== closeOwner.revision || !eligibleRoomClose(closeOwner)) close.invalidated = true; + } catch { close.invalidated = true; } + } + const transfer = pendingTransfer, transferOwner = stageManagementOwner; + if (transfer && !transfer.invalidated) { + try { + if (!transferOwner || transfer.revision !== transferOwner.revision + || !eligibleTransfer(transferOwner, transfer.id, transfer.row)) transfer.invalidated = true; + } catch { transfer.invalidated = true; } + } +} +function projectSpeakingRoster(state: StageEventMap['state']): Map | undefined { + const speakers: unknown = state.speakers; + if (!Array.isArray(speakers) || !decodeRoomName) return; + const roster = new Map(); + for (const person of speakers) { + if (!person || typeof person !== 'object' || Array.isArray(person)) continue; + const id: unknown = person.participantId; + if (typeof id !== 'string' || !id.trim() || roster.has(id) || person.onStage !== true) continue; + const role: unknown = person.role; + if (role !== 'speaker' && role !== 'controller') continue; + const name: unknown = person.name; + if (typeof name === 'string') roster.set(id, decodeRoomName(name)); + } + return roster; +} +function receiveSpeakingState(current: Client, generation: number, detail: StageEventMap['state'], unseen: boolean): boolean { + reconcileSpeaking(); + if (!unseen || detail !== current.state) return true; + const revision = speakingRevision, seat = speakingSeat; + let identity: SpeakingSeat | undefined, roster: Map | undefined; + try { + identity = speakingIdentity(current, generation); + if (identity) roster = projectSpeakingRoster(detail); + } catch { /* Copy the complete projection before replacing history or releasing delta suppression. */ } + let live: SpeakingSeat | undefined, matches = false; + try { + live = speakingIdentity(current, generation); + matches = Boolean(identity && live && detail === current.state + && live.roomId === identity.roomId && live.participantId === identity.participantId); + } catch { /* An unreadable identity cannot admit a reader. */ } + reconcileSpeaking(); reconcileSpeakingConsents(); + if (revision !== speakingRevision || seat !== speakingSeat || current !== client || generation !== entryGeneration + || !matches || !identity) return false; + speakingSeat = seat ?? identity; + speakingRoster = roster ?? new Map(); + if (roster) { + speakingSuppressed = new Set(); + if (speakingReport) speakingReport = { rawEmpty: speakingReport.rawEmpty, + ids: new Set([...speakingReport.ids].filter(id => roster.has(id))) }; + } else speakingReport = undefined; + speakingRevision++; + return roster !== undefined; +} +function projectSpeakingReport(detail: unknown): SpeakingReport | undefined { + if (!detail || typeof detail !== 'object' || Array.isArray(detail)) return; + const participantIds: unknown = Reflect.get(detail, 'participantIds'); + if (!Array.isArray(participantIds)) return; + const ids = new Set(), rawEmpty = participantIds.length === 0; + for (const id of participantIds) { + if (typeof id !== 'string') return; + if (speakingRoster.has(id) && !speakingSuppressed.has(id)) ids.add(id); + } + return { ids, rawEmpty }; +} +function pruneSpeaking(current: Client, generation: number, id: string): boolean { + reconcileSpeaking(); + if (!speakingSeat || speakingSeat.client !== current || speakingSeat.generation !== generation + || typeof id !== 'string' || !id.trim() || !speakingRoster.has(id)) return false; + speakingSuppressed.add(id); + speakingReport?.ids.delete(id); + speakingRevision++; + return true; +} +function renderSpeaking(): void { + reconcileSpeaking(); reconcileSpeakingConsents(); + const revision = speakingRevision, seat = speakingSeat, report = speakingReport; + const current = client, controls = controlsClient, generation = entryGeneration; + const valid = (): boolean => { + // Reconcile the winner even if a nested receipt already superseded this render. + reconcileSpeaking(); reconcileSpeakingConsents(); + if (revision !== speakingRevision || seat !== speakingSeat || report !== speakingReport + || current !== client || controls !== controlsClient || generation !== entryGeneration) { + renderSpeaking(); return false; + } + return true; + }; + if (!valid()) return; + if (!seat) { + if (!speakingSummary.hidden) { speakingSummary.hidden = true; if (!valid()) return; } + if (speakingSummary.textContent !== '') { speakingSummary.textContent = ''; if (!valid()) return; } + return; + } + const names = report ? [...speakingRoster].filter(([id]) => report.ids.has(id)).map(([, name]) => name) : []; + const text = !report ? '發言偵測:等待伺服器回報。' + : report.rawEmpty ? '最近收到的發言回報:未偵測到發言。' + : names.length ? `最近收到的發言回報:${names.join('、')}(${names.length} 人)。` + : '最近收到的發言回報:沒有可對應的台上成員。'; + if (speakingSummary.textContent !== text) { speakingSummary.textContent = text; if (!valid()) return; } + if (speakingSummary.hidden) { speakingSummary.hidden = false; if (!valid()) return; } +} let feedbackRevision = 0; let handRejectionOwner: { client: Client; roomId: string; participantId: string; generation: number } | undefined; function retireHandRejection(): void { @@ -286,7 +422,7 @@ function retireControls(): void { } /** Retire initial-join feedback before user or terminal callbacks can start another entry. */ function retireEntry(): number { - const generation = ++entryGeneration; retireOccupancy(); retireQuality(); + const generation = ++entryGeneration; retireOccupancy(); retireQuality(); retireSpeaking(); hideCancel(); submit.disabled = false; submit.setAttribute('aria-busy', 'false'); return generation; @@ -1012,7 +1148,7 @@ function signalingUrl(host: URL, value: string): URL { join.onsubmit = (event) => { event.preventDefault(); const previous = client, generation = ++entryGeneration; - retireOccupancy(); retireQuality(); + retireOccupancy(); retireQuality(); retireSpeaking(); clearError(); const hostValue = input('host').value, pathValue = input('ws-path').value; const joinOptions = { roomId: input('room').value.trim(), code: input('code').value.trim() || undefined, name: input('name').value.trim() }; @@ -1021,6 +1157,7 @@ join.onsubmit = (event) => { let progress = true, receivedState = false, cancelledEntry = false; const occupancyReceipts = new WeakSet(); const qualityReceipts = new WeakSet(); + const speakingReceipts = new WeakSet(); let entryClient: Client | undefined; const showProgress = (message: string): void => { if (generation === entryGeneration && feedback === feedbackRevision && progress) status.textContent = message; @@ -1036,6 +1173,7 @@ join.onsubmit = (event) => { syncMic(); syncStage(); syncMicRetry(); renderOccupancy(); if (cancelled === entryGeneration) renderQuality(); + if (cancelled === entryGeneration) renderSpeaking(); if (cancelled !== entryGeneration) return; try { const stopping = entryClient?.disconnect(); @@ -1053,6 +1191,7 @@ join.onsubmit = (event) => { hand.disabled = leave.disabled = true; renderOccupancy(); if (generation === entryGeneration) renderQuality(); + if (generation === entryGeneration) renderSpeaking(); if (generation !== entryGeneration) return; await previous?.disconnect(); if (generation !== entryGeneration) return; @@ -1076,6 +1215,10 @@ join.onsubmit = (event) => { if (detail && typeof detail === 'object') occupancyReceipts.add(detail); if (client !== current || cancelledEntry || generation !== entryGeneration || occupancyRetiredGeneration === generation || controlsClient !== current) return; // SDK installs state before status; only its actual, previously unseen state receipt is fresh. + const speakingStateValid = receiveSpeakingState(current, generation, detail, Boolean(unseen)); + if (!speakingStateValid) renderSpeaking(); + // Public projection getters may have replaced this entry before legacy reconciliation. + if (client !== current || controlsClient !== current || generation !== entryGeneration || cancelledEntry) return; reconcileQuality(); if (detail === current.state && unseen && eligibleQualitySeat(current) && !qualitySeat) { qualitySeat = { client: current, generation, roomId: detail.roomId, participantId: detail.me.participantId }; @@ -1103,6 +1246,7 @@ join.onsubmit = (event) => { if (renderingEntry === entryGeneration && client === current && controlsClient === current) { leave.disabled = false; renderOccupancy(); if (renderingEntry === entryGeneration && client === current && controlsClient === current) renderQuality(); + if (renderingEntry === entryGeneration && client === current && controlsClient === current) renderSpeaking(); } }); client.on('quality', ({ detail }) => { @@ -1120,6 +1264,21 @@ join.onsubmit = (event) => { } renderQuality(); }); + client.on('speaking', ({ detail }) => { + const unseen = !detail || typeof detail !== 'object' || !speakingReceipts.has(detail); + if (detail && typeof detail === 'object') speakingReceipts.add(detail); + if (client !== current || cancelledEntry || generation !== entryGeneration || controlsClient !== current) return; + reconcileSpeaking(); + const seat = speakingSeat, revision = speakingRevision; + if (unseen && seat) { + let projection: SpeakingReport | undefined; + try { projection = projectSpeakingReport(detail); } catch { /* Malformed reports mean unknown activity, not silence. */ } + reconcileSpeaking(); reconcileSpeakingConsents(); + if (revision !== speakingRevision || seat !== speakingSeat || current !== client || generation !== entryGeneration) { renderSpeaking(); return; } + speakingReport = projection; speakingRevision++; + } + renderSpeaking(); + }); const retireOwnHandRejection = (participantId: string): void => { if (client === current && controlsClient === current && !cancelledEntry && generation === entryGeneration && participantId === current.me?.participantId) retireHandRejection(); @@ -1131,6 +1290,7 @@ join.onsubmit = (event) => { const ownsTransferEvents = (): boolean => client === current && controlsClient === current && !cancelledEntry && generation === entryGeneration && stageManagementOwner?.client === current; client.on('role', ({ detail }) => { + const speakingPruned = detail.role === 'audience' && pruneSpeaking(current, generation, detail.participantId); const closeFenced = detail.role !== 'controller' && fenceRoomClose(current, generation, detail.participantId); const transferFenced = ownsTransferEvents() && ((detail.participantId === stageManagementOwner!.participantId && detail.role !== 'controller') || (stageManagementRows.has(detail.participantId) && detail.role !== 'speaker')); @@ -1142,6 +1302,7 @@ join.onsubmit = (event) => { if (closeFenced) syncRoomClose(); if (transferFenced) syncStageManagement(); if (ownDeparture && client === current && generation === entryGeneration) renderQuality(); + if (speakingPruned && client === current && generation === entryGeneration) renderSpeaking(); }); client.on('transferred', ({ detail }) => { const closeFenced = fenceRoomClose(current, generation, detail.fromId); @@ -1151,6 +1312,7 @@ join.onsubmit = (event) => { if (transferFenced) syncStageManagement(); }); client.on('stageleft', ({ detail }) => { + const speakingPruned = pruneSpeaking(current, generation, detail.participantId); const ownDeparture = client === current && controlsClient === current && !cancelledEntry && generation === entryGeneration && detail.participantId === current.me?.participantId; if (ownDeparture) retireQuality(); @@ -1158,6 +1320,7 @@ join.onsubmit = (event) => { if (transferFenced) fenceTransfer(false); if (transferFenced) syncStageManagement(); if (ownDeparture && client === current && generation === entryGeneration) renderQuality(); + if (speakingPruned && client === current && generation === entryGeneration) renderSpeaking(); }); client.on('handrejected', ({ detail }) => { // No per-raise identity is provided; feedback belongs only to the current eligible seat. @@ -1183,8 +1346,12 @@ join.onsubmit = (event) => { }); client.on('status', ({ detail }) => { if (client !== current || cancelledEntry || generation !== entryGeneration) return; - if (detail === 'disconnected') { occupancyRetiredGeneration = generation; retireOccupancy(); retireQuality(); } - else { reconcileOccupancy(); if (detail === 'reconnecting') retireQuality(); else reconcileQuality(); } + if (detail === 'disconnected') { occupancyRetiredGeneration = generation; retireOccupancy(); retireQuality(); retireSpeaking(); } + else { + reconcileOccupancy(); + if (detail === 'reconnecting') { retireQuality(); retireSpeaking(); } + else { reconcileQuality(); reconcileSpeaking(); } + } const renderingEntry = entryGeneration; syncRoomClose(); if (renderingEntry !== entryGeneration || client !== current) return; @@ -1194,6 +1361,7 @@ join.onsubmit = (event) => { syncHand(); syncMic(); syncStage(); syncMicRetry(); syncRename(); syncRoomName(); syncHandQueue(); syncStageManagement(); syncRoomClose(); syncMicrophone(); syncAudioRetry(); syncOutput(); if (renderingEntry === entryGeneration && client === current) renderOccupancy(); if (renderingEntry === entryGeneration && client === current) renderQuality(); + if (renderingEntry === entryGeneration && client === current) renderSpeaking(); }); client.on('error', ({ detail }) => { if (client !== current || cancelledEntry || generation !== entryGeneration) return; progress = false; report(detail); }); client.on('audioblocked', () => { if (cancelledEntry) return; progress = false; status.textContent = '請點擊啟用音訊。'; }); @@ -1203,6 +1371,7 @@ join.onsubmit = (event) => { clearError(); retirePlayback(); retireControls(); syncHand(); syncMic(); syncStage(); syncMicRetry(); syncRename(); syncRoomName(); renderOccupancy(); if (retired === entryGeneration && client === current) renderQuality(); + if (retired === entryGeneration && client === current) renderSpeaking(); if (retired !== entryGeneration || client !== current) return; status.textContent = detail.reason === 'shutdown' ? '伺服器維護或重新啟動,房間已關閉。' : '房間已關閉。'; hand.disabled = leave.disabled = true; }); @@ -1212,6 +1381,7 @@ join.onsubmit = (event) => { clearError(); retirePlayback(); retireControls(); syncHand(); syncMic(); syncStage(); syncMicRetry(); syncRename(); syncRoomName(); renderOccupancy(); if (retired === entryGeneration && client === current) renderQuality(); + if (retired === entryGeneration && client === current) renderSpeaking(); if (retired !== entryGeneration || client !== current) return; status.textContent = '你已被主控移出房間。'; hand.disabled = leave.disabled = true; }); @@ -1426,6 +1596,7 @@ leave.onclick = () => { retireControls(); hand.setAttribute('aria-busy', 'false'); syncHand(); syncMic(); syncStage(); syncMicRetry(); renderOccupancy(); if (generation === entryGeneration && client === current) renderQuality(); + if (generation === entryGeneration && client === current) renderSpeaking(); if (generation !== entryGeneration || client !== current) return; void current?.disconnect().then(() => { if (generation !== entryGeneration || client !== current) return; From 338ec91cf4180092a548b7d26d6908f56f574f30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=A5=E8=AA=9E=E3=81=BB=E3=81=97=E3=81=9E=E3=82=89?= <92282870+YueyuHoshizora@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:54:14 +0800 Subject: [PATCH 08/38] fix(widget): isolate speaking receipts across stage lifecycles Retire stale reader admission, suppress departed IDs until authoritative state reconciliation and preserve live roster rendering and same-client rejoins. Fence existing command consents before reentrant summary paint. Validated with 5664 tests, four typechecks, build and independent review. --- PLAN.md | 3 + README.md | 2 +- packages/client/README.md | 2 +- packages/client/src/element.ts | 220 +++++++++--- packages/client/test/element.test.ts | 516 +++++++++++++++++++++++++++ 5 files changed, 686 insertions(+), 57 deletions(-) diff --git a/PLAN.md b/PLAN.md index 8ccd7d3..34ab8ac 100644 --- a/PLAN.md +++ b/PLAN.md @@ -841,3 +841,6 @@ N 路混音管線(decode→mix→encode)、不含自己的台上混音、lim 222. **最小 embed 公開發言回報摘要(第201批,2026-10-16):** immutable200 完整 embed/真 SDK→validator→Hub 證明無 speaking 呈現、所有角色皆收既有回報、初次/恢復 state→speaking→ACK 與 reconnect retained getter。新增單一普通 speaking-summary paragraph,公開台上名稱/人數依 roster 順序顯示,waiting/raw empty/nonempty unmatched 分開,不依 connected/mute/capture 推斷發言。只保留安全 public ID/name primitives、已匹配 IDs 與 raw-empty 標誌,未知/離台 IDs 不延後復活;新 state 可更新名稱/刪除但不重加舊活動。本人或他人 stageleft/audience role 純移除並暫時抑制 ID,distinct 當前有效權威 state 才核對解除,仍須之後新 speaking 才重加;不撤銷本人作為觀眾的閱讀資格或其他發言者。state/report 拒絕 receipt 也記錄,status/getter/舊 object 不重開;本摘要的 malformed envelope/roster/getter projection failure 回 waiting,不能冒充沉默;不承諾既有其他控制能處理畸形公開 snapshot,其原有例外邊界不擴修。projection 完成且 revision 有效才套用;entry/terminal/reconnect pure retirement 先於 DOM,close/transfer 權限先核對,每次 setter/getter tail 依目前 owner 調和,nested 新回報後靜默失權不留下舊資料或復活 consent,新合法 entry 優先。10 檔 embed HTML/源碼/新測試/五處 fixture 加兩文件,不改 SDK/server/widget/media/styles/protocol/config/依賴,不增 timer/poll/command/media。無 timestamp/epoch,最近收到非即時可聽或音訊恢復保證;native layout/keyboard/AT/硬體音訊未驗收。 + + +223. **Widget 發言摘要生命週期隔離(第202批,2026-10-16):** immutable201 真按鈕→SDK→validator→Hub 的 active-media 路徑 role→speaking→stageleft→state→ACK 在 role callback 仍顯示剛下台本人;另分開 isolated delta/synthetic 新回報與重放證明 stale roster 再接納、prestate/reconnect rejected receipt 與 status-only 缺口,不混淆證據來源。限定 speaking owner/receipt/ID suppression,重連/終止/換席先 pure retire,distinct 當前權威 state 再新 speaking 才恢復;拒絕 receipt 也記錄。保留 SDK 新身分 state-before-status 時非接納的 WAITING placeholder,原測試不弱化;合法同 client terminal 後同 ID 重入可恢復。本人/他人 audience-role/stageleft 純移除並抑制 ID,新的有效權威 state 才核對解除,舊集合不自動重加;所有角色 readership 與其餘 ID 保持。保留 admitted owner 原 live roster 名稱映射/永久刪除、原文案/empty/malformed sparse array 規則、detail 原樣轉送、connected grace、micerror 不清 speaking、原 text replacement/同文字子節點修復與普通 setter 次數,不套用 embed 不同顯示政策。已知失效在可重入 getter 前純處理,Close/Transfer 權限早於 DOM;獨立重現 terminal helper getter→nested speaking paint 可先觸發舊控制,故限既有兩 authority helper 的 known-delta 純階段提前,使用已保存身分、原 paired sites 後再 reconcile,保留新 state/new consent 的較新 tenure,不新增 action policy。nested 新報告後靜默失權的 tail 依目前 owner 調和,新合法 tenure 優先。leave 先 pure retire、current=null 與既有權限撤銷後 paint,不作廣泛生命週期重寫。五檔 element 源碼/測試+三文件,無 SDK/server/media/protocol/API/依賴變更或新增 timer/poll/command。歷史 VAD 無 timestamp/epoch,非即時可聽/沉默/恢復證明;原生 browser/AT/實體音訊未驗收。 diff --git a/README.md b/README.md index f35537f..d055fa2 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ Web Component 本人名稱與房名編輯器的 hidden/disabled/aria-busy Web Component 也顯示最近快照的房間席位:主控以外的總人數/房間上限、其中台上人數/台位上限、觀眾人數,以及另計的主控台上/台下席位。房間上限包含一般觀眾與講者,台位上限不含主控,不能直接把觀眾數或台上總數各自除以上限。舉手不增加人數,暫時離線但仍保留的席位照計;數字不是在線/收聽人數或立即加入保證。重連顯示上次快照,須新權威快照才更新;離房、斷線或終止時清空。只讀既有公開狀態,不顯示個人名單/識別碼/代碼,不新增請求、輪詢或音訊操作;原生版面與輔助科技未驗收。 -Web Component 另以普通文字顯示最近收到的發言者回報,按目前台上順序列出名稱與人數;尚未收到、明確空集合與無法對應台上成員分開呈現。改名或移出台上隨快照更新,控制連線寬限中仍在台上的成員不被排除;觀眾與未知 ID 不會洩露。重連、離房或換席清除舊集合,等待新回報。這是伺服器活動偵測,沒有時間戳,並非即時或可聽音訊保證,空集合也不證明沒有音訊;不增加輪詢、媒體工作或 live region,原生輔助科技與實際音訊尚未驗收。 +Web Component 另以普通文字顯示最近收到的發言者回報,按目前台上順序列出名稱與人數;尚未收到、明確空集合與無法對應台上成員分開呈現。改名或移出台上隨快照更新,控制連線寬限中仍在台上的成員不被排除;觀眾與未知 ID 不會洩露。重連、離房或換席清除舊集合,須新的目前權威 state 與後續新回報才恢復;status-only 與已接收/拒絕事件物件重放不恢復。本人或他人下台/轉觀眾通知先移除該 ID,新的權威快照核對前,即使新回報也不借用舊台上名單;重新核對後仍須下一份新回報才重加。本人下台只移除本人活動,不撤銷作為觀眾閱讀其他人回報的資格;合法同 client 斷線重入仍支援。這是伺服器活動偵測,沒有時間戳,並非即時或可聽音訊保證,空集合也不證明沒有音訊;不增加輪詢、媒體工作或 live region,原生輔助科技與實際音訊尚未驗收。 Web Component 的台上者收到伺服器本人品質回報後,可看到最近收到的上/下行掉包率及 RTT;只顯示本人,觀眾/台下主控不顯示。缺失值為「—」,不代表 0;RTT 並非端到端音訊延遲。重連、本人下台/轉為觀眾、換席或終止會清除舊摘要;須先收到目前 client 的新權威台上 state,再收到新的本人報告才重新顯示。單獨 status 恢復、角色通知或重放已接收/拒絕的事件物件不能還原。相同 client 合法離房重入仍可由新 state 與新報告恢復。麥克風錯誤僅清除報告,保留合格台位的接收資格,因此不必另等 state,後續新本人報告可顯示;`micready` 不會還原。報告沒有時間戳或 peer 世代,因此數字不是即時健康或麥克風恢復證明;不增加輪詢或音訊操作。 diff --git a/packages/client/README.md b/packages/client/README.md index 8663917..caff8f1 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -215,7 +215,7 @@ WebSocket 建構失敗、hello 逾時或尚未進房的連線關閉,也會釋 草稿不是目前實際擷取裝置:公開 client 可另改設定,且離台/回台等生命週期可使 setInputDevice 正常完成卻未採用該選擇。完成只恢復按鈕,不顯示「已套用」或硬體成功;不新增 SDK API、預覽、儲存、屬性或自動重試。可用 ::part(microphone) 設定外觀。驗證限公開 SDK 與受控 DOM/socket/media,原生下拉鍵盤、AT、權限與實體音訊未驗收。 -- 發言者摘要:所有已進房者可看到最近收到的伺服器發言偵測回報,按目前台上順序列出名稱與人數。尚未收到時顯示等待,明確空集合才顯示「未偵測到發言」;回報中的 ID 無法對應台上成員時另作說明,不公開未知 ID 或觀眾名稱。名稱依目前快照安全顯示,改名與移出台上會同步更新;已移除或當下無法對應的 ID 不會因之後上台而復活舊活動,須等新回報;控制連線暫時斷開但仍在台上的寬限席位不因此排除。重連、終止連線、離房或換席清除舊集合,恢復快照後須再收到新回報,不從 SDK 保留的 getter 還原。這是伺服器活動偵測(VAD),沒有時間戳,不保證即時或實際可聽見音訊;空集合也不是無音訊的證明。本機收聽靜音、音量、自動播放與傳輸狀態可能不同。不增加輪詢或媒體操作,原 `stage-speaking` detail 仍原樣轉送;摘要為普通文字,未新增頻繁宣讀的 live region。可用 `::part(speaking)` 設定外觀;原生畫面、輔助科技與實體音訊仍需驗收。 +- 發言者摘要:所有已進房者可看到最近收到的伺服器發言偵測回報,按目前台上順序列出名稱與人數。尚未收到時顯示等待,明確空集合才顯示「未偵測到發言」;回報中的 ID 無法對應台上成員時另作說明,不公開未知 ID 或觀眾名稱。名稱依目前快照安全顯示,改名與移出台上會同步更新;已移除或當下無法對應的 ID 不會因之後上台而復活舊活動,須等新回報;控制連線暫時斷開但仍在台上的寬限席位不因此排除。重連、終止連線、離房或換席清除舊集合,只有新的目前權威 state 才重新接納後續新回報;status、任意重繪與已接收/拒絕的事件物件重放不開啟接收資格,不從 SDK 保留的 getter 還原。新身分快照在 SDK status 回呼先可讀時,仍可先顯示等待,但這個提示不授權提前採用回報。公開同 client 合法斷線/終止後重新加入同房同身分仍可由新 state 與新回報恢復。本人或他人的下台/轉觀眾通知先移除並暫時抑制該 ID;新的權威快照核對前,新回報不能借用舊台上名單,解除抑制也不重加舊活動,須之後新回報才重加。本人轉為觀眾仍可讀其他台上者,micerror/micready/收聽設定不冒充房間活動變化。這是伺服器活動偵測(VAD),沒有時間戳,不保證即時或實際可聽見音訊;空集合也不是無音訊的證明。本機收聽靜音、音量、自動播放與傳輸狀態可能不同。不增加輪詢或媒體操作,原 `stage-speaking` detail 仍原樣轉送;摘要為普通文字,未新增頻繁宣讀的 live region。可用 `::part(speaking)` 設定外觀;原生畫面、輔助科技與實體音訊仍需驗收。 - 本人連線品質:台上者收到自己的 `quality` 報告後,顯示最近收到的上/下行掉包率及 RTT;缺失或無效值顯示「—」,0 仍顯示 0,不據此推斷健康。只取本人 ID,不顯示其他發言者資料;觀眾與台下主控隱藏。重連、本人下台/轉為觀眾、離房/換席或終止會撤銷舊接收資格並清空;新的本人報告須在目前 client 的新權威台上 state 之後才可採用。status、角色/上台通知與任意重繪不開啟資格,已接收或拒絕的 state/quality 事件物件重放也不算新收件。公開 client 合法斷線後再加入同房同身分仍支援,新 state 加新報告即可顯示,並非永久封鎖該 client。房間麥克風錯誤僅清空報告,仍合格時可直接接收下一份新本人報告而不需另等 state;`micready` 不還原舊值。一般同席快照及保留台位的主控/講者移交保留已收到的數字。這是歷史回報而非即時狀態,RTT 不是端到端音訊延遲;事件沒有時間戳/peer 世代,麥克風錯誤後才到達的報告也不證明已恢復。伺服器無新報告時不自動推測過期或良好。沒有額外輪詢、媒體操作或控制請求,`stage-quality` 照原物件轉送;原生畫面、輔助科技與音訊仍需驗收。 - 舉手佇列位置來自目前 `state.hands` 的本人 ID;快照更新時重算,未找到本人則只顯示「已舉手」,重連期間標示「上次佇列」。保留寬限期的斷線者仍在佇列中;主控可任意核准人選,因此這不是保證的發言順序或等待時間。 - 輸出裝置選擇:已進房時可在「輸出裝置選擇(草稿)」手動重新整理揚聲器/耳機清單,再明確送出選擇;選單變更本身不呼叫 setSinkId。空字串沿用既有 SDK 的系統預設輸出,不支援 outputDeviceSupported 的瀏覽器顯示說明並停用此組控制。名稱以 textContent 顯示,匿名裝置有通用名稱;重新整理後缺失的草稿保留為未列出項,不偷偷改用其他裝置。重新整理與送出共用等待鎖,送出完成只恢復控制,草稿不表示目前實際輸出、已套用或可聽音訊;不會自動 enumerate、play、開麥克風或儲存裝置 ID,也不另呼叫權限申請 API;setSinkId 的裝置存取與權限仍由瀏覽器決定。 diff --git a/packages/client/src/element.ts b/packages/client/src/element.ts index 8ebdbdb..6e44bd1 100644 --- a/packages/client/src/element.ts +++ b/packages/client/src/element.ts @@ -94,6 +94,12 @@ export class StageElement extends HTMLElement { private readonly speaking: HTMLParagraphElement; private speakingOwner: { client: StageClient; roomId: string; participantId: string; generation: number } | undefined; private speakingReport: { ids: Set; empty: boolean } | undefined; + private readonly speakingSnapshots = new WeakSet(); + private readonly speakingReceipts = new WeakSet(); + private readonly speakingSuppressed = new Set(); + private speakingEventRevision = 0; + private speakingRevision = 0; + private speakingPainted = { hidden: true, text: '' }; private readonly handRejection: HTMLParagraphElement; private handRejectionOwner: { client: StageClient; roomId: string; participantId: string; generation: number } | undefined; /** Last failure (request rejected, mic denied…); stays until dismissed, the next action or join. */ @@ -460,17 +466,20 @@ export class StageElement extends HTMLElement { for (const type of EVENTS) { client.on(type, ({ detail }) => { if (generation !== this.generation || this.current !== client) return; + const speakingEvent = this.rememberSpeakingEvent(type, detail); const qualityEvent = this.rememberQualityEvent(type, detail); + if (type === 'closed' || type === 'kicked') this.stageManagementTerminalClient = client; + else if (type === 'state') this.stageManagementTerminalClient = undefined; // Own-role deltas precede their snapshot. Fence both dialogs before any DOM setter can reenter. this.noteRoomCloseEvent(type, detail); this.noteStageTransferEvent(type, detail); + this.reconcileRoomClose(); this.noteQualityEvent(client, generation, type, detail, qualityEvent); + this.noteSpeakingEvent(client, generation, type, detail, speakingEvent); if (type === 'closed' || type === 'kicked') this.microphoneTerminalClient = client; else if (type === 'state') this.microphoneTerminalClient = undefined; if (type === 'closed' || type === 'kicked') this.outputDeviceTerminalClient = client; else if (type === 'state') this.outputDeviceTerminalClient = undefined; - if (type === 'closed' || type === 'kicked') this.stageManagementTerminalClient = client; - else if (type === 'state') this.stageManagementTerminalClient = undefined; if (type === 'closed' || type === 'kicked' || (type === 'status' && detail === 'disconnected')) this.audioRecoveryTerminalClient = client; else if (type === 'state') this.audioRecoveryTerminalClient = undefined; this.syncOccupancy(type === 'state' ? detail as StageEventMap['state'] : undefined, @@ -499,10 +508,8 @@ export class StageElement extends HTMLElement { this.syncQualityOwner(type === 'quality' || type === 'state' || type === 'status' || type === 'closed' || type === 'kicked' || type === 'micerror' || (type === 'stageleft' && (detail as StageEventMap['stageleft']).participantId === ownId)); - if (type === 'closed' || type === 'kicked' - || (type === 'status' && (detail === 'reconnecting' || detail === 'disconnected'))) this.retireSpeakingOwner(); - else if (type === 'speaking') this.syncSpeakingOwner({ participantIds: (detail as StageEventMap['speaking'] | null | undefined)?.participantIds }); - else if (type === 'state' || type === 'status') this.syncSpeakingOwner(); + this.syncSpeakingOwner(type === 'speaking' || type === 'state' || type === 'status' + || type === 'closed' || type === 'kicked'); // SDK status precedes state; a forwarded status listener may disconnect immediately. if (type === 'state' || client.state) { receivedState = true; this.hideRetry(); this.hideCancel(); } if (type === 'closed' || type === 'kicked') { terminal = true; this.micFailure = undefined; this.hideRetry(); this.hideCancel(); } @@ -530,9 +537,11 @@ export class StageElement extends HTMLElement { if (micLifecycle) this.syncMicRetry(); this.syncOccupancy(); this.syncQualityOwner(false); + this.syncSpeakingOwner(false); this.dispatchEvent(new CustomEvent(`stage-${type}`, { detail, bubbles: true, composed: true })); this.syncOccupancy(); this.syncQualityOwner(false); + this.syncSpeakingOwner(false); // Application callbacks may retire this seat or mutate its exposed snapshot. this.syncMicrophoneOwner(); this.syncOutputDeviceOwner(); @@ -583,6 +592,7 @@ export class StageElement extends HTMLElement { this.leftRoom = true; this.roomExit = exit; this.retireOccupancy(); this.retireQualityOwner(); + this.retireSpeakingOwner(); this.hideError(); // Retire the event lane before disconnect can synchronously invoke application callbacks. void this.leave().catch(error => { @@ -596,6 +606,7 @@ export class StageElement extends HTMLElement { private async leave(): Promise { this.retireOccupancy(); this.retireQualityOwner(); + this.retireSpeakingOwner(); this.retireRoomClose(); this.roomCloseAuthority = undefined; this.roomCloseTerminalClient = undefined; this.joinPhase = undefined; @@ -611,7 +622,6 @@ export class StageElement extends HTMLElement { this.retireStageManagement(); this.audioRecoveryTerminalClient = undefined; this.retireAudioRecovery(); - this.retireSpeakingOwner(); this.retireHandRejection(); this.hideCancel(); this.hideRetry(); @@ -700,7 +710,7 @@ export class StageElement extends HTMLElement { } private noteRoomCloseEvent(type: keyof StageEventMap, detail: StageEventMap[keyof StageEventMap]): void { - const client = this.current, state = client?.state, me = client?.me; + const client = this.current, authority = this.roomCloseAuthority; if (type === 'closed' || type === 'kicked' || (type === 'status' && detail === 'disconnected')) { this.roomCloseTerminalClient = client ?? undefined; this.retireRoomClose(); return; } @@ -710,15 +720,14 @@ export class StageElement extends HTMLElement { } const role = type === 'role' ? detail as StageEventMap['role'] : undefined; const transfer = type === 'transferred' ? detail as StageEventMap['transferred'] : undefined; - if (client && state && me && ((role && role.participantId === me.participantId && role.role !== 'controller') - || (transfer && transfer.fromId === me.participantId && transfer.toId !== me.participantId))) { - const previous = this.roomCloseAuthority; - this.roomCloseAuthority = { client, roomId: state.roomId, participantId: me.participantId, generation: this.generation, - revision: (previous?.revision ?? 0) + 1, awaitingState: true }; + // Both dialogs consume known loss using copied IDs before either helper reads public getters. + if (authority && authority.client === client && authority.generation === this.generation + && ((role && role.participantId === authority.participantId && role.role !== 'controller') + || (transfer && transfer.fromId === authority.participantId && transfer.toId !== authority.participantId))) { + this.roomCloseAuthority = { ...authority, revision: authority.revision + 1, awaitingState: true }; this.retireRoomClose(); } if (type === 'status' && detail === 'reconnecting' && this.roomCloseAttempt) this.roomCloseAttempt.invalid = true; - this.reconcileRoomClose(); } private roomCloseMounted(): boolean { @@ -827,8 +836,9 @@ export class StageElement extends HTMLElement { } private retireSpeakingOwner(): void { + ++this.speakingRevision; this.speakingOwner = undefined; this.speakingReport = undefined; - this.speaking.textContent = ''; this.speaking.hidden = true; + this.speakingSuppressed.clear(); } private retireHandRejection(): void { @@ -854,40 +864,137 @@ export class StageElement extends HTMLElement { if (this.handRejection.textContent !== text) this.handRejection.textContent = text; } - /** A resumed SDK getter can retain the old report; only a new event establishes this owner's set. */ - private syncSpeakingOwner(report?: { participantIds: unknown }): void { - const client = this.current, state = client?.state, me = state?.me; - if (!this.isConnected || !client || !state || !me - || (client.status !== 'waiting' && client.status !== 'live')) { - this.retireSpeakingOwner(); return; + private rememberSpeakingEvent(type: keyof StageEventMap, detail: StageEventMap[keyof StageEventMap]): { revision: number; unseen: boolean } { + const revision = ++this.speakingEventRevision; + const identities = type === 'state' ? this.speakingSnapshots : type === 'speaking' ? this.speakingReceipts : undefined; + const object = detail !== null && (typeof detail === 'object' || typeof detail === 'function'); + const unseen = !object || !identities?.has(detail as object); + // Reserve even rejected envelopes before public getters can reenter. Primitive malformed reports stay unknown. + if (identities && object) identities.add(detail as object); + if (type === 'closed' || type === 'kicked' + || (type === 'status' && (detail === 'reconnecting' || detail === 'disconnected'))) this.retireSpeakingOwner(); + else if (type === 'stageleft' || (type === 'role' && (detail as StageEventMap['role']).role === 'audience')) { + const id = (detail as StageEventMap['stageleft']).participantId; + if (revision === this.speakingEventRevision && typeof id === 'string') { + this.speakingSuppressed.add(id); + this.speakingReport?.ids.delete(id); + ++this.speakingRevision; + } } - const owner = this.speakingOwner; - if (!owner || owner.client !== client || owner.generation !== this.generation - || owner.roomId !== state.roomId || owner.participantId !== me.participantId) { + return { revision, unseen }; + } + + /** Room-wide readership survives own-stage loss; only connection and entry identity bind its tenure. */ + private reconcileSpeakingOwner(): void { + for (;;) { + const owner = this.speakingOwner; + if (!owner) return; + const revision = this.speakingRevision, client = owner.client; + const state = client.state, status = client.status; + const roomId = state?.roomId, participantId = state?.me?.participantId; + const currentState = client.state, currentStatus = client.status; + if (owner !== this.speakingOwner || revision !== this.speakingRevision) continue; + if (state !== currentState || status !== currentStatus) continue; + if (this.isConnected && this.current === client && this.generation === owner.generation + && roomId === owner.roomId && participantId === owner.participantId + && (status === 'waiting' || status === 'live')) return; this.retireSpeakingOwner(); - this.speakingOwner = { client, roomId: state.roomId, participantId: me.participantId, generation: this.generation }; + // A corrective paint must not allow its setters to restore either pending dialog's consent. + this.noteRoomCloseEvent('status', currentStatus); + this.noteStageTransferEvent('status', currentStatus); + this.reconcileRoomClose(); + } + } + + private noteSpeakingEvent(client: StageClient, generation: number, type: keyof StageEventMap, + detail: StageEventMap[keyof StageEventMap], event: { revision: number; unseen: boolean }): void { + this.reconcileSpeakingOwner(); + if (event.revision !== this.speakingEventRevision || this.current !== client || this.generation !== generation) return; + if (type === 'state' && event.unseen) { + const state = client.state, status = client.status; + const roomId = state?.roomId, participantId = state?.me?.participantId; + const eligible = state && state === detail && this.isConnected && participantId !== undefined + && (status === 'waiting' || status === 'live') && state === client.state && status === client.status; + this.reconcileSpeakingOwner(); + if (eligible && event.revision === this.speakingEventRevision && this.current === client && this.generation === generation) { + if (!this.speakingOwner) this.speakingOwner = { client, roomId: roomId!, participantId, generation }; + // Releasing suppression never repopulates the already-pruned historical report. + this.speakingSuppressed.clear(); + ++this.speakingRevision; + } } - if (report) { - const ids = Array.isArray(report.participantIds) ? [...report.participantIds] : undefined; - // Copy before forwarding the mutable event; malformed synthetic reports are not evidence of silence. - this.speakingReport = ids?.every(id => typeof id === 'string') ? { ids: new Set(ids), empty: !ids.length } : undefined; + this.reconcileSpeakingOwner(); + const owner = this.speakingOwner; + if (type !== 'speaking' || !event.unseen || !owner || event.revision !== this.speakingEventRevision) return; + const raw = (detail as StageEventMap['speaking'] | null | undefined)?.participantIds; + const ids: unknown[] | undefined = Array.isArray(raw) ? [...raw] : undefined; + const report = ids?.every((id): id is string => typeof id === 'string') + ? { ids: new Set(ids.filter(id => !this.speakingSuppressed.has(id))), empty: ids.length === 0 } : undefined; + this.reconcileSpeakingOwner(); + if (owner !== this.speakingOwner || event.revision !== this.speakingEventRevision) return; + this.speakingReport = report; ++this.speakingRevision; + } + + private speakingProjection(): { hidden: boolean; text: string; revision: number } { + for (;;) { + this.reconcileSpeakingOwner(); + const owner = this.speakingOwner, report = this.speakingReport, revision = this.speakingRevision; + const client = this.current, generation = this.generation, state = client?.state, status = client?.status; + // SDK status can precede the first receipt for a new identity. This placeholder admits no report. + const placeholder = !owner && state?.me && !this.speakingSnapshots.has(state) && this.isConnected + && (status === 'waiting' || status === 'live'); + const hidden = !owner && !placeholder; + let text = hidden ? '' : '發言偵測:等待伺服器回報。'; + let seen: Set | undefined; + if (owner && report) { + if (report.empty) text = '最近收到的發言回報:未偵測到發言。'; + else { + seen = new Set(); const names: string[] = []; + for (const person of state?.speakers ?? []) { + const id = person.participantId, onStage = person.onStage, role = person.role; + if (!onStage || (role !== 'speaker' && role !== 'controller') || !report.ids.has(id) + || this.speakingSuppressed.has(id) || seen.has(id)) continue; + seen.add(id); + // Read the live public roster on every original render, including disconnected grace seats. + names.push(decodeName(person.name)); + } + text = names.length ? `最近收到的發言回報:${names.join('、')}(${names.length} 人)。` + : '最近收到的發言回報:沒有可對應的台上成員。'; + } + } + const sameSnapshot = state === client?.state && status === client?.status; + // Always reconcile the live winner, even after a getter completed a newer nested report. + this.reconcileSpeakingOwner(); + if (revision !== this.speakingRevision || owner !== this.speakingOwner || client !== this.current + || generation !== this.generation || !sameSnapshot) continue; + if (seen && report) report.ids = seen; + return { hidden, text, revision }; } - const current = this.speakingReport; - this.speaking.hidden = false; - if (!current) { this.speaking.textContent = '發言偵測:等待伺服器回報。'; return; } - if (current.empty) { this.speaking.textContent = '最近收到的發言回報:未偵測到發言。'; return; } - const seen = new Set(), names: string[] = []; - for (const person of state.speakers) { - if (!person.onStage || (person.role !== 'speaker' && person.role !== 'controller') - || !current.ids.has(person.participantId) || seen.has(person.participantId)) continue; - seen.add(person.participantId); - // Control-socket grace can retain media, so connected is deliberately not an eligibility test. - names.push(decodeName(person.name)); + } + + /** New events supply activity; rendering only maps and permanently prunes that copied report. */ + private syncSpeakingOwner(force = true): void { + const projection = this.speakingProjection(); + if (!force && projection.hidden === this.speakingPainted.hidden && projection.text === this.speakingPainted.text) return; + const current = (): boolean => { + const next = this.speakingProjection(); + if (next.revision !== projection.revision || next.hidden !== projection.hidden || next.text !== projection.text) { + // A host setter may finish its old write after a nested painter; its cached paint is no longer proof. + this.syncSpeakingOwner(); return false; + } + return true; + }; + // Keep ordinary unconditional replacement and its hidden-then-text order, including host descendant repair. + if (projection.hidden) { + this.speaking.textContent = projection.text; + if (!current()) return; + this.speaking.hidden = true; + } else { + this.speaking.hidden = false; + if (!current()) return; + this.speaking.textContent = projection.text; } - // A later stage entry must not revive an ID from an earlier, unmatched or removed seat. - current.ids = seen; - this.speaking.textContent = names.length ? `最近收到的發言回報:${names.join('、')}(${names.length} 人)。` - : '最近收到的發言回報:沒有可對應的台上成員。'; + if (current()) this.speakingPainted = projection; } /** Retirement is pure: a corrective DOM write must never precede the dialog authority fences. */ @@ -907,6 +1014,7 @@ export class StageElement extends HTMLElement { this.retireQualityOwner(); this.noteRoomCloseEvent('status', currentStatus); this.noteStageTransferEvent('status', currentStatus); + this.reconcileRoomClose(); // Either authority getter may have admitted a newer entry; reconcile that entry too. } } @@ -1324,17 +1432,8 @@ export class StageElement extends HTMLElement { private noteStageTransferEvent(type: keyof StageEventMap, detail: StageEventMap[keyof StageEventMap]): void { const owner = this.stageManagementOwner; if (!owner) return; - if (type === 'status' && (detail === 'reconnecting' || detail === 'disconnected') && this.stageTransferAttempt) this.stageTransferAttempt.invalid = true; - const client = this.current, state = client?.state, me = client?.me; - const sameController = owner.client === client && owner.generation === this.generation - && state?.roomId === owner.roomId && me?.participantId === owner.participantId - && me.role === 'controller' && me.connected === true; - // A replacement snapshot can emit status before state; observe its own-seat loss before DOM sync. - if (!sameController) { - owner.transferRevision++; owner.transferAwaitingState = true; - if (this.stageTransferAttempt) this.stageTransferAttempt.invalid = true; - } - if (type === 'state') { owner.transferAwaitingState = !sameController; return; } + if ((type === 'closed' || type === 'kicked' || (type === 'status' && (detail === 'reconnecting' || detail === 'disconnected'))) + && this.stageTransferAttempt) this.stageTransferAttempt.invalid = true; const role = type === 'role' ? detail as StageEventMap['role'] : undefined; const left = type === 'stageleft' ? detail as StageEventMap['stageleft'] : undefined; const transferred = type === 'transferred' ? detail as StageEventMap['transferred'] : undefined; @@ -1346,6 +1445,17 @@ export class StageElement extends HTMLElement { owner.transferRevision++; owner.transferAwaitingState = true; if (this.stageTransferAttempt) this.stageTransferAttempt.invalid = true; } + const client = this.current, state = client?.state, me = client?.me; + if (owner !== this.stageManagementOwner) return; + const sameController = owner.client === client && owner.generation === this.generation + && state?.roomId === owner.roomId && me?.participantId === owner.participantId + && me.role === 'controller' && me.connected === true; + // A replacement snapshot can emit status before state; observe its own-seat loss before DOM sync. + if (!sameController) { + owner.transferRevision++; owner.transferAwaitingState = true; + if (this.stageTransferAttempt) this.stageTransferAttempt.invalid = true; + } + if (type === 'state') { owner.transferAwaitingState = !sameController; return; } } private canTransferStage(id: string, node: HTMLLIElement): boolean { diff --git a/packages/client/test/element.test.ts b/packages/client/test/element.test.ts index e84b81d..3a7257e 100644 --- a/packages/client/test/element.test.ts +++ b/packages/client/test/element.test.ts @@ -12962,3 +12962,519 @@ describe('widget own quality observation window', () => { }); }); + + +/** Full widget/SDK/Hub and isolated public-event controls; no native DOM or acoustic claim. */ +describe('widget speaking receipt ownership and reentry', () => { + type Widget = InstanceType; + const WAITING = '發言偵測:等待伺服器回報。'; + const EMPTY = '最近收到的發言回報:未偵測到發言。'; + const UNMATCHED = '最近收到的發言回報:沒有可對應的台上成員。'; + const label = (...names: string[]) => `最近收到的發言回報:${names.join('、')}(${names.length} 人)。`; + const view = (e: Widget) => ({ hidden: e['speaking'].hidden, text: e['speaking'].textContent }); + const visible = (text: string) => ({ hidden: false, text }); + const HIDDEN = { hidden: true, text: '' }; + const report = (...participantIds: string[]) => ({ type: 'speaking' as const, participantIds }); + const emit = (client: StageClient, type: string, detail?: unknown) => client.dispatchEvent(new CustomEvent(type, { detail })); + function state(): RoomStatePayload { + const s = roomState(); + s.speakers = [{ ...s.me, participantId: 'host', role: 'controller', onStage: true, name: '甲' }, + { ...s.me, participantId: 'speaker', role: 'speaker', onStage: true, name: '乙' }]; + return s; + } + const receiveState = (client: StageClient, value = state()) => client['receive']({ type: 'room:state', ...value }); + const shown = (element: Widget, text = label('甲', '乙')) => assert.deepEqual(view(element), visible(text)); + const hidden = (element: Widget) => assert.deepEqual(view(element), HIDDEN); + async function joined(t: TestContext) { + const fixture = await subscribedWidget(t); await receiveState(fixture.client); return fixture; + } + function setter(t: TestContext, node: HTMLParagraphElement, property: 'hidden' | 'textContent', action: () => void): void { + let value = Reflect.get(node, property), armed = true; + Object.defineProperty(node, property, { configurable: true, get: () => value, set: next => { + value = next; if (armed) { armed = false; action(); } + } }); + t.after(() => Object.defineProperty(node, property, { configurable: true, writable: true, value })); + } + it('actual widget button → SDK → validator → Hub removes own name before role and corrected mixer report', async t => { + const config = testConfig(c => { c.rooms.presenceBroadcastMs = 0; c.rooms.qualityIntervalMs = 0; }); + const h = harness(config), sockets: BridgeSocket[] = []; + class BridgeSocket { + static OPEN = 1; readyState = 1; + onmessage: ((event: { data: string }) => void) | null = null; + onclose: (() => void) | null = null; + chain = Promise.resolve(); readonly session = new FakeSession(); readonly sent: ClientMessage[] = []; + constructor() { + sockets.push(this); const record = this.session.send.bind(this.session); + this.session.send = (message: ServerMessage, encoded?: string) => { + record(message, encoded); + if (this.readyState === BridgeSocket.OPEN) this.onmessage?.({ data: encoded ?? JSON.stringify(message) }); + }; + queueMicrotask(() => h.hub.attach(this.session)); + } + send(data: string) { const message = parseClientMessage(data, config.limits); this.sent.push(message); + this.chain = this.chain.then(() => h.hub.handle(this.session, message)); } + close(code = 1006) { if (this.readyState !== BridgeSocket.OPEN) return; this.readyState = 3; + this.chain = this.chain.then(() => h.hub.detach(this.session, code === 1000)).then(() => this.onclose?.()); } + } + const original = Object.getOwnPropertyDescriptor(globalThis, 'WebSocket'); + Object.defineProperty(globalThis, 'WebSocket', { configurable: true, value: BridgeSocket }); + t.mock.method(StageClient.prototype as unknown as { createMedia(): void }, 'createMedia', () => {}); + let now = 1000; t.mock.method(performance, 'now', () => now); + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); + const flush = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); }; + let element: InstanceType | undefined; + t.after(async () => { + element?.disconnectedCallback(); await flush(); await Promise.all(sockets.map(s => s.chain)); + await h.hub.shutdown(); await h.transport.close(); + if (original) Object.defineProperty(globalThis, 'WebSocket', original); else Reflect.deleteProperty(globalThis, 'WebSocket'); + }); + const host = await h.create('甲'); + element = mountJoiningWidget({ room: host.roomId, code: host.code, name: '乙' }); + for (let i = 0; i < 100 && !(element.client?.me && element.client['pending'].size === 0); i++) { + await flush(); now += 20; t.mock.timers.tick(20); await flush(); + } + const e = element, client = e.client!, socket = sockets[0]!; assert.ok(client.me); + assert.deepEqual(socket.session.inbox.map(m => m.type), ['hello', 'rtc:config', 'room:state', 'room:state', 'speaking', 'ok']); + const pid = client.me.participantId; + assert.equal(await h.req(socket.session, { type: 'hand:raise' }), 'ok'); + assert.equal(await h.req(host.s, { type: 'stage:approve', targetId: pid }), 'ok'); + assert.equal(await h.req(socket.session, { type: 'rtc:offer', payload: SENDRECV_OFFER }), 'ok'); + assert.equal(await h.req(host.s, { type: 'rtc:offer', payload: SENDRECV_OFFER }), 'ok'); + assert.equal(h.transport.emitUplink(host.roomId, host.id, sine(960, .5)), true); + assert.equal(h.transport.emitUplink(host.roomId, pid, sine(960, .5)), true); + h.mixers.get(host.roomId)!.tick(); assert.deepEqual(view(e), visible(label('甲', '乙'))); + const deltaViews: unknown[] = []; + for (const type of ['role', 'stageleft']) e.addEventListener(`stage-${type}`, () => { + deltaViews.push({ type, ownStillOnStage: client.me!.onStage, view: view(e) }); + }); + const framesBefore = socket.sent.length, incomingBefore = socket.session.inbox.length; + click(e['leaveStage']); await flush(); now += 100; t.mock.timers.tick(100); await flush(); await socket.chain; await flush(); + assert.equal(client.me!.onStage, false); assert.equal(client.me!.role, 'audience'); + assert.deepEqual(socket.sent.slice(framesBefore).map(m => m.type), ['stage:leave']); + assert.equal(client['pending'].size, 0); assert.deepEqual(view(e), visible(label('甲'))); + assert.deepEqual(socket.session.inbox.slice(incomingBefore).map(m => m.type), ['role:update', 'speaking', 'stage:left', 'room:state', 'ok']); + assert.deepEqual(deltaViews, + [{ type: 'role', ownStillOnStage: true, view: visible(label('甲')) }, { type: 'stageleft', ownStillOnStage: true, view: visible(label('甲')) }]); + }); + + for (const own of [false, true]) { + it(`isolated actual Hub ${own ? 'own' : 'other'} removal deltas prune and suppress before the current snapshot`, async t => { + const h = harness(testConfig(c => { c.rooms.presenceBroadcastMs = 0; c.rooms.qualityIntervalMs = 0; })); + t.after(async () => { await h.hub.shutdown(); await h.transport.close(); }); + const host = await h.create('甲'), speaker = await h.join(host.roomId, host.code, '乙'); + assert.equal(await h.req(speaker.s, { type: 'hand:raise' }), 'ok'); + assert.equal(await h.req(host.s, { type: 'stage:approve', targetId: speaker.id }), 'ok'); + const observer = (own ? speaker : host).s; + const f = await socketJoinedWidget(t, observer.last('room:state')!); + f.socket.deliver(report(host.id, speaker.id)); shown(f.element); + observer.clear(); assert.equal(await h.req(host.s, { type: 'stage:remove', targetId: speaker.id }), 'ok'); + const deltas = observer.inbox.filter(m => m.type === 'stage:left' || m.type === 'role:update'); + assert.deepEqual(deltas.map(m => m.type), ['role:update', 'stage:left']); + let callbacks = 0; + for (const type of ['role', 'stageleft']) f.element.addEventListener(`stage-${type}`, () => { + callbacks++; shown(f.element, label('甲')); + assert.ok(f.client.state!.speakers.some(p => p.participantId === speaker.id), 'SDK still exposes the previous roster'); + }, { once: true }); + for (const delta of deltas) f.socket.deliver(delta); + assert.equal(callbacks, 2); + // Synthetic newly allocated receipt deliberately probes the stale-roster interval, not Hub mixer ordering. + emit(f.client, 'speaking', { participantIds: [host.id, speaker.id] }); shown(f.element, label('甲')); + f.socket.deliver(observer.last('room:state')!); shown(f.element, label('甲')); + if (own) assert.equal(f.client.me!.role, 'audience', 'own removal preserves room-wide reader admission'); + }); + } + + for (const type of ['stageleft', 'role'] as const) { + it(`isolated ${type} suppresses all new receipts until an unseen exact current snapshot, without reviving history`, async t => { + const { element, client } = await joined(t), accepted = client.state!; + emit(client, 'speaking', report('host', 'speaker')); shown(element); + const rejected = state(); emit(client, 'state', rejected); + emit(client, type, { participantId: 'speaker', role: 'audience' }); shown(element, label('甲')); + for (const old of [accepted, rejected]) { + emit(client, 'speaking', report('host', 'speaker')); shown(element, label('甲')); + client['snapshot'] = old; emit(client, 'state', old); + emit(client, 'speaking', report('host', 'speaker')); shown(element, label('甲')); + } + client['setStatus']('waiting'); emit(client, 'stagejoined', { participantId: 'speaker', role: 'speaker' }); + emit(client, 'role', { participantId: 'speaker', role: 'controller' }); + emit(client, 'speaking', report('host', 'speaker')); shown(element, label('甲')); + await receiveState(client); shown(element, label('甲')); + emit(client, 'speaking', report('host', 'speaker')); shown(element); + }); + } + + it('rejects pre-state and non-admitting status-placeholder receipts permanently while forwarding exact details', async t => { + const f = socketJoiningWidget(t); await settle(); const socket = f.sockets[0]!, request = await f.hello(socket), client = f.element.client!; + const rejected = report('host'); emit(client, 'speaking', rejected); hidden(f.element); + socket.deliver({ type: 'room:state', ...state() }); socket.deliver({ type: 'ok', requestId: request.requestId }); await settle(); + emit(client, 'speaking', rejected); shown(f.element, WAITING); + const placeholder = report('speaker'), newer = state(); newer.roomId = 'replacement'; newer.status = 'waiting'; + f.element.addEventListener('stage-status', () => { + shown(f.element, WAITING); emit(client, 'speaking', placeholder); shown(f.element, WAITING); + }, { once: true }); + let forwarded: CustomEvent | undefined; + f.element.addEventListener('stage-speaking', event => { forwarded = event as CustomEvent; }); + socket.deliver({ type: 'room:state', ...newer }); shown(f.element, WAITING); + emit(client, 'speaking', placeholder); shown(f.element, WAITING); + assert.equal(forwarded!.detail, placeholder); assert.equal(forwarded!.bubbles, true); assert.equal(forwarded!.composed, true); + emit(client, 'speaking', report('speaker')); shown(f.element, label('乙')); + }); + + it('actual SDK reconnect consumes rejected and accepted reports and snapshots until new state and new receipt', async t => { + const f = await socketJoinedWidget(t, state()), acceptedState = f.client.state!, accepted = report('host'); + emit(f.client, 'speaking', accepted); shown(f.element, label('甲')); f.socket.close(); hidden(f.element); + const rejectedState = state(), rejected = report('speaker'), statusOnly = report('host', 'speaker'); + emit(f.client, 'state', rejectedState); emit(f.client, 'speaking', rejected); hidden(f.element); + await f.tick(500); const next = f.sockets[1]!, request = await f.hello(next); + next.deliver({ type: 'status', state: 'live' }); emit(f.client, 'speaking', statusOnly); hidden(f.element); + for (const old of [acceptedState, rejectedState]) { + f.client['snapshot'] = old; emit(f.client, 'state', old); emit(f.client, 'speaking', report('speaker')); hidden(f.element); + } + next.deliver({ type: 'room:state', ...state(), resumeToken: 'resume-me' }); + next.deliver({ type: 'ok', requestId: request.requestId }); await settle(); shown(f.element, WAITING); + for (const detail of [accepted, rejected, statusOnly]) { emit(f.client, 'speaking', detail); shown(f.element, WAITING); } + emit(f.client, 'speaking', report('speaker')); shown(f.element, label('乙')); + }); + + for (const ids of [[], ['host'], ['unknown']] as const) { + it(`accepted ${ids.length ? ids[0] : 'empty'} receipt replay cannot overwrite newer activity`, async t => { + const { element, client } = await joined(t), old = report(...ids); + emit(client, 'speaking', old); emit(client, 'speaking', report('speaker')); shown(element, label('乙')); + emit(client, 'speaking', old); shown(element, label('乙')); + emit(client, 'speaking', report()); shown(element, EMPTY); emit(client, 'speaking', old); shown(element, EMPTY); + }); + } + + it('object/function receipts are identity-fenced while primitive and sparse malformed inputs stay unknown', async t => { + const { element, client } = await joined(t); + for (const detail of [undefined, null, false, 1, 'host', {}, { participantIds: Array(1) }, { participantIds: ['host', 1] }]) { + emit(client, 'speaking', report('host')); shown(element, label('甲')); + emit(client, 'speaking', detail); shown(element, WAITING); + } + const callable = Object.assign(() => {}, { participantIds: ['host'] }); + emit(client, 'speaking', callable); shown(element, label('甲')); + emit(client, 'speaking', report('speaker')); emit(client, 'speaking', callable); shown(element, label('乙')); + }); + + it('retains live-name rendering, permanent pruning, grace membership, and ordinary host descendant repair', async t => { + const { element, client } = await joined(t), snapshot = client.state!, node = element['speaking'] as unknown as Node; + emit(client, 'speaking', report('host', 'speaker')); shown(element); + snapshot.speakers[0]!.name = '改名 & 甲'; snapshot.speakers[0]!.connected = false; + element.playbackMuted = true; shown(element, label('改名 & 甲', '乙')); + snapshot.speakers[1]!.onStage = false; client['setStatus']('waiting'); shown(element, label('改名 & 甲')); + snapshot.speakers[1]!.onStage = true; element.playbackMuted = false; shown(element, label('改名 & 甲')); + emit(client, 'speaking', report('speaker')); shown(element, label('乙')); + let text = node.textContent, replacements = 0; + Object.defineProperty(node, 'textContent', { configurable: true, get: () => text, set: value => { + text = value; replacements++; node.children.length = 0; + } }); + t.after(() => Object.defineProperty(node, 'textContent', { configurable: true, writable: true, value: text })); + node.children.push(Object.assign(new Node(), { textContent: text })); + emit(client, 'speaking', report('speaker')); shown(element, label('乙')); + assert.equal(replacements, 1); assert.deepEqual(node.children, []); + emit(client, 'speaking', report('unknown')); shown(element, UNMATCHED); + }); + + for (const type of ['stageleft', 'role', 'closed', 'kicked', 'status'] as const) { + for (const getter of ['state', 'status'] as const) { + it(`${type} pure loss bookkeeping precedes ${getter} reentry with a newly allocated report`, async t => { + const { element, client } = await joined(t); emit(client, 'speaking', report('host', 'speaker')); + const rejected = report('host', 'speaker'), read = Object.getOwnPropertyDescriptor(StageClient.prototype, getter)!.get!; + let armed = true; + Object.defineProperty(client, getter, { configurable: true, get() { + const result: unknown = read.call(client); + if (armed) { armed = false; emit(client, 'speaking', rejected); } + return result; + } }); + t.after(() => Reflect.deleteProperty(client, getter)); + emit(client, type, type === 'status' ? 'reconnecting' : type === 'closed' || type === 'kicked' + ? { roomId: 'one' } : { participantId: 'speaker', role: 'audience' }); + const delta = type === 'stageleft' || type === 'role'; + if (delta) shown(element, label('甲')); else hidden(element); + await receiveState(client); if (delta) shown(element, label('甲')); else shown(element, WAITING); + emit(client, 'speaking', rejected); if (delta) shown(element, label('甲')); else shown(element, WAITING); + emit(client, 'speaking', report('speaker')); shown(element, label('乙')); + }); + } + } + + for (const property of ['hidden', 'textContent'] as const) { + for (const loss of ['status', 'room', 'self'] as const) { + it(`${property} setter clears silent ${loss} loss after a nested winner and requires a distinct authoritative state`, async t => { + const { element, client } = await joined(t); emit(client, 'speaking', report('host')); + setter(t, element['speaking'], property, () => { + emit(client, 'speaking', report('speaker')); + if (loss === 'status') client['currentStatus'] = 'reconnecting'; + else if (loss === 'room') client['snapshot']!.roomId = 'other'; else client['snapshot']!.me.participantId = 'other'; + }); + emit(client, 'speaking', report('host', 'speaker')); hidden(element); + client['currentStatus'] = 'live'; client['snapshot']!.roomId = 'one'; client['snapshot']!.me.participantId = 'me'; + emit(client, 'speaking', report('host')); hidden(element); + await receiveState(client); shown(element, WAITING); emit(client, 'speaking', report('speaker')); shown(element, label('乙')); + }); + } + + it(`${property} setter preserves a legitimate replacement entry and its newer report`, async t => { + const { element, client } = await joined(t); + setter(t, element['speaking'], property, () => { + const replacement = state(); replacement.roomId = 'new'; replacement.me.participantId = 'new'; + void receiveState(client, replacement); emit(client, 'speaking', report('speaker')); + }); + emit(client, 'speaking', report('host')); shown(element, label('乙')); assert.equal(client.state!.roomId, 'new'); + }); + + it(`${property} setter rejects retired terminal receipts and preserves later genuine same-client admission`, async t => { + const { element, client } = await joined(t), retired = report('host'); + setter(t, element['speaking'], property, () => { void client['receive']({ type: 'room:closed', roomId: 'one' }); }); + emit(client, 'speaking', retired); hidden(element); await settle(); assert.equal(client.state, null); + await receiveState(client); shown(element, WAITING); emit(client, 'speaking', retired); shown(element, WAITING); + emit(client, 'speaking', report('speaker')); shown(element, label('乙')); + }); + } + + for (const getter of ['state', 'status', 'name'] as const) { + for (const loss of ['status', 'room'] as const) { + it(`${getter} getter reconciles a nested newer report then silent ${loss} loss`, async t => { + const { element, client } = await joined(t); emit(client, 'speaking', report('host')); + const target = getter === 'name' ? client.state!.speakers[0]! : client; + const descriptor = Object.getOwnPropertyDescriptor(target, getter); + const read = getter === 'name' ? () => '甲' : Object.getOwnPropertyDescriptor(StageClient.prototype, getter)!.get!; + let armed = true; + Object.defineProperty(target, getter, { configurable: true, get() { + const result: unknown = read.call(client); + if (armed) { + armed = false; emit(client, 'speaking', report('speaker')); + if (loss === 'status') client['currentStatus'] = 'reconnecting'; else client['snapshot']!.roomId = 'other'; + } + return result; + } }); + t.after(() => { if (descriptor) Object.defineProperty(target, getter, descriptor); else Reflect.deleteProperty(target, getter); }); + element['syncSpeakingOwner'](); hidden(element); + client['currentStatus'] = 'live'; client['snapshot']!.roomId = 'one'; emit(client, 'speaking', report('host')); hidden(element); + await receiveState(client); emit(client, 'speaking', report('speaker')); shown(element, label('乙')); + }); + } + + it(`${getter} getter cannot overwrite or prune a nested newer admitted report`, async t => { + const { element, client } = await joined(t); emit(client, 'speaking', report('host')); + const target = getter === 'name' ? client.state!.speakers[0]! : client; + const descriptor = Object.getOwnPropertyDescriptor(target, getter); + const read = getter === 'name' ? () => '甲' : Object.getOwnPropertyDescriptor(StageClient.prototype, getter)!.get!; + let armed = true; + Object.defineProperty(target, getter, { configurable: true, get() { + const result: unknown = read.call(client); + if (armed) { armed = false; emit(client, 'speaking', report('speaker')); } + return result; + } }); + t.after(() => { if (descriptor) Object.defineProperty(target, getter, descriptor); else Reflect.deleteProperty(target, getter); }); + element['syncSpeakingOwner'](); shown(element, label('乙')); element['render'](); shown(element, label('乙')); + }); + } + + it('consumes an outer receipt before a public getter delivers a newer report, including later replay', async t => { + const { element, client } = await joined(t), outer = report('host'); + const read = Object.getOwnPropertyDescriptor(StageClient.prototype, 'state')!.get!; let armed = true; + Object.defineProperty(client, 'state', { configurable: true, get() { + const result: unknown = read.call(client); + if (armed) { armed = false; emit(client, 'speaking', report('speaker')); } + return result; + } }); + t.after(() => Reflect.deleteProperty(client, 'state')); + emit(client, 'speaking', outer); shown(element, label('乙')); emit(client, 'speaking', outer); shown(element, label('乙')); + }); + + for (const getter of ['state', 'status'] as const) { + it(`early pure terminal retirement preserves genuinely new state and report from a ${getter} getter`, async t => { + const { element, client } = await joined(t); emit(client, 'speaking', report('host')); + const read = Object.getOwnPropertyDescriptor(StageClient.prototype, getter)!.get!; let armed = true; + Object.defineProperty(client, getter, { configurable: true, get() { + const result: unknown = read.call(client); + if (armed) { armed = false; void receiveState(client); emit(client, 'speaking', report('speaker')); } + return result; + } }); + t.after(() => Reflect.deleteProperty(client, getter)); + emit(client, 'closed', { roomId: 'one' }); shown(element, label('乙')); + }); + } + + for (const action of ['close', 'transfer'] as const) { + for (const loss of ['status', 'identity'] as const) { + for (const property of ['hidden', 'textContent'] as const) { + it(`corrective speaking ${property} cannot restore pending ${action} consent after silent ${loss} loss`, async t => { + const { element, client } = await joined(t), value = state(); value.me.role = 'controller'; value.controllerId = 'me'; + value.speakers = [{ ...value.me, name: '甲', onStage: true }, { ...value.speakers[1]!, connected: true }]; + await receiveState(client, value); emit(client, 'speaking', report('me', 'speaker')); + const row = element['stageManagementRows'].get('speaker')!; + const close = t.mock.method(client, 'closeRoom', async () => {}), transfer = t.mock.method(client, 'transferControl', async () => {}); + let stored = Reflect.get(element['speaking'], property), armed = false, lost = false; + Object.defineProperty(element['speaking'], property, { configurable: true, get: () => stored, set: next => { + stored = next; + if (armed) { + armed = false; emit(client, 'speaking', report('speaker')); lost = true; + if (loss === 'status') client['currentStatus'] = 'reconnecting'; else client['snapshot']!.me.participantId = 'other'; + } else if (lost) { + lost = false; client['currentStatus'] = 'live'; client['snapshot']!.me.participantId = 'me'; + } + } }); + t.after(() => Object.defineProperty(element['speaking'], property, { configurable: true, writable: true, value: stored })); + const confirm = t.mock.method(window, 'confirm', () => { + armed = true; emit(client, 'speaking', report('me')); hidden(element); return true; + }); + click(action === 'close' ? element['roomClose'] : row.transfer); await settle(); + assert.equal(confirm.mock.callCount(), 1); assert.equal(close.mock.callCount(), 0); assert.equal(transfer.mock.callCount(), 0); + emit(client, 'speaking', report('speaker')); hidden(element); + await receiveState(client, value); emit(client, 'speaking', report('speaker')); shown(element, label('乙')); + }); + } + } + } + + for (const type of ['role', 'stageleft'] as const) { + it(`${type} loss fences existing Close and Transfer before a new speaking paint can click stale controls`, async t => { + const { element, client } = await joined(t), value = state(); value.me.role = 'controller'; value.me.onStage = true; value.controllerId = 'me'; + value.speakers = [{ ...value.me, name: '甲' }, value.speakers[1]!]; await receiveState(client, value); + emit(client, 'speaking', report('me', 'speaker')); const row = element['stageManagementRows'].get('speaker')!; + const close = t.mock.method(client, 'closeRoom', async () => {}), transfer = t.mock.method(client, 'transferControl', async () => {}); + const confirm = t.mock.method(window, 'confirm', () => true); + setter(t, element['speaking'], 'textContent', () => { click(element['roomClose']); click(row.transfer); }); + // Own audience-role invalidates Close; target stageleft invalidates Transfer while its pending dialog excludes Close. + if (type === 'role') emit(client, type, { participantId: 'me', role: 'audience' }); + else { + t.mock.method(window, 'confirm', () => { emit(client, type, { participantId: 'speaker', role: 'audience' }); return true; }); + click(row.transfer); + } + await settle(); assert.equal(close.mock.callCount(), 0); assert.equal(transfer.mock.callCount(), 0); + if (type === 'role') assert.equal(confirm.mock.callCount(), 0); + }); + } + + it('manual leave retires purely before early UI helpers and paints only after current-client and dialog teardown', async t => { + const { element, client } = await joined(t), value = state(); value.me.role = 'controller'; value.controllerId = 'me'; + await receiveState(client, value); emit(client, 'speaking', report('host', 'speaker')); + const row = element['stageManagementRows'].get('speaker')!, rejected = report('host'); + setter(t, element['error'], 'hidden', () => { emit(client, 'speaking', rejected); assert.equal(element['speakingOwner'], undefined); }); + const close = t.mock.method(client, 'closeRoom', async () => {}), transfer = t.mock.method(client, 'transferControl', async () => {}); + const confirm = t.mock.method(window, 'confirm', () => true); + setter(t, element['speaking'], 'textContent', () => { + assert.equal(element.client, null); click(element['roomClose']); click(row.transfer); + }); + click(element['roomAction']); await settle(); hidden(element); + assert.equal(confirm.mock.callCount(), 0); assert.equal(close.mock.callCount(), 0); assert.equal(transfer.mock.callCount(), 0); + }); + + for (const property of ['hidden', 'textContent'] as const) { + it(`${property} setter completing its stale write after a nested painter repairs the actual final DOM`, async t => { + const { element, client } = await joined(t), node = element['speaking']; + let value = Reflect.get(node, property), armed = true; + Object.defineProperty(node, property, { configurable: true, get: () => value, set: next => { + if (armed) { + armed = false; + if (property === 'hidden') emit(client, 'closed', { roomId: 'one' }); + else emit(client, 'speaking', report('speaker')); + } + value = next; + } }); + t.after(() => Object.defineProperty(node, property, { configurable: true, writable: true, value })); + emit(client, 'speaking', report('host')); + if (property === 'hidden') hidden(element); else shown(element, label('乙')); + }); + } + + for (const type of ['closed', 'kicked', 'role', 'transferred'] as const) { + for (const action of ['close', 'transfer'] as const) { + it(`${type} pure dialog loss precedes a getter-nested speaking setter clicking ${action}`, async t => { + const { element, client } = await joined(t), value = state(); value.me.role = 'controller'; value.me.onStage = true; value.controllerId = 'me'; + value.speakers = [{ ...value.me, name: '甲' }, value.speakers[1]!]; await receiveState(client, value); + emit(client, 'speaking', report('me', 'speaker')); const row = element['stageManagementRows'].get('speaker')!; + const close = t.mock.method(client, 'closeRoom', async () => {}), transfer = t.mock.method(client, 'transferControl', async () => {}); + const confirm = t.mock.method(window, 'confirm', () => true); + setter(t, element['speaking'], 'textContent', () => { click(action === 'close' ? element['roomClose'] : row.transfer); }); + const read = Object.getOwnPropertyDescriptor(StageClient.prototype, 'state')!.get!; let armed = true; + Object.defineProperty(client, 'state', { configurable: true, get() { + const result: unknown = read.call(client); + if (armed) { armed = false; emit(client, 'speaking', report('speaker')); } + return result; + } }); + t.after(() => Reflect.deleteProperty(client, 'state')); + emit(client, type, type === 'role' ? { participantId: 'me', role: 'audience' } + : type === 'transferred' ? { fromId: 'me', toId: 'speaker' } : { roomId: 'one' }); + await settle(); assert.equal(confirm.mock.callCount(), 0); assert.equal(close.mock.callCount(), 0); assert.equal(transfer.mock.callCount(), 0); + }); + } + } + + for (const terminal of ['closed', 'kicked'] as const) { + for (const action of ['close', 'transfer'] as const) { + it(`${terminal} outer getter continuation preserves genuine same-client new state and new ${action} consent`, async t => { + const { element, client } = await joined(t), value = state(); value.me.role = 'controller'; value.me.onStage = true; value.controllerId = 'me'; + value.speakers = [{ ...value.me, name: '甲' }, value.speakers[1]!]; await receiveState(client, value); + emit(client, 'speaking', report('me')); + const close = t.mock.method(client, 'closeRoom', async () => {}), transfer = t.mock.method(client, 'transferControl', async () => {}); + const confirm = t.mock.method(window, 'confirm', () => true); + const read = Object.getOwnPropertyDescriptor(StageClient.prototype, 'state')!.get!; let armed = true; + Object.defineProperty(client, 'state', { configurable: true, get() { + const result: unknown = read.call(client); + if (armed) { + armed = false; void receiveState(client, value); emit(client, 'speaking', report('speaker')); + const button = action === 'close' ? element['roomClose'] : element['stageManagementRows'].get('speaker')!.transfer; + click(button); + } + return result; + } }); + t.after(() => Reflect.deleteProperty(client, 'state')); + emit(client, terminal, { roomId: 'one' }); await settle(); shown(element, label('乙')); + assert.equal(confirm.mock.callCount(), 1); assert.equal(close.mock.callCount(), action === 'close' ? 1 : 0); + assert.equal(transfer.mock.callCount(), action === 'transfer' ? 1 : 0); + assert.equal(element['canCloseRoom'](), true); + const row = element['stageManagementRows'].get('speaker')!; + assert.equal(element['canTransferStage']('speaker', row.node), true); + }); + + it(`${terminal} fences old pending ${action} consent even when a getter restores same-client state, and permits a new attempt`, async t => { + const { element, client } = await joined(t), value = state(); value.me.role = 'controller'; value.me.onStage = true; value.controllerId = 'me'; + value.speakers = [{ ...value.me, name: '甲' }, value.speakers[1]!]; await receiveState(client, value); + emit(client, 'speaking', report('me')); + const close = t.mock.method(client, 'closeRoom', async () => {}), transfer = t.mock.method(client, 'transferControl', async () => {}); + const read = Object.getOwnPropertyDescriptor(StageClient.prototype, 'state')!.get!; let armed = false, first = true; + Object.defineProperty(client, 'state', { configurable: true, get() { + const result: unknown = read.call(client); + if (armed) { armed = false; void receiveState(client, value); emit(client, 'speaking', report('speaker')); } + return result; + } }); + t.after(() => Reflect.deleteProperty(client, 'state')); + const confirm = t.mock.method(window, 'confirm', () => { + if (first) { first = false; armed = true; emit(client, terminal, { roomId: 'one' }); } + return true; + }); + const button = () => action === 'close' ? element['roomClose'] : element['stageManagementRows'].get('speaker')!.transfer; + click(button()); await settle(); shown(element, label('乙')); + assert.equal(close.mock.callCount(), 0); assert.equal(transfer.mock.callCount(), 0); + click(button()); await settle(); assert.equal(confirm.mock.callCount(), 2); + assert.equal(close.mock.callCount(), action === 'close' ? 1 : 0); assert.equal(transfer.mock.callCount(), action === 'transfer' ? 1 : 0); + }); + } + } + + for (const type of ['speaking', 'state', 'status', 'mic', 'micerror', 'micready', 'role', 'stagejoined', 'transferred', 'quality', 'error'] as const) { + it(`preserves ordinary ${type} speaking setter trace without gratuitous paints`, async t => { + const { element, client } = await joined(t); emit(client, 'speaking', report('host', 'speaker')); + const writes: string[] = [], node = element['speaking']; + for (const property of ['hidden', 'textContent'] as const) { + let value = Reflect.get(node, property); + Object.defineProperty(node, property, { configurable: true, get: () => value, set: next => { value = next; writes.push(property); } }); + t.after(() => Object.defineProperty(node, property, { configurable: true, writable: true, value })); + } + if (type === 'speaking') emit(client, type, report('host', 'speaker')); + else if (type === 'state') await receiveState(client); + else if (type === 'status') client['setStatus']('waiting'); + else if (type === 'mic') emit(client, type, { participantId: 'speaker', muted: true, forceMuted: true }); + else if (type === 'role' || type === 'stagejoined') emit(client, type, { participantId: 'speaker', role: 'controller' }); + else if (type === 'transferred') emit(client, type, { fromId: 'host', toId: 'speaker' }); + else if (type === 'quality') emit(client, type, { participants: [] }); + else emit(client, type, type === 'micready' ? undefined : new Error('Unrelated')); + const pair = ['hidden', 'textContent']; + assert.deepEqual(writes, type === 'state' || type === 'status' ? [...pair, ...pair] : type === 'speaking' || type === 'mic' ? pair : []); + shown(element); + }); + } +}); From b902b20590316e55fb1b89b9c00eb8f425682b25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=A5=E8=AA=9E=E3=81=BB=E3=81=97=E3=81=9E=E3=82=89?= <92282870+YueyuHoshizora@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:23:34 +0800 Subject: [PATCH 09/38] feat(client): add manual own connection statistics (batch 203) --- PLAN.md | 3 + README.md | 2 + packages/client/README.md | 3 +- packages/client/src/element.ts | 198 ++++++++++++- packages/client/test/element.test.ts | 424 +++++++++++++++++++++++++++ 5 files changed, 627 insertions(+), 3 deletions(-) diff --git a/PLAN.md b/PLAN.md index 34ab8ac..3f202fd 100644 --- a/PLAN.md +++ b/PLAN.md @@ -844,3 +844,6 @@ N 路混音管線(decode→mix→encode)、不含自己的台上混音、lim 223. **Widget 發言摘要生命週期隔離(第202批,2026-10-16):** immutable201 真按鈕→SDK→validator→Hub 的 active-media 路徑 role→speaking→stageleft→state→ACK 在 role callback 仍顯示剛下台本人;另分開 isolated delta/synthetic 新回報與重放證明 stale roster 再接納、prestate/reconnect rejected receipt 與 status-only 缺口,不混淆證據來源。限定 speaking owner/receipt/ID suppression,重連/終止/換席先 pure retire,distinct 當前權威 state 再新 speaking 才恢復;拒絕 receipt 也記錄。保留 SDK 新身分 state-before-status 時非接納的 WAITING placeholder,原測試不弱化;合法同 client terminal 後同 ID 重入可恢復。本人/他人 audience-role/stageleft 純移除並抑制 ID,新的有效權威 state 才核對解除,舊集合不自動重加;所有角色 readership 與其餘 ID 保持。保留 admitted owner 原 live roster 名稱映射/永久刪除、原文案/empty/malformed sparse array 規則、detail 原樣轉送、connected grace、micerror 不清 speaking、原 text replacement/同文字子節點修復與普通 setter 次數,不套用 embed 不同顯示政策。已知失效在可重入 getter 前純處理,Close/Transfer 權限早於 DOM;獨立重現 terminal helper getter→nested speaking paint 可先觸發舊控制,故限既有兩 authority helper 的 known-delta 純階段提前,使用已保存身分、原 paired sites 後再 reconcile,保留新 state/new consent 的較新 tenure,不新增 action policy。nested 新報告後靜默失權的 tail 依目前 owner 調和,新合法 tenure 優先。leave 先 pure retire、current=null 與既有權限撤銷後 paint,不作廣泛生命週期重寫。五檔 element 源碼/測試+三文件,無 SDK/server/media/protocol/API/依賴變更或新增 timer/poll/command。歷史 VAD 無 timestamp/epoch,非即時可聽/沉默/恢復證明;原生 browser/AT/實體音訊未驗收。 + + +224. **Widget 手動本人連線統計(第203批,2026-10-16):** immutable202 完整 widget/實際 SDK 證明既有 getStats 無 widget 入口且觀眾不具 publisher 品質摘要。新增獨立 connection-stats part、明確按鈕與普通文字,所有有效席位可單次讀取 receive loss/RTT/buffer 與 send loss/RTT;無輪詢、timer、自動讀取/重試、控制命令、媒體或權限操作。沿用 SDK 每方向最後符合 RTP 與共用差值基準,標示上次手動結果/SDK 取樣區間,非全串流聚合、按鈕間隔或端到端音訊延遲。只保存有限數值 primitive,缺失/非法 metric 為破折號,缺少方向為暫無;畸形 envelope/direction 或 throwing getter 固定局部失敗,保留歷史,不寫全域錯誤/quality/speaking。方法 getter 前 reserve attempt,lookup/call/settlement/projection/每個新 DOM setter 後核對 owner/revision/status/seat,拒 hidden/disabled/detached controls、舊 promise 與 stale finally。重連/終止/離房/卸載/換席先純退役;新的明確 click 可在有效席位恢復後讀取,不需 passive distinct state;普通角色/台位/mic 變更保留。native promise 不可取消,retained peer 遲到完成可能推進共用 SDK 基準,不改 SDK。Close/Transfer 權限先於修正 DOM;獨立重現 Leave/rejoin legacy setter 重入,新增僅本功能 departure admission/paint fence,直到既有 teardown 完成,不廣泛改生命週期或原權限政策。五檔 element 源碼/測試+三文件;新增100例,原測試完整保留,actual SDK 取樣/單位/共享基準與 lifecycle/consent/負面控制納入驗證。無 SDK/server/media/protocol/config/依賴變更;原生瀏覽器/鍵盤/AT/硬體音訊未驗收。 diff --git a/README.md b/README.md index d055fa2..224d289 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,8 @@ Web Component 另以普通文字顯示最近收到的發言者回報,按目前 Web Component 的台上者收到伺服器本人品質回報後,可看到最近收到的上/下行掉包率及 RTT;只顯示本人,觀眾/台下主控不顯示。缺失值為「—」,不代表 0;RTT 並非端到端音訊延遲。重連、本人下台/轉為觀眾、換席或終止會清除舊摘要;須先收到目前 client 的新權威台上 state,再收到新的本人報告才重新顯示。單獨 status 恢復、角色通知或重放已接收/拒絕的事件物件不能還原。相同 client 合法離房重入仍可由新 state 與新報告恢復。麥克風錯誤僅清除報告,保留合格台位的接收資格,因此不必另等 state,後續新本人報告可顯示;`micready` 不會還原。報告沒有時間戳或 peer 世代,因此數字不是即時健康或麥克風恢復證明;不增加輪詢或音訊操作。 +Web Component 另有「更新我的連線統計」,觀眾與台上者都可手動讀取本人瀏覽器的接收掉包率/RTT/緩衝及傳送掉包率/RTT。只在明確按下時呼叫既有 `getStats()`,沒有輪詢或自動更新;數字標示為上次手動讀取及 SDK 取樣區間,RTT 不是端到端音訊延遲,也不證明目前可聽。每方向沿用 SDK 最後一筆符合的音訊 RTP 統計,並非全部串流加總;其他 `getStats()` 呼叫共用差值基準,因此區間不是兩次按鈕間隔。等待與讀取失敗保留上次結果,錯誤只顯示固定本地說明;缺失指標為「—」,沒有該方向則顯示暫無統計。重連、離房、終止或換席清除舊結果;恢復有效席位後可重新手動讀取,不自動恢復舊數字。既有原生讀取無取消能力,保留 peer 的遲到完成仍可能推進 SDK 共用基準,但不能回寫已退役 UI。不改原有品質回報、媒體或權限,原生瀏覽器/輔助科技與實體音訊未驗收。 + Web Component 的「離開房間」會停止本機收聽/擷取及自動重連,保留屬性與音量/收聽靜音偏好;同一元素更換屬性或移除再掛載後仍等待手動「重新加入」。重入採用最新屬性,不保證原台位/主控身分;關房/被踢不提供這個重入操作。離房先退休 client,因此與元件移除一樣不轉送其晚到事件(含 disconnect status),也不新增合成 SDK 狀態。此 UI 使用既有 disconnect,不代表伺服器房間立即關閉;受控 DOM/SDK 假媒體測試仍不等於真實瀏覽器音訊驗收。 Web Component 的目前主控另有獨立「關閉房間」,台上與台下皆可用;先確認「確定關閉此房間並讓所有人離場?關閉後無法再加入此房間。」,才呼叫既有 `closeRoom()`。離開仍只清理本人連線並沿用主控寬限,關閉則由伺服器終止全房。重連、失權、控制項被移出元件或缺少確認能力時不能送出;確認途中曾失去資格,即使恢復也須重新確認。與移交僅互斥同步確認流程,不新增所有命令的等待鎖;原有換席後再次移交的行為保留。取消、送出與 ACK 不清既有錯誤,也不本機宣告成功、離房或自動重試;關閉文案依既有終止事件,舊請求結果不能污染新席位或較新錯誤。SDK 全域錯誤政策不變。受控完整元件/實際 SDK/Hub 驗證不代表原生確認視窗、版面、鍵盤/焦點/AT 或硬體音訊驗收。 diff --git a/packages/client/README.md b/packages/client/README.md index caff8f1..18f54ea 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -217,6 +217,7 @@ WebSocket 建構失敗、hello 逾時或尚未進房的連線關閉,也會釋 - 發言者摘要:所有已進房者可看到最近收到的伺服器發言偵測回報,按目前台上順序列出名稱與人數。尚未收到時顯示等待,明確空集合才顯示「未偵測到發言」;回報中的 ID 無法對應台上成員時另作說明,不公開未知 ID 或觀眾名稱。名稱依目前快照安全顯示,改名與移出台上會同步更新;已移除或當下無法對應的 ID 不會因之後上台而復活舊活動,須等新回報;控制連線暫時斷開但仍在台上的寬限席位不因此排除。重連、終止連線、離房或換席清除舊集合,只有新的目前權威 state 才重新接納後續新回報;status、任意重繪與已接收/拒絕的事件物件重放不開啟接收資格,不從 SDK 保留的 getter 還原。新身分快照在 SDK status 回呼先可讀時,仍可先顯示等待,但這個提示不授權提前採用回報。公開同 client 合法斷線/終止後重新加入同房同身分仍可由新 state 與新回報恢復。本人或他人的下台/轉觀眾通知先移除並暫時抑制該 ID;新的權威快照核對前,新回報不能借用舊台上名單,解除抑制也不重加舊活動,須之後新回報才重加。本人轉為觀眾仍可讀其他台上者,micerror/micready/收聽設定不冒充房間活動變化。這是伺服器活動偵測(VAD),沒有時間戳,不保證即時或實際可聽見音訊;空集合也不是無音訊的證明。本機收聽靜音、音量、自動播放與傳輸狀態可能不同。不增加輪詢或媒體操作,原 `stage-speaking` detail 仍原樣轉送;摘要為普通文字,未新增頻繁宣讀的 live region。可用 `::part(speaking)` 設定外觀;原生畫面、輔助科技與實體音訊仍需驗收。 - 本人連線品質:台上者收到自己的 `quality` 報告後,顯示最近收到的上/下行掉包率及 RTT;缺失或無效值顯示「—」,0 仍顯示 0,不據此推斷健康。只取本人 ID,不顯示其他發言者資料;觀眾與台下主控隱藏。重連、本人下台/轉為觀眾、離房/換席或終止會撤銷舊接收資格並清空;新的本人報告須在目前 client 的新權威台上 state 之後才可採用。status、角色/上台通知與任意重繪不開啟資格,已接收或拒絕的 state/quality 事件物件重放也不算新收件。公開 client 合法斷線後再加入同房同身分仍支援,新 state 加新報告即可顯示,並非永久封鎖該 client。房間麥克風錯誤僅清空報告,仍合格時可直接接收下一份新本人報告而不需另等 state;`micready` 不還原舊值。一般同席快照及保留台位的主控/講者移交保留已收到的數字。這是歷史回報而非即時狀態,RTT 不是端到端音訊延遲;事件沒有時間戳/peer 世代,麥克風錯誤後才到達的報告也不證明已恢復。伺服器無新報告時不自動推測過期或良好。沒有額外輪詢、媒體操作或控制請求,`stage-quality` 照原物件轉送;原生畫面、輔助科技與音訊仍需驗收。 +- 手動本人連線統計:所有有效已進房席位皆可按「更新我的連線統計」,明確呼叫一次既有 `getStats()`;觀眾也可讀取,不以台位、麥克風或 connected 旗標過濾。顯示接收掉包率/RTT/緩衝及傳送掉包率/RTT,掉包率一位小數、RTT 秒轉整數毫秒、緩衝沿用毫秒;0 保留,缺失/無效數值為「—」,缺少方向顯示暫無統計。每方向是 SDK 最後一筆符合的音訊 RTP,並非全部串流加總。不展示 bitrate、jitter、codec、原始報告或例外。數字是上次手動讀取的歷史結果,標示 SDK 取樣區間;其他呼叫者共用 SDK 差值基準,區間不等於兩次按鈕間隔,RTT 也不是端到端音訊延遲。等待時停用按鈕並標示 aria-busy,保留歷史;失敗顯示本功能固定說明並保留歷史,不覆寫全域錯誤、品質或發言摘要。重連/斷線/終止/離房/卸載/換席撤銷舊讀取和結果;恢復有效席位後可明確再讀,不需等待新 state,也不由 status 自動讀取。一般同席角色、台位或麥克風變更保留歷史。原生 promise 無取消能力,保留 peer 的舊讀取仍可能推進 SDK 共用基準;UI 只隔離舊結果,沒有新增 timeout、重試或基準重設。沒有輪詢、媒體、播放、裝置或權限操作,普通文字不新增 live region;可用 `::part(connection-stats)` 設定外觀。受控 SDK/DOM 驗證不等於原生版面、鍵盤、AT 或實際音訊驗收。 - 舉手佇列位置來自目前 `state.hands` 的本人 ID;快照更新時重算,未找到本人則只顯示「已舉手」,重連期間標示「上次佇列」。保留寬限期的斷線者仍在佇列中;主控可任意核准人選,因此這不是保證的發言順序或等待時間。 - 輸出裝置選擇:已進房時可在「輸出裝置選擇(草稿)」手動重新整理揚聲器/耳機清單,再明確送出選擇;選單變更本身不呼叫 setSinkId。空字串沿用既有 SDK 的系統預設輸出,不支援 outputDeviceSupported 的瀏覽器顯示說明並停用此組控制。名稱以 textContent 顯示,匿名裝置有通用名稱;重新整理後缺失的草稿保留為未列出項,不偷偷改用其他裝置。重新整理與送出共用等待鎖,送出完成只恢復控制,草稿不表示目前實際輸出、已套用或可聽音訊;不會自動 enumerate、play、開麥克風或儲存裝置 ID,也不另呼叫權限申請 API;setSinkId 的裝置存取與權限仍由瀏覽器決定。 輸出設定作用於同一 client 的 audio 元件,因此等待/草稿/清單按 client 生命週期保留,而非按 room/participant 重設。公開 client 斷線、關房、換席或重入相同/不同房間時,沒有有效房間便隱藏停用;重新取得快照仍要等原輸出操作完成才可再次送出。一般重連保留顯示但停用。真正更換 client 或移除元件才退役舊 UI,既有 setSinkId 無取消 API,不能取消已交給瀏覽器的操作;舊 client 的遲到完成不操作新 client 的控制。永不完成的操作持續等待,沒有新增 timeout/強制解除;目前有效連線且可見 owner 的失敗可明確重試,隱藏或重連等待期間的失敗不另冒出錯誤。宿主直接呼叫 element.client.setOutputDevice 不經此 UI 鎖,不能保證與宿主並行選擇的先後結果。可用 ::part(output-device) 設定外觀;驗證限公開 SDK 與受控 DOM/audio/socket,原生裝置、權限、鍵盤、AT 與實際播放未驗收。 @@ -240,7 +241,7 @@ WebSocket 建構失敗、hello 逾時或尚未進房的連線關閉,也會釋 - 舉手/收回與麥克風靜音按鈕在接納 click 前重查目前席位、重連、冷卻與強制靜音限制。即使主頁的 `stage-state`/`stage-status` listener 在畫面重繪前同步點擊舊的 enabled 按鈕,已失效操作也不進入 pending、不清除目前錯誤、不送請求或占用 SDK 舉手冷卻;隱藏的這兩種控制同時停用。有效操作仍沿用目前快照選擇 raise/withdraw 或 mute/unmute,並保留各自等待 ACK 與錯誤處理。 - 連線結束提示:呼叫 `element.client.disconnect()` 會顯示「未連線」並隱藏房內操作;一般關房、被移出與伺服器維護/重啟各有提示,收聽偏好變更不會清掉提示。換房或重新掛載後清除舊提示,舊 client 的遲到事件不更新或重新派發到新 widget。 - 事件:所有 client 事件以 `stage-` 從元素派發(`bubbles`、`composed`,`detail` 相同),例如 `stage-state`、`stage-hand`、`stage-kicked`。 -- 樣式:`::part(panel)`、`::part(status)`、`::part(error)`、`::part(error-dismiss)`、`::part(actions)`、`::part(playback)`、`::part(name)`、`::part(room-name)`、`::part(quality)`、`::part(speaking)`、`::part(audio-retry)`、`::part(audio-recovery)`,以及 CSS 變數 `--stage-fg`、`--stage-bg`、`--stage-border`、`--stage-radius`、`--stage-button-bg`、`--stage-accent`、`--stage-accent-fg`、`--stage-error`。 +- 樣式:`::part(panel)`、`::part(status)`、`::part(error)`、`::part(error-dismiss)`、`::part(actions)`、`::part(playback)`、`::part(name)`、`::part(room-name)`、`::part(quality)`、`::part(speaking)`、`::part(connection-stats)`、`::part(audio-retry)`、`::part(audio-recovery)`,以及 CSS 變數 `--stage-fg`、`--stage-bg`、`--stage-border`、`--stage-radius`、`--stage-button-bg`、`--stage-accent`、`--stage-accent-fg`、`--stage-error`。 - 自訂標籤名:`import { defineStageElement } from 'https://host/lib/stage-element.js'` 後呼叫 `defineStageElement('my-stage')`(`stage-client` 已於載入時註冊,多個自訂標籤可並存)。每個自訂標籤使用 `StageElement` 的獨立子類別,因此仍是 `instanceof StageElement`,但 constructor 不與預設/其他標籤相同;重複註冊同名標籤為 no-op,既有其他元件不被覆寫。 `` 的舉手、麥克風、離開/返回舞台與音訊解鎖按鈕在各自請求完成前保持停用並標示 `aria-busy`,不會被冷卻倒數或新 state 的重新繪製提前解鎖。失敗後顯示錯誤並重新套用目前冷卻/強制靜音限制;切換房間或移除元件時,舊請求不會改動新房間的按鈕狀態。 diff --git a/packages/client/src/element.ts b/packages/client/src/element.ts index 6e44bd1..99eb29a 100644 --- a/packages/client/src/element.ts +++ b/packages/client/src/element.ts @@ -13,6 +13,8 @@ const STYLE = ` [part=status] { margin: 0; } .room-occupancy { margin: 0; overflow-wrap: anywhere; } [part=quality] { margin: 0; font-size: .875em; } +[part=connection-stats][hidden] { display: none; } +[part=connection-stats] p { margin: .5rem 0 0; font-size: .875em; overflow-wrap: anywhere; } [part=speaking] { margin: 0; font-size: .875em; } [part=hand-rejection] { margin: 0; } [part=audio-recovery] { margin: 0; font-size: .875em; } @@ -68,7 +70,7 @@ button.primary { background: var(--stage-accent, #2563eb); border-color: transpa * Controllers can lock/unlock other speakers' mute, explicitly apply a room-mix gain draft, remove them from the stage, or confirm a connected speaker's control handoff. * The microphone picker is an explicit draft, never an indicator of the actual capture device. * The output picker is also a draft; explicit refresh and submission never imply audible playback. - * Style through `::part(panel|status|quality|speaking|hand-rejection|audio-recovery|audio-retry|actions|name|room-name|hands|stage-management|playback|microphone|output-device)` and the + * Style through `::part(panel|status|quality|connection-stats|speaking|hand-rejection|audio-recovery|audio-retry|actions|name|room-name|hands|stage-management|playback|microphone|output-device)` and the * `--stage-*` custom properties. */ export class StageElement extends HTMLElement { @@ -91,6 +93,16 @@ export class StageElement extends HTMLElement { // Extra callback reconciliation repaints only changed projection; original paint triggers stay unconditional. private qualityPaintedRevision = -1; private qualityClearFirst = true; + private readonly connectionStatsPanel: HTMLDivElement; + private readonly connectionStatsUpdate: HTMLButtonElement; + private readonly connectionStatsText: HTMLParagraphElement; + private connectionStatsOwner: { client: StageClient; roomId: string; participantId: string; generation: number } | undefined; + private connectionStatsTerminalClient: StageClient | undefined; + private connectionStatsAttempt: object | undefined; + private connectionStatsReport: { inbound: { loss: number | undefined; rtt: number | undefined; buffer: number | undefined } | null; + outbound: { loss: number | undefined; rtt: number | undefined } | null } | undefined; + private connectionStatsFailed = false; + private connectionStatsLeaving = false; private readonly speaking: HTMLParagraphElement; private speakingOwner: { client: StageClient; roomId: string; participantId: string; generation: number } | undefined; private speakingReport: { ids: Set; empty: boolean } | undefined; @@ -213,6 +225,15 @@ export class StageElement extends HTMLElement { this.quality = document.createElement('p'); this.quality.part.add('quality'); this.quality.hidden = true; + this.connectionStatsPanel = document.createElement('div'); + this.connectionStatsPanel.part.add('connection-stats'); + this.connectionStatsPanel.hidden = true; + this.connectionStatsUpdate = document.createElement('button'); + this.connectionStatsUpdate.type = 'button'; + this.connectionStatsUpdate.textContent = '更新我的連線統計'; + this.connectionStatsUpdate.onclick = () => this.refreshConnectionStats(); + this.connectionStatsText = document.createElement('p'); + this.connectionStatsPanel.append(this.connectionStatsUpdate, this.connectionStatsText); this.speaking = document.createElement('p'); this.speaking.part.add('speaking'); this.speaking.hidden = true; @@ -376,7 +397,7 @@ export class StageElement extends HTMLElement { this.retry = this.button('重試加入', () => { if (this.canRetry()) this.rejoin(); }); this.cancelJoin = this.button('取消加入', () => this.cancelInitial?.()); actions.append(this.unlock, this.listen, this.hand, this.mic, this.micRetry, this.audioRetry, this.leaveStage, this.roomAction, this.roomClose, this.retry, this.cancelJoin); - panel.append(this.status, this.occupancy, this.quality, this.speaking, this.handRejection, this.error, this.errorDismiss, this.namePanel, this.roomNamePanel, this.handQueuePanel, this.stageManagementPanel, actions, this.roomCloseNotice, this.audioRecovery, this.microphonePanel, this.outputDevicePanel, this.playback); + panel.append(this.status, this.occupancy, this.quality, this.connectionStatsPanel, this.speaking, this.handRejection, this.error, this.errorDismiss, this.namePanel, this.roomNamePanel, this.handQueuePanel, this.stageManagementPanel, actions, this.roomCloseNotice, this.audioRecovery, this.microphonePanel, this.outputDevicePanel, this.playback); root.append(style, panel); this.render(); } @@ -468,6 +489,11 @@ export class StageElement extends HTMLElement { if (generation !== this.generation || this.current !== client) return; const speakingEvent = this.rememberSpeakingEvent(type, detail); const qualityEvent = this.rememberQualityEvent(type, detail); + // Retire the manual read before public authority getters can admit a genuine new entry. + if (type === 'closed' || type === 'kicked') this.connectionStatsTerminalClient = client; + else if (type === 'state') this.connectionStatsTerminalClient = undefined; + if (type === 'closed' || type === 'kicked' + || (type === 'status' && (detail === 'reconnecting' || detail === 'disconnected'))) this.retireConnectionStats(); if (type === 'closed' || type === 'kicked') this.stageManagementTerminalClient = client; else if (type === 'state') this.stageManagementTerminalClient = undefined; // Own-role deltas precede their snapshot. Fence both dialogs before any DOM setter can reenter. @@ -538,10 +564,12 @@ export class StageElement extends HTMLElement { this.syncOccupancy(); this.syncQualityOwner(false); this.syncSpeakingOwner(false); + this.syncConnectionStats(); this.dispatchEvent(new CustomEvent(`stage-${type}`, { detail, bubbles: true, composed: true })); this.syncOccupancy(); this.syncQualityOwner(false); this.syncSpeakingOwner(false); + this.syncConnectionStats(); // Application callbacks may retire this seat or mutate its exposed snapshot. this.syncMicrophoneOwner(); this.syncOutputDeviceOwner(); @@ -590,6 +618,7 @@ export class StageElement extends HTMLElement { ++this.generation; const exit = {}; this.leftRoom = true; this.roomExit = exit; + this.retireConnectionStats(); this.retireOccupancy(); this.retireQualityOwner(); this.retireSpeakingOwner(); @@ -604,6 +633,8 @@ export class StageElement extends HTMLElement { } private async leave(): Promise { + this.connectionStatsLeaving = true; + this.retireConnectionStats(); this.retireOccupancy(); this.retireQualityOwner(); this.retireSpeakingOwner(); @@ -631,6 +662,8 @@ export class StageElement extends HTMLElement { this.cooldownTimer = undefined; this.cooldownDue = 0; const client = this.current; this.current = null; + this.connectionStatsLeaving = false; + this.connectionStatsTerminalClient = undefined; this.unlock.hidden = true; this.render(); await client?.disconnect(); @@ -830,6 +863,166 @@ export class StageElement extends HTMLElement { })(); } + private retireConnectionStats(): void { + this.connectionStatsOwner = undefined; this.connectionStatsAttempt = undefined; + this.connectionStatsReport = undefined; this.connectionStatsFailed = false; + } + + private connectionStatsMounted(): boolean { + return this.isConnected && this.connectionStatsPanel.isConnected && this.connectionStatsUpdate.isConnected + && this.connectionStatsText.isConnected && this.widgetRoot.contains(this.connectionStatsPanel) + && this.connectionStatsPanel.contains(this.connectionStatsUpdate) && this.connectionStatsPanel.contains(this.connectionStatsText); + } + + /** Manual readership needs a current seat, not a publisher, microphone, or new passive report. */ + private reconcileConnectionStats(): void { + for (;;) { + const owner = this.connectionStatsOwner, client = this.current, generation = this.generation; + const state = client?.state, me = client?.me, status = client?.status; + const roomId = state?.roomId, participantId = me?.participantId; + if (owner !== this.connectionStatsOwner || client !== this.current || generation !== this.generation + || state !== client?.state || status !== client?.status || roomId !== client?.state?.roomId + || participantId !== client?.me?.participantId) continue; + const eligible = Boolean(client && state && me && typeof roomId === 'string' && typeof participantId === 'string' + && (status === 'waiting' || status === 'live') && this.connectionStatsTerminalClient !== client + && !this.connectionStatsLeaving && !this.leftRoom && !this.roomExit && this.connectionStatsMounted()); + const lost = Boolean(owner && (!eligible || owner.client !== client || owner.generation !== generation + || owner.roomId !== roomId || owner.participantId !== participantId)); + if (lost) this.retireConnectionStats(); + const retained = this.connectionStatsOwner; + const authorities = [this.roomCloseOwner, this.stageManagementOwner]; + const authorityLost = authorities.some(authority => authority && (!eligible || authority.client !== client + || authority.generation !== generation || authority.roomId !== roomId || authority.participantId !== participantId + || me?.role !== 'controller' || me.connected !== true)); + // Preserve ordinary helper traces; only observed loss needs corrective authority notes. + if (lost || authorityLost) { + this.noteRoomCloseEvent('status', status ?? 'disconnected'); + this.noteStageTransferEvent('status', status ?? 'disconnected'); + } + this.reconcileRoomClose(); + const transfer = this.stageTransferAttempt; + if (transfer && !this.canTransferStage(transfer.id, transfer.node)) transfer.invalid = true; + if (retained !== this.connectionStatsOwner || client !== this.current || generation !== this.generation + || state !== client?.state || status !== client?.status || roomId !== client?.state?.roomId + || participantId !== client?.me?.participantId || eligible !== Boolean(client && state && me + && typeof roomId === 'string' && typeof participantId === 'string' + && (status === 'waiting' || status === 'live') && this.connectionStatsTerminalClient !== client + && !this.connectionStatsLeaving && !this.leftRoom && !this.roomExit && this.connectionStatsMounted())) continue; + if (eligible && !retained) this.connectionStatsOwner = { client: client!, generation, roomId: roomId!, participantId: participantId! }; + return; + } + } + + private connectionStatsMessage(): string { + const report = this.connectionStatsReport; + if (!report) return this.connectionStatsFailed ? '無法讀取連線統計,請稍後手動再試。' : '尚未手動讀取連線統計。'; + const loss = (value: number | undefined): string => value === undefined ? '—' : value.toFixed(1); + const ms = (value: number | undefined): string => value === undefined ? '—' : String(Math.round(value)); + const inbound = report.inbound, outbound = report.outbound; + const receive = inbound ? `接收掉包 ${loss(inbound.loss)}% · 接收 RTT ${ms(inbound.rtt)} ms · 接收緩衝 ${ms(inbound.buffer)} ms` : '接收:暫無統計'; + const send = outbound ? `傳送掉包 ${loss(outbound.loss)}% · 傳送 RTT ${ms(outbound.rtt)} ms` : '傳送:暫無統計'; + return `上次手動讀取(SDK 取樣區間):${receive};${send}。RTT 不是端到端音訊延遲;不保證資料即時或目前可聽。` + + (this.connectionStatsFailed ? '本次讀取失敗,仍顯示上次手動讀取結果。' : ''); + } + + private syncConnectionStats(): void { + for (;;) { + // Existing leave teardown has earlier host callbacks; defer new DOM until its current=null fence. + if (this.current && (this.connectionStatsLeaving || this.leftRoom || this.roomExit)) { + this.retireConnectionStats(); return; + } + this.reconcileConnectionStats(); + const owner = this.connectionStatsOwner, attempt = this.connectionStatsAttempt; + const report = this.connectionStatsReport, failed = this.connectionStatsFailed; + const hidden = !owner, busy = Boolean(attempt), disabled = hidden || busy; + const text = hidden ? '' : this.connectionStatsMessage(); + // Reproject after every changed setter, including a stale outer setter that overwrites a nested winner. + if (hidden && !this.connectionStatsPanel.hidden) { this.connectionStatsPanel.hidden = true; continue; } + if (this.connectionStatsText.textContent !== text) { this.connectionStatsText.textContent = text; continue; } + if (this.connectionStatsUpdate.disabled !== disabled) { this.connectionStatsUpdate.disabled = disabled; continue; } + if (this.connectionStatsUpdate.getAttribute('aria-busy') !== String(busy)) { + this.connectionStatsUpdate.setAttribute('aria-busy', String(busy)); continue; + } + const label = busy ? '讀取中…' : '更新我的連線統計'; + if (this.connectionStatsUpdate.textContent !== label) { this.connectionStatsUpdate.textContent = label; continue; } + if (this.connectionStatsPanel.hidden !== hidden) { this.connectionStatsPanel.hidden = hidden; continue; } + this.reconcileConnectionStats(); + if (owner === this.connectionStatsOwner && attempt === this.connectionStatsAttempt + && report === this.connectionStatsReport && failed === this.connectionStatsFailed) return; + } + } + + private projectConnectionStats(raw: unknown, current: () => boolean): StageElement['connectionStatsReport'] { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(); + const direction = (key: 'inbound' | 'outbound') => { + const value: unknown = (raw as Record)[key]; + if (!current()) return undefined; + if (value === null || value === undefined) return null; + if (typeof value !== 'object' || Array.isArray(value)) throw new Error(); + return value as Record; + }; + const numeric = (value: unknown, max = Infinity, factor = 1): number | undefined => { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > max) return undefined; + const converted = value * factor; + return Number.isFinite(converted) ? converted : undefined; + }; + const inbound = direction('inbound'); + if (!current()) return undefined; + let receive: NonNullable['inbound'] = null; + if (inbound) { + const loss = numeric(inbound.lossPercent, 100); if (!current()) return undefined; + const rtt = numeric(inbound.rtt, Infinity, 1000); if (!current()) return undefined; + const buffer = numeric(inbound.bufferMs); if (!current()) return undefined; + receive = { loss, rtt, buffer }; + } + const outbound = direction('outbound'); + if (!current()) return undefined; + let send: NonNullable['outbound'] = null; + if (outbound) { + const loss = numeric(outbound.lossPercent, 100); if (!current()) return undefined; + const rtt = numeric(outbound.rtt, Infinity, 1000); if (!current()) return undefined; + send = { loss, rtt }; + } + return { inbound: receive, outbound: send }; + } + + private refreshConnectionStats(): void { + if (this.connectionStatsLeaving || this.leftRoom || this.roomExit) return; + if (this.connectionStatsUpdate.disabled || this.connectionStatsUpdate.hidden || this.connectionStatsPanel.hidden + || !this.connectionStatsMounted()) return; + this.syncConnectionStats(); + const owner = this.connectionStatsOwner; + if (!owner || this.connectionStatsAttempt) return; + // Reserve before any getStats lookup or DOM callback can invoke the retained handler again. + const attempt = this.connectionStatsAttempt = {}; + this.connectionStatsFailed = false; + const current = (): boolean => { + this.syncConnectionStats(); + return this.connectionStatsOwner === owner && this.connectionStatsAttempt === attempt; + }; + void (async () => { + try { + if (!current()) return; + const read = owner.client.getStats; + if (!current()) return; + if (typeof read !== 'function') throw new Error(); + const pending = read.call(owner.client); + const active = current(); + // Consume even a retired native promise; UI retirement does not cancel SDK sampling or its deltas. + const raw: unknown = await pending; + if (!current() || !active) return; + const report = this.projectConnectionStats(raw, current); + if (!current() || !report) return; + this.connectionStatsReport = report; + } catch { + if (current()) this.connectionStatsFailed = true; + } finally { + if (this.connectionStatsOwner === owner && this.connectionStatsAttempt === attempt) this.connectionStatsAttempt = undefined; + this.syncConnectionStats(); + } + })(); + } + private retireQualityOwner(): void { ++this.qualityRevision; this.qualityOwner = undefined; this.qualityReport = undefined; this.qualityClearFirst = true; @@ -2000,6 +2193,7 @@ export class StageElement extends HTMLElement { this.syncRoomClose(); this.syncQualityOwner(); this.syncSpeakingOwner(); + this.syncConnectionStats(); this.syncHandRejection(); const client = this.current; const me = client?.me ?? null; diff --git a/packages/client/test/element.test.ts b/packages/client/test/element.test.ts index 3a7257e..52e50db 100644 --- a/packages/client/test/element.test.ts +++ b/packages/client/test/element.test.ts @@ -13478,3 +13478,427 @@ describe('widget speaking receipt ownership and reentry', () => { }); } }); + +describe('widget manual own connection statistics', () => { + type Widget = InstanceType; + type Entry = Record; + const emit = (client: StageClient, type: string, detail?: unknown) => client.dispatchEvent(new CustomEvent(type, { detail })); + const text = (element: Widget) => element['connectionStatsText'].textContent!; + const refresh = (element: Widget) => click(element['connectionStatsUpdate']); + const report = (...entries: Entry[]) => new Map(entries.map(entry => [String(entry.id), entry])) as unknown as RTCStatsReport; + const inbound = (timestamp: number, extra: Entry = {}): Entry => ({ id: 'in', type: 'inbound-rtp', kind: 'audio', timestamp, + packetsReceived: 100, packetsLost: 2, bytesReceived: 1000, jitterBufferDelay: 2, jitterBufferEmittedCount: 100, ...extra }); + const stats = (rtt = .04) => ({ inbound: { lossPercent: 4, rtt, bufferMs: 20 }, outbound: { lossPercent: 12.5, rtt: .09 } }); + function pending() { + let resolve!: (value: T) => void, reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; + } + function fake(t: TestContext, client: StageClient, read: () => unknown) { + return t.mock.method(client as unknown as { getStats(): unknown }, 'getStats', read); + } + function setter(t: TestContext, node: object, property: string, action: () => void, late = false) { + let value = Reflect.get(node, property), armed = true; + Object.defineProperty(node, property, { configurable: true, get: () => value, set: next => { + if (!late) value = next; + if (armed) { armed = false; action(); } + if (late) value = next; + } }); + t.after(() => Object.defineProperty(node, property, { configurable: true, writable: true, value })); + } + const hidden = (element: Widget) => { + assert.equal(element['connectionStatsPanel'].hidden, true); assert.equal(text(element), ''); + assert.equal(element['connectionStatsUpdate'].disabled, true); assert.equal(element['connectionStatsUpdate'].getAttribute('aria-busy'), 'false'); + assert.equal(element['connectionStatsReport'], undefined); assert.equal(element['connectionStatsAttempt'], undefined); + }; + const busy = (element: Widget, value: boolean) => { + assert.equal(element['connectionStatsUpdate'].disabled, value); + assert.equal(element['connectionStatsUpdate'].getAttribute('aria-busy'), String(value)); + }; + async function controller(t: TestContext) { + const f = await subscribedWidget(t), state = roomState(true); + state.me.role = 'controller'; state.controllerId = 'me'; + state.speakers = [{ ...state.me }, { ...state.me, participantId: 'target', name: 'Speaker', role: 'speaker' }]; + await f.client['receive']({ type: 'room:state', ...state }); return f; + } + + it('has one independent ordinary panel, one explicit button, and no passive read', async t => { + const { element, client } = await subscribedWidget(t), read = fake(t, client, async () => stats()); + const panel = element['connectionStatsPanel'] as unknown as Node; + assert.ok(panel.part.has('connection-stats')); assert.equal(panel.hidden, false); + assert.deepEqual(panel.children.map(node => node.tagName), ['BUTTON', 'P']); + assert.equal(element['connectionStatsUpdate'].textContent, '更新我的連線統計'); + assert.equal(element['connectionStatsUpdate'].type, 'button'); + for (const node of [panel, ...panel.children]) { assert.equal(node.getAttribute('role'), null); assert.equal(node.getAttribute('aria-live'), null); } + assert.equal(text(element), '尚未手動讀取連線統計。'); busy(element, false); + element['render'](); client['setStatus']('waiting'); emit(client, 'quality', { participants: [] }); emit(client, 'speaking', { participantIds: [] }); + await settle(); assert.equal(read.mock.callCount(), 0); assert.equal(element['quality'].hidden, true); + refresh(element); await settle(); assert.equal(read.mock.callCount(), 1); + assert.match(text(element), /上次手動讀取(SDK 取樣區間)/); assert.match(text(element), /RTT 不是端到端音訊延遲/); + }); + + it('uses actual SDK getStats units, first and shared sample deltas, and last audio direction without aggregation', async t => { + const { element, client } = await subscribedWidget(t); let calls = 0; + const pair = { id: 'pair', type: 'candidate-pair', state: 'succeeded', nominated: true, currentRoundTripTime: .04 }; + const send = { id: 'out', type: 'outbound-rtp', kind: 'audio', remoteId: 'remote' }; + const remote = { id: 'remote', type: 'remote-inbound-rtp', fractionLost: .125, roundTripTime: .09 }; + let entries = report(inbound(1000), pair, send, remote); + client['pc'] = { getStats: async () => { calls++; return entries; }, close() {} } as unknown as RTCPeerConnection; + refresh(element); await settle(); assert.equal(calls, 1); + assert.match(text(element), /接收掉包 —% · 接收 RTT 40 ms · 接收緩衝 — ms;傳送掉包 12.5% · 傳送 RTT 90 ms/); + entries = report(inbound(2000, { packetsReceived: 148, packetsLost: 4, jitterBufferDelay: 3, jitterBufferEmittedCount: 150 }), pair, send, remote); + refresh(element); await settle(); assert.match(text(element), /接收掉包 4.0% · 接收 RTT 40 ms · 接收緩衝 20 ms/); + entries = report(inbound(3000, { packetsReceived: 198, packetsLost: 4, jitterBufferDelay: 4, jitterBufferEmittedCount: 200 }), pair); + await client.getStats(); + entries = report(inbound(4000, { packetsReceived: 297, packetsLost: 5, jitterBufferDelay: 4.5, jitterBufferEmittedCount: 300 }), pair); + refresh(element); await settle(); assert.equal(calls, 4); assert.match(text(element), /接收掉包 1.0% · 接收 RTT 40 ms · 接收緩衝 5 ms/); + assert.match(text(element), /傳送:暫無統計/); + entries = report(inbound(5000, { remoteId: 'a' }), inbound(5000, { id: 'last', remoteId: 'b' }), + { id: 'a', roundTripTime: .111 }, { id: 'b', roundTripTime: .222 }, { id: 'video', type: 'inbound-rtp', kind: 'video', rtt: 99 }); + refresh(element); await settle(); assert.match(text(element), /接收 RTT 222 ms/); assert.doesNotMatch(text(element), /111|333/); + assert.equal(element['quality'].hidden, true, 'audience manual read does not invent a publisher quality report'); + }); + + it('actual SDK no peer, no audio, partial, reset and repeated counters never invent zero loss', async t => { + const { element, client } = await subscribedWidget(t); + refresh(element); await settle(); assert.match(text(element), /接收:暫無統計;傳送:暫無統計/); + let entries = report({ id: 'video', type: 'inbound-rtp', kind: 'video' }); + client['pc'] = { getStats: async () => entries, close() {} } as unknown as RTCPeerConnection; + refresh(element); await settle(); assert.match(text(element), /接收:暫無統計/); + entries = report({ id: 'in', type: 'inbound-rtp', kind: 'audio' }); + refresh(element); await settle(); assert.match(text(element), /接收掉包 —% · 接收 RTT — ms · 接收緩衝 — ms/); + for (const stamp of [1000, 1000, 500]) { + entries = report(inbound(stamp)); refresh(element); await settle(); assert.match(text(element), /接收掉包 —%/); + } + entries = report(inbound(2000, { packetsReceived: 1, packetsLost: 0, jitterBufferDelay: 0, jitterBufferEmittedCount: 1 })); + refresh(element); await settle(); assert.match(text(element), /接收掉包 —%/); + }); + + for (const role of ['audience', 'speaker', 'controller'] as const) for (const onStage of [false, true]) { + it(`allows explicit ${role} onStage=${onStage} read without mic or connected-seat gate`, async t => { + const { element, client } = await subscribedWidget(t), state = roomState(onStage); + state.me.role = role; state.me.connected = false; state.me.forceMuted = true; + await client['receive']({ type: 'room:state', ...state }); client['setStatus']('waiting'); + const read = fake(t, client, async () => stats()); refresh(element); await settle(); assert.equal(read.mock.callCount(), 1); + }); + } + + for (const invalid of [undefined, null, '1', -1, NaN, Infinity, -Infinity, {}, 101]) { + it(`validates each numeric field without coercion (${String(invalid)})`, async t => { + const { element, client } = await subscribedWidget(t); + fake(t, client, async () => ({ inbound: { lossPercent: invalid, rtt: invalid, bufferMs: invalid }, outbound: { lossPercent: invalid, rtt: invalid } })); + refresh(element); await settle(); + assert.match(text(element), /接收掉包 —%/); assert.match(text(element), /傳送掉包 —%/); + if (invalid !== 101) assert.match(text(element), /接收 RTT — ms · 接收緩衝 — ms;傳送掉包 —% · 傳送 RTT — ms/); + assert.doesNotMatch(text(element), /NaN|Infinity|\[object Object\]/); + }); + } + it('preserves zero, caps percent at100, rounds consistently and rejects seconds-to-ms overflow', async t => { + const { element, client } = await subscribedWidget(t); let result: unknown = { inbound: { lossPercent: 0, rtt: 0, bufferMs: 0 }, outbound: { lossPercent: 100, rtt: 0 } }; + fake(t, client, async () => result); refresh(element); await settle(); + assert.match(text(element), /接收掉包 0.0% · 接收 RTT 0 ms · 接收緩衝 0 ms;傳送掉包 100.0% · 傳送 RTT 0 ms/); + result = { inbound: { lossPercent: 1.26, rtt: .0126, bufferMs: 20.6 }, outbound: { lossPercent: 2.34, rtt: Number.MAX_VALUE } }; + refresh(element); await settle(); assert.match(text(element), /接收掉包 1.3% · 接收 RTT 13 ms · 接收緩衝 21 ms;傳送掉包 2.3% · 傳送 RTT — ms/); + }); + + for (const malformed of [null, undefined, false, 42, 'secret', [], { inbound: [] }, { outbound: 1 }, { inbound: () => 1 }]) { + it(`retains history on malformed result ${JSON.stringify(malformed)}`, async t => { + const { element, client } = await subscribedWidget(t); let result: unknown = stats(); fake(t, client, async () => result); + refresh(element); await settle(); const historical = text(element); result = malformed; + refresh(element); await settle(); assert.equal(text(element), historical + '本次讀取失敗,仍顯示上次手動讀取結果。'); busy(element, false); + }); + } + for (const field of ['inbound', 'outbound', 'lossPercent', 'rtt', 'bufferMs']) { + it(`uses fixed local failure on throwing ${field} getter without coercing its exception`, async t => { + const { element, client } = await subscribedWidget(t); const raw = stats(), secret = { toString() { assert.fail('no exception coercion'); } }; + const target = field === 'inbound' || field === 'outbound' ? raw : raw.inbound; + Object.defineProperty(target, field, { get() { throw secret; } }); + fake(t, client, async () => raw); refresh(element); await settle(); + assert.equal(text(element), '無法讀取連線統計,請稍後手動再試。'); assert.equal(element['error'].hidden, true); busy(element, false); + }); + } + it('reads only five metrics, copies numbers, and never renders arbitrary RTC/exception data', async t => { + const { element, client } = await subscribedWidget(t), raw = stats(); + for (const object of [raw, raw.inbound, raw.outbound]) for (const key of ['address', 'mimeType', 'jitter', 'bitrateKbps', 'packetsLost', 'timestamp', 'ssrc', 'token', 'toString']) { + Object.defineProperty(object, key, { get() { assert.fail(`unused ${key}`); } }); + } + fake(t, client, async () => raw); refresh(element); await settle(); const before = text(element); + raw.inbound.rtt = 999; raw.outbound.lossPercent = 88; element['syncConnectionStats'](); assert.equal(text(element), before); + assert.equal((before.match(/掉包| RTT \d|緩衝/g) ?? []).length, 5); + }); + + it('preserves historical result while busy, and keeps global error/quality/speaking independent on failure/retry', async t => { + const { element, client } = await subscribedWidget(t, true); const work = pending(); let count = 0; + fake(t, client, () => ++count === 1 ? Promise.resolve(stats()) : work.promise); + emit(client, 'error', new Error('Existing error')); emit(client, 'quality', { participants: [{ participantId: 'me', rttMs: 77 }] }); + emit(client, 'speaking', { participantIds: [] }); + const unaffected = [element['error'].textContent, element['error'].hidden, element['quality'].textContent, element['speaking'].textContent]; + refresh(element); await settle(); const historical = text(element); refresh(element); busy(element, true); assert.equal(text(element), historical); + refresh(element); element['connectionStatsUpdate'].disabled = false; refresh(element); assert.equal(count, 2); busy(element, true); + work.reject({ message: 'private address/token' }); await settle(); + assert.equal(text(element), historical + '本次讀取失敗,仍顯示上次手動讀取結果。'); + assert.deepEqual([element['error'].textContent, element['error'].hidden, element['quality'].textContent, element['speaking'].textContent], unaffected); + fake(t, client, async () => ({ inbound: null, outbound: null })); refresh(element); await settle(); + assert.match(text(element), /接收:暫無統計;傳送:暫無統計/); assert.doesNotMatch(text(element), /失敗|40 ms/); + }); + for (const mode of ['missing', 'noncallable', 'sync', 'reject', 'getter']) { + it(`handles ${mode} capability failure locally and permits a new explicit attempt`, async t => { + const { element, client } = await subscribedWidget(t); + Object.defineProperty(client, 'getStats', { configurable: true, ...(mode === 'getter' ? { get() { throw 'private'; } } + : { value: mode === 'missing' ? undefined : mode === 'noncallable' ? 1 : () => { if (mode === 'sync') throw 'private'; return Promise.reject('private'); } }) }); + refresh(element); await settle(); assert.equal(text(element), '無法讀取連線統計,請稍後手動再試。'); busy(element, false); + Object.defineProperty(client, 'getStats', { configurable: true, value: async () => stats() }); refresh(element); await settle(); assert.match(text(element), /40 ms/); + t.after(() => Reflect.deleteProperty(client, 'getStats')); + }); + } + + it('reserves before reentrant method lookup and before thenable assimilation', async t => { + const { element, client } = await subscribedWidget(t); let getters = 0, calls = 0, thenReads = 0; + Object.defineProperty(client, 'getStats', { configurable: true, get() { + getters++; element['connectionStatsUpdate'].disabled = false; refresh(element); + return () => { calls++; return { get then() { thenReads++; element['connectionStatsUpdate'].disabled = false; refresh(element); + return (resolve: (value: unknown) => void) => resolve(stats()); } }; }; + } }); + t.after(() => Reflect.deleteProperty(client, 'getStats')); + refresh(element); await settle(); assert.deepEqual([getters, calls, thenReads], [1, 1, 1]); busy(element, false); assert.match(text(element), /40 ms/); + }); + for (const boundary of ['getter', 'call', 'result']) { + it(`${boundary} reentry retires old token and cannot release a newer pending attempt`, async t => { + const { element, client } = await subscribedWidget(t), newer = pending(); let calls = 0, entered = false; + const replace = () => { + if (entered) return; entered = true; client['setStatus']('reconnecting'); client['setStatus']('live'); refresh(element); + }; + Object.defineProperty(client, 'getStats', { configurable: true, get() { + if (boundary === 'getter' && !entered) { replace(); return () => { assert.fail('retired getter cannot be called'); }; } + return () => { + calls++; if (entered) return newer.promise; + if (boundary === 'call') replace(); + const value = stats(); + if (boundary === 'result') Object.defineProperty(value, 'inbound', { get() { replace(); return stats().inbound; } }); + return Promise.resolve(value); + }; + } }); + t.after(() => Reflect.deleteProperty(client, 'getStats')); + refresh(element); await settle(); assert.ok(entered); assert.equal(calls, boundary === 'getter' ? 1 : 2); busy(element, true); + assert.doesNotMatch(text(element), /40 ms/); newer.resolve(stats(.123)); await settle(); busy(element, false); assert.match(text(element), /123 ms/); + }); + } + + for (const cause of ['reconnecting', 'disconnected', 'closed', 'kicked', 'room', 'participant', 'client', 'unmount', 'leave']) { + for (const outcome of ['resolve', 'reject']) { + it(`${cause} retires history and ignores late ${outcome}`, async t => { + const { element, client } = await subscribedWidget(t), old = pending(); let count = 0; + fake(t, client, () => ++count === 1 ? Promise.resolve(stats()) : old.promise); + refresh(element); await settle(); refresh(element); busy(element, true); + if (cause === 'reconnecting' || cause === 'disconnected') client['setStatus'](cause); + else if (cause === 'closed' || cause === 'kicked') emit(client, cause, { roomId: 'one' }); + else if (cause === 'room') { client.state!.roomId = 'other'; element['render'](); } + else if (cause === 'participant') { client.me!.participantId = 'other'; element['render'](); } + else if (cause === 'client') { element['current'] = null; element['render'](); } + else if (cause === 'unmount') { Object.assign(element, { isConnected: false }); element.disconnectedCallback(); } + else click(element['roomAction']); + await settle(); assert.equal(element['connectionStatsReport'], undefined); assert.equal(element['connectionStatsAttempt'], undefined); + if (outcome === 'resolve') old.resolve(stats(.999)); else old.reject(new Error('private')); + await settle(); assert.doesNotMatch(text(element), /999|40 ms|失敗/); + if (cause !== 'room' && cause !== 'participant') hidden(element); + }); + } + } + it('reconnect permits fresh explicit same-seat read without a distinct passive state and stale finally cannot unlock it', async t => { + const { element, client } = await subscribedWidget(t), old = pending(), newer = pending(); let calls = 0; + fake(t, client, () => ++calls === 1 ? old.promise : newer.promise); const originalState = client.state; + refresh(element); client['setStatus']('reconnecting'); hidden(element); client['setStatus']('live'); + assert.equal(client.state, originalState); assert.equal(calls, 1); refresh(element); busy(element, true); + old.reject('private'); await settle(); busy(element, true); assert.doesNotMatch(text(element), /失敗/); + newer.resolve(stats(.073)); await settle(); busy(element, false); assert.match(text(element), /73 ms/); + }); + for (const terminal of ['closed', 'kicked', 'disconnect']) { + it(`genuine same-client rejoin after ${terminal} admits only a fresh manual result`, async t => { + const { element, client } = await subscribedWidget(t), old = pending(); let calls = 0; + fake(t, client, () => ++calls === 1 ? old.promise : Promise.resolve(stats(.088))); refresh(element); + if (terminal === 'disconnect') await client.disconnect(); else emit(client, terminal, { roomId: 'one' }); + hidden(element); await client['receive']({ type: 'room:state', ...roomState() }); + assert.equal(calls, 1); refresh(element); await settle(); assert.equal(calls, 2); assert.match(text(element), /88 ms/); + old.resolve(stats(.999)); await settle(); assert.match(text(element), /88 ms/); assert.doesNotMatch(text(element), /999/); + }); + } + it('same-seat role/stage/mic changes preserve pending read and historical result', async t => { + const { element, client } = await subscribedWidget(t), work = pending(); const read = fake(t, client, () => work.promise); + refresh(element); const owner = element['connectionStatsOwner'], attempt = element['connectionStatsAttempt']; + for (const onStage of [true, false]) { + await client['receive']({ type: 'room:state', ...roomState(onStage) }); + emit(client, 'micerror', new Error('mic denied')); emit(client, 'micready'); emit(client, 'role', { participantId: 'me', role: 'controller' }); + emit(client, 'stageleft', { participantId: 'me' }); + assert.equal(element['connectionStatsOwner'], owner); assert.equal(element['connectionStatsAttempt'], attempt); busy(element, true); + } + work.resolve(stats()); await settle(); const before = text(element); await client['receive']({ type: 'room:state', ...roomState(true) }); + assert.equal(text(element), before); assert.equal(read.mock.callCount(), 1); + }); + + for (const kind of ['disabled', 'button-hidden', 'panel-hidden', 'button-detached', 'panel-detached', 'reparented']) { + it(`rejects retained ${kind} synthetic click`, async t => { + const { element, client } = await subscribedWidget(t), read = fake(t, client, async () => stats()); + if (kind === 'disabled') element['connectionStatsUpdate'].disabled = true; + if (kind === 'button-hidden') element['connectionStatsUpdate'].hidden = true; + if (kind === 'panel-hidden') element['connectionStatsPanel'].hidden = true; + if (kind === 'button-detached') Object.assign(element['connectionStatsUpdate'], { isConnected: false }); + if (kind === 'panel-detached') Object.assign(element['connectionStatsPanel'], { isConnected: false }); + if (kind === 'reparented') element['connectionStatsPanel'].replaceChildren(); + refresh(element); await settle(); assert.equal(read.mock.callCount(), 0); + }); + } + for (const property of ['disabled', 'textContent', 'aria-busy', 'hidden']) { + it(`revalidates ${property} setter reentry, including stale outer writes after nested recovery`, async t => { + const { element, client } = await subscribedWidget(t), newer = pending(); let reads = 0; + fake(t, client, () => { reads++; return newer.promise; }); + const reenter = () => { client['setStatus']('reconnecting'); client['setStatus']('live'); refresh(element); }; + if (property === 'aria-busy') { + const set = element['connectionStatsUpdate'].setAttribute.bind(element['connectionStatsUpdate']); let armed = true; + t.mock.method(element['connectionStatsUpdate'], 'setAttribute', (name: string, value: string) => { + if (name === 'aria-busy' && armed) { armed = false; reenter(); } set(name, value); + }); + } else if (property === 'hidden') { + client['setStatus']('reconnecting'); setter(t, element['connectionStatsPanel'], 'hidden', reenter, true); client['setStatus']('live'); + } else setter(t, element['connectionStatsUpdate'], property, reenter, true); + refresh(element); await settle(); assert.equal(reads, 1); busy(element, true); + newer.resolve(stats(.081)); await settle(); busy(element, false); assert.match(text(element), /81 ms/); + }); + } + it('repairs host-corrupted text/visibility/busy values and makes no unchanged writes to its own panel', async t => { + const { element, client } = await subscribedWidget(t); fake(t, client, async () => stats()); refresh(element); await settle(); + const before = text(element), writes: string[] = []; + for (const [node, property] of [[element['connectionStatsText'], 'textContent'], [element['connectionStatsPanel'], 'hidden'], + [element['connectionStatsUpdate'], 'textContent'], [element['connectionStatsUpdate'], 'disabled']] as const) { + let value = Reflect.get(node, property); Object.defineProperty(node, property, { configurable: true, get: () => value, set: next => { value = next; writes.push(property); } }); + t.after(() => Object.defineProperty(node, property, { configurable: true, writable: true, value })); + } + const attributes = t.mock.method(element['connectionStatsUpdate'], 'setAttribute'); + element['syncConnectionStats'](); element['syncConnectionStats'](); assert.deepEqual(writes, []); assert.equal(attributes.mock.callCount(), 0); + element['connectionStatsText'].textContent = 'host'; element['connectionStatsPanel'].hidden = true; + element['connectionStatsUpdate'].textContent = 'host'; element['connectionStatsUpdate'].disabled = true; + element['connectionStatsUpdate'].setAttribute('aria-busy', 'true'); element['syncConnectionStats'](); + assert.equal(text(element), before); assert.equal(element['connectionStatsPanel'].hidden, false); busy(element, false); + }); + + for (const action of ['close', 'transfer']) for (const lose of [false, true]) { + it(`${action} consent ${lose ? 'is retired before corrective stats DOM can restore silent reconnect' : 'survives ordinary read'}`, async t => { + const { element, client } = await controller(t); + const close = t.mock.method(client, 'closeRoom', async () => {}), transfer = t.mock.method(client, 'transferControl', async () => {}); + Object.defineProperty(client, 'getStats', { configurable: true, get() { + if (lose) client['currentStatus'] = 'reconnecting'; return async () => stats(); + } }); + t.after(() => Reflect.deleteProperty(client, 'getStats')); + if (lose) setter(t, element['connectionStatsPanel'], 'hidden', () => { client['currentStatus'] = 'live'; }); + t.mock.method(window, 'confirm', () => { refresh(element); return true; }); + click(action === 'close' ? element['roomClose'] : element['stageManagementRows'].get('target')!.transfer); await settle(); + assert.equal(close.mock.callCount(), !lose && action === 'close' ? 1 : 0); + assert.equal(transfer.mock.callCount(), !lose && action === 'transfer' ? 1 : 0); + }); + } + for (const cause of ['leave', 'unmount', 'rejoin']) { + it(`${cause} fences diagnostics before existing teardown DOM can reclick a retained button`, async t => { + const { element, client } = await subscribedWidget(t), read = fake(t, client, async () => stats()); + const node = cause === 'leave' ? element['error'] : element['nameInput']; + setter(t, node, cause === 'leave' ? 'hidden' : 'value', () => refresh(element)); + if (cause === 'leave') click(element['roomAction']); + else if (cause === 'unmount') element.disconnectedCallback(); + else element.setAttribute('name', 'Replacement'); + await settle(); assert.equal(read.mock.callCount(), 0); + }); + } + it('actual retained peer read may advance SDK history after reconnect, but cannot repaint retired UI', async t => { + const { element, client } = await subscribedWidget(t), old = pending(); let calls = 0; + let entries = report(inbound(1000)); + client['pc'] = { close() {}, getStats: () => ++calls === 2 ? old.promise : Promise.resolve(entries) } as unknown as RTCPeerConnection; + refresh(element); await settle(); refresh(element); client['setStatus']('reconnecting'); client['setStatus']('live'); + old.resolve(report(inbound(2000, { packetsReceived: 198, packetsLost: 4 }))); await settle(); + assert.doesNotMatch(text(element), /2.0%/); assert.equal(calls, 2); + entries = report(inbound(3000, { packetsReceived: 297, packetsLost: 5 })); refresh(element); await settle(); + assert.match(text(element), /接收掉包 1.0%/); assert.equal(calls, 3); + }); + it('actual SDK read adds no timer, command, permission, capture, playback or negotiation operation', async t => { + const { element, client } = await subscribedWidget(t); let calls = 0; + client['pc'] = { getStats: async () => { calls++; return report(inbound(1000)); }, close() {} } as unknown as RTCPeerConnection; + const request = t.mock.method(client as unknown as { request(): never }, 'request', () => assert.fail('no request')); + const capture = t.mock.method(client as unknown as { acquireMic(): never }, 'acquireMic', () => assert.fail('no capture')); + const negotiate = t.mock.method(client as unknown as { negotiate(): never }, 'negotiate', () => assert.fail('no negotiation')); + const play = t.mock.method(client['audio'], 'play'), pause = t.mock.method(client['audio'], 'pause'); + const timeout = t.mock.method(globalThis, 'setTimeout'), interval = t.mock.method(globalThis, 'setInterval'); + refresh(element); await settle(); assert.equal(calls, 1); + for (const spy of [request, capture, negotiate, play, pause, timeout, interval]) assert.equal(spy.mock.callCount(), 0); + }); + for (const result of ['resolve', 'reject']) { + it(`actual SDK ${result} from replaced peer cannot affect new widget report or baseline with reused RTP ID`, async t => { + const { element, client } = await subscribedWidget(t), old = pending(); + client['pc'] = { getStats: () => old.promise, close() {} } as unknown as RTCPeerConnection; + refresh(element); await client.disconnect(); await client['receive']({ type: 'room:state', ...roomState() }); + let entries = report(inbound(3000, { packetsReceived: 300, packetsLost: 3 })); + client['pc'] = { getStats: async () => entries, close() {} } as unknown as RTCPeerConnection; + refresh(element); await settle(); const newer = text(element); + if (result === 'resolve') old.resolve(report(inbound(9000, { packetsReceived: 9000, packetsLost: 90 }))); + else old.reject('private old native failure'); + await settle(); assert.equal(text(element), newer); busy(element, false); + entries = report(inbound(4000, { packetsReceived: 399, packetsLost: 4 })); refresh(element); await settle(); + assert.match(text(element), /接收掉包 1.0%/); + }); + } + it('actual SDK newer external completion suppresses older pending widget data without inventing a failure', async t => { + const { element, client } = await subscribedWidget(t), old = pending(); let calls = 0; + client['pc'] = { getStats: () => ++calls === 1 ? old.promise : Promise.resolve(report(inbound(2000))), close() {} } as unknown as RTCPeerConnection; + refresh(element); await client.getStats(); old.resolve(report(inbound(1000))); await settle(); + assert.match(text(element), /接收:暫無統計;傳送:暫無統計/); assert.doesNotMatch(text(element), /失敗/); busy(element, false); + }); + for (const boundary of ['result getter', 'result setter']) { + it(`${boundary} reconciles nested newer attempt followed by silent loss, even after an outer stale write`, async t => { + const { element, client } = await subscribedWidget(t), newer = pending(); let calls = 0; + const replaceThenLose = () => { + client['setStatus']('reconnecting'); client['setStatus']('live'); refresh(element); + client['currentStatus'] = 'disconnected'; + }; + fake(t, client, () => { + if (++calls > 1) return newer.promise; + const result = stats(); + if (boundary === 'result getter') Object.defineProperty(result, 'inbound', { get() { replaceThenLose(); return stats().inbound; } }); + return Promise.resolve(result); + }); + if (boundary === 'result setter') setter(t, element['connectionStatsText'], 'textContent', replaceThenLose, true); + refresh(element); await settle(); assert.equal(calls, 2); hidden(element); + newer.resolve(stats(.999)); await settle(); hidden(element); + client['setStatus']('live'); assert.equal(text(element), '尚未手動讀取連線統計。'); assert.equal(calls, 2); + }); + } + for (const terminal of ['closed', 'kicked']) { + it(`${terminal} outer getter preserves a genuinely new same-client entry and explicit read`, async t => { + const { element, client } = await subscribedWidget(t), read = fake(t, client, async () => stats(.064)); + const get = Object.getOwnPropertyDescriptor(StageClient.prototype, 'state')!.get!; let armed = true; + Object.defineProperty(client, 'state', { configurable: true, get() { + const value: unknown = get.call(client); + if (armed) { armed = false; void client['receive']({ type: 'room:state', ...roomState() }); refresh(element); } + return value; + } }); + t.after(() => Reflect.deleteProperty(client, 'state')); + emit(client, terminal, { roomId: 'one' }); await settle(); + assert.equal(read.mock.callCount(), 1); assert.equal(element['connectionStatsPanel'].hidden, false); assert.match(text(element), /64 ms/); + }); + } + for (const cause of ['leave', 'unmount', 'rejoin']) for (const action of ['close', 'transfer']) { + it(`${cause} defers stats DOM until current=null so an early host reclick cannot admit ${action}`, async t => { + const { element, client } = await controller(t), row = element['stageManagementRows'].get('target')!; + const close = t.mock.method(client, 'closeRoom', async () => {}), transfer = t.mock.method(client, 'transferControl', async () => {}); + const read = fake(t, client, async () => stats()); t.mock.method(window, 'confirm', () => true); + let earlyPaints = 0; + setter(t, element['connectionStatsPanel'], 'hidden', () => { + if (element.client !== client) return; earlyPaints++; + const button = action === 'close' ? element['roomClose'] : row.transfer; + button.disabled = false; click(button); + }); + setter(t, cause === 'leave' ? element['error'] : element['nameInput'], cause === 'leave' ? 'hidden' : 'value', () => { + refresh(element); element['syncConnectionStats'](); + }); + if (cause === 'leave') click(element['roomAction']); + else if (cause === 'unmount') element.disconnectedCallback(); + else element.setAttribute('name', 'Replacement'); + await settle(); assert.equal(earlyPaints, 0); assert.equal(read.mock.callCount(), 0); + assert.equal(close.mock.callCount(), 0); assert.equal(transfer.mock.callCount(), 0); + }); + } +}); From 26643a861817cc75dbd4b4396c981a4c7889729a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=A5=E8=AA=9E=E3=81=BB=E3=81=97=E3=81=9E=E3=82=89?= <92282870+YueyuHoshizora@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:48:39 +0800 Subject: [PATCH 10/38] feat(embed): add manual own connection statistics (batch 204) --- PLAN.md | 3 + README.md | 2 + test/fixtures/embedApp.ts | 3 +- test/fixtures/embedSdkApp.ts | 3 +- test/webEmbedConnectionStats.test.ts | 566 +++++++++++++++++++++++++++ test/webEmbedEntry.test.ts | 3 +- test/webEmbedMicrophone.test.ts | 3 +- test/webEmbedWsPath.test.ts | 3 +- web/embed.html | 2 +- web/src/embed.ts | 213 ++++++++++ 10 files changed, 795 insertions(+), 6 deletions(-) create mode 100644 test/webEmbedConnectionStats.test.ts diff --git a/PLAN.md b/PLAN.md index 3f202fd..bde34f7 100644 --- a/PLAN.md +++ b/PLAN.md @@ -847,3 +847,6 @@ N 路混音管線(decode→mix→encode)、不含自己的台上混音、lim 224. **Widget 手動本人連線統計(第203批,2026-10-16):** immutable202 完整 widget/實際 SDK 證明既有 getStats 無 widget 入口且觀眾不具 publisher 品質摘要。新增獨立 connection-stats part、明確按鈕與普通文字,所有有效席位可單次讀取 receive loss/RTT/buffer 與 send loss/RTT;無輪詢、timer、自動讀取/重試、控制命令、媒體或權限操作。沿用 SDK 每方向最後符合 RTP 與共用差值基準,標示上次手動結果/SDK 取樣區間,非全串流聚合、按鈕間隔或端到端音訊延遲。只保存有限數值 primitive,缺失/非法 metric 為破折號,缺少方向為暫無;畸形 envelope/direction 或 throwing getter 固定局部失敗,保留歷史,不寫全域錯誤/quality/speaking。方法 getter 前 reserve attempt,lookup/call/settlement/projection/每個新 DOM setter 後核對 owner/revision/status/seat,拒 hidden/disabled/detached controls、舊 promise 與 stale finally。重連/終止/離房/卸載/換席先純退役;新的明確 click 可在有效席位恢復後讀取,不需 passive distinct state;普通角色/台位/mic 變更保留。native promise 不可取消,retained peer 遲到完成可能推進共用 SDK 基準,不改 SDK。Close/Transfer 權限先於修正 DOM;獨立重現 Leave/rejoin legacy setter 重入,新增僅本功能 departure admission/paint fence,直到既有 teardown 完成,不廣泛改生命週期或原權限政策。五檔 element 源碼/測試+三文件;新增100例,原測試完整保留,actual SDK 取樣/單位/共享基準與 lifecycle/consent/負面控制納入驗證。無 SDK/server/media/protocol/config/依賴變更;原生瀏覽器/鍵盤/AT/硬體音訊未驗收。 + + +225. **最小 embed 手動本人連線統計(第204批,2026-10-16):** immutable203 完整 embed/實際 SDK 證明缺少手動 getStats 入口,觀眾無 publisher 品質回報但可讀自身 peer 統計。新增獨立普通面板與明確按鈕,僅 receive loss/RTT/buffer、send loss/RTT 五指標;零保留、非法/缺失 metric 為破折號、方向缺少為暫無、畸形 envelope/direction 或 throwing getter 固定局部失敗。只存有限 primitive;不顯示 raw/codec/counter/其他人資訊,不改全域 error/quality/speaking。標示上次手動歷史與 SDK 取樣區間,last matching audio RTP 非 aggregate,其他呼叫者共用基準,RTT 非端到端/可聽保證。stats-only captured client/generation controls binding 在新 client 綁定時建立;join/retireEntry/retireControls/disconnected 先純退役,不能把舊 controls 套上新 entry 世代。reconnect 保留 binding 但清 owner/attempt/history,之後有效席位明確 click 可讀,不需新 passive state;disconnected controls 不自行復活。room/participant 更換隔離舊結果,同席 role/stage/mic 變更保留。方法 getter 前 reserve,lookup/call/settle/projection/每個新 DOM setter 後核對 token/binding/owner,舊 native promise 不能解鎖或覆寫新 entry;不宣稱取消其 SDK 取樣或共用基準更新。fixed DOM 用 exact document query identity+panel containment 防 detached/replaced action;departure 純階段不新 paint,Close/Transfer consent 核對先於新/修正 DOM,nested 新 winner 與 silent loss 都依當前 owner 調和,不廣修既有生命週期。十檔限定 HTML/embed source/新測試/五 fixture map 精確新增節點與 README/PLAN,原 assertions 保留;無 SDK/server/widget/media/protocol/config/依賴變更。無 timer/poll/auto read/retry/timeout/新媒體或權限操作;native browser/AT/硬體音訊未驗收。 diff --git a/README.md b/README.md index 224d289..092d500 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,8 @@ SDK 的應用層 keepalive 會與控制請求共用送出間隔,避免合法 最小 embed 也顯示房間席位快照:主控以外總人數/房間上限、其中台上人數/台位上限、觀眾數與另計的主控台上/台下位置。房間上限包含一般觀眾與講者,台位上限不含主控;舉手不另計、斷線保留席位仍包含,因此不是在線/收聽人數或即時入場保證。只有目前進房流程的新權威 state 更新數字;重連保留同席位上次快照,單獨 status 恢復不標新資料。取消、離房、斷線、終止或替換 entry 清空,舊client的晚到狀態/終止事件不覆寫新加入。僅保存驗證後數量、上限與位置,不讀私有名單/代碼/token,不新增輪詢、命令或媒體操作;無效投影僅隱藏摘要。原生版面、鍵盤/AT與實體音訊尚未驗收。 +最小 embed 另提供「更新我的連線統計」,所有有效已進房席位(含觀眾)都能明確讀取一次本人瀏覽器的接收掉包率/RTT/緩衝與傳送掉包率/RTT。只沿用既有 `getStats()`,不輪詢、不自動呼叫,也不改品質回報或音訊。每方向取 SDK 最後符合的音訊 RTP,非全部串流加總;數字標示為上次手動讀取與 SDK 取樣區間,其他呼叫者共用差值基準,因此不是按鈕間隔,RTT 不是端到端音訊延遲或可聽保證。0 保留,缺失/非法指標為「—」,缺少方向為暫無統計。等待與失敗保留歷史,固定失敗提示只在此區顯示,不覆寫全域錯誤。重連清除舊結果;有效席位恢復後可手動再讀,status 本身不讀取。離房、取消、斷線、終止與新進房流程退役舊操作;既有 embed 斷線後 controls 不自行恢復,重新送出進房才建立新操作入口。不同 client 的統計不共用頁面 audio 裝置操作的等待鎖,舊 promise 不能回寫新畫面;原生讀取無取消能力,保留 peer 的遲到完成仍可能推進 SDK 差值基準。普通角色/台位/麥克風變化不抹去同席歷史,不新增權限、擷取、播放、命令或 live region。原生版面、鍵盤/AT 與實體音訊尚未驗收。 + 最小 embed 的台上使用者可查看「最近收到」的本人連線品質:上行/下行掉包與 RTT;RTT 不是端到端音訊延遲。資料只來自既有伺服器品質回報,不另輪詢或操作媒體。缺少或非法欄位顯示「—」,沒有本人回報便隱藏;不顯示其他人的數值,台下主控也不借用別人的回報。重連、本人下台、離房或替換進房流程會清除,重新取得權威台上快照後,仍須再收到品質回報才顯示。相同席位的麥克風失敗/準備完成、靜音或重試不抹除已收到的傳輸歷史,也不產生新數字。回報沒有量測時間或媒體世代,因此「最近收到」不代表最新量測、音訊已恢復或可聽;原生 layout、鍵盤/AT 與實體音訊未驗收。 最小 embed 的所有房內角色也可查看「最近收到的發言回報」:依目前公開台上名單顯示名稱與人數,觀眾與台下主控同樣可看,斷線寬限內仍在台上的成員不因控制連線離線而被濾除。尚未收到報告、明確空回報,以及非空回報但沒有可對應台上成員會分開顯示;未知/離台 ID 不顯示或留待未來自動復活。下台/轉觀眾通知先移除該 ID,直到新的權威快照核對前,新回報也不借用舊台上名單;重新出現仍需後續新回報。重連、離房或替換進房流程清空,status 恢復與 retained speaking getter 不當新資料。名稱只取公開名單並安全顯示文字,不讀私有觀眾/代碼/token;相同名稱的不同成員仍各計一人。這是歷史偵測回報,不保證此刻發言、可聽播放、麥克風恢復或零音訊;不增加輪詢、計時器或媒體操作,原生瀏覽器/鍵盤/AT 與硬體音訊未驗收。 diff --git a/test/fixtures/embedApp.ts b/test/fixtures/embedApp.ts index 33fa0f5..7d4e12e 100644 --- a/test/fixtures/embedApp.ts +++ b/test/fixtures/embedApp.ts @@ -161,8 +161,9 @@ export class EmbedSdk { export const flush = async (): Promise => { for (let i = 0; i < 16; i++) await Promise.resolve(); }; export function embedApp(options: { libraryLoad?: Promise | ((url: string, attempt: number) => Promise); source?: string; search?: string; maxGainDb?: unknown; confirm?: ((message: string) => boolean) | null } = {}) { - const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'speaking-summary', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); + const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'speaking-summary', 'connection-stats-panel', 'connection-stats-update', 'connection-stats-text', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); const node = (id: string): EmbedNode => { const found = nodes.get(id); assert.ok(found, `unmodeled node: ${id}`); return found; }; + node('connection-stats-panel').append(node('connection-stats-update'), node('connection-stats-text')); node('error-notice').hidden = true; node('hand-rejected').hidden = true; node('room-occupancy').hidden = true; node('own-connection-quality').hidden = true; node('speaking-summary').hidden = true; const cancel = node('cancel-join'); cancel.hidden = cancel.disabled = true; cancel.textContent = '取消連線'; const hand = node('hand'), mic = node('mic'), stage = node('stage'), micRetry = node('mic-retry'), leave = node('leave'), form = node('join'), status = node('status'); diff --git a/test/fixtures/embedSdkApp.ts b/test/fixtures/embedSdkApp.ts index c004db3..a587d73 100644 --- a/test/fixtures/embedSdkApp.ts +++ b/test/fixtures/embedSdkApp.ts @@ -107,8 +107,9 @@ export async function actualApp(t: TestContext, options: { deferCapture?: boolea const originals = new Map(Object.keys(globals).map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value }); t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); let now = 1000; t.mock.method(performance, 'now', () => now); - const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'speaking-summary', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); + const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'speaking-summary', 'connection-stats-panel', 'connection-stats-update', 'connection-stats-text', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); const node = (id: string): EmbedNode => { const found = nodes.get(id); assert.ok(found, id); return found; }; + node('connection-stats-panel').append(node('connection-stats-update'), node('connection-stats-text')); node('error-notice').hidden = true; node('hand-rejected').hidden = true; node('room-occupancy').hidden = true; node('own-connection-quality').hidden = true; node('speaking-summary').hidden = true; const cancel = node('cancel-join'); cancel.hidden = cancel.disabled = true; cancel.textContent = '取消連線'; const form = node('join'), stage = node('stage'), status = node('status'), mic = node('mic'), micRetry = node('mic-retry'), leave = node('leave'); diff --git a/test/webEmbedConnectionStats.test.ts b/test/webEmbedConnectionStats.test.ts new file mode 100644 index 0000000..ca4c1e4 --- /dev/null +++ b/test/webEmbedConnectionStats.test.ts @@ -0,0 +1,566 @@ +/// +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { it, type TestContext } from 'node:test'; +import { StageClient } from '../packages/client/src/index.ts'; +import type { ParticipantView, RoomStatePayload } from '../shared/protocol.ts'; +import { EmbedNode, embedApp, flush } from './fixtures/embedApp.ts'; +import { actualApp, actualState, StagePeer, StageSocket, StageContext } from './fixtures/embedSdkApp.ts'; + +// Execute the complete production entry with the real SDK; replace only browser/external-host boundaries. +type App = Awaited>; +type Entry = Record; +const INITIAL = '尚未手動讀取連線統計。'; +const FAILED = '無法讀取連線統計,請稍後手動再試。'; +const RETAINED = '本次讀取失敗,仍顯示上次手動讀取結果。'; +const report = (...entries: Entry[]) => new Map(entries.map(entry => [String(entry.id), entry])) as unknown as RTCStatsReport; +const inbound = (timestamp = 1000, received = 100, lost = 0, delay = 2, emitted = 100): Entry => + ({ id: 'in', type: 'inbound-rtp', kind: 'audio', timestamp, bytesReceived: timestamp, + packetsReceived: received, packetsLost: lost, jitterBufferDelay: delay, jitterBufferEmittedCount: emitted }); +const pair = { id: 'pair', type: 'candidate-pair', nominated: true, state: 'succeeded', currentRoundTripTime: .043 }; +const outbound = { id: 'out', type: 'outbound-rtp', kind: 'audio', timestamp: 1000, bytesSent: 300, remoteId: 'remote' }; +const remote = { id: 'remote', type: 'remote-inbound-rtp', fractionLost: .125, roundTripTime: .1 }; +const stats = (rtt = .04) => ({ inbound: { lossPercent: 4, rtt, bufferMs: 20 }, outbound: { lossPercent: 12.5, rtt: .09 } }); +const text = (h: App) => h.node('connection-stats-text').textContent; +const refresh = (h: App) => h.node('connection-stats-update').invoke(); +function state(role: ParticipantView['role'] = 'audience', onStage = false): RoomStatePayload { + const s = actualState({ role, onStage }); s.controllerId = role === 'controller' ? s.me.participantId : 'host'; + const controller = { ...s.me, participantId: s.controllerId, role: 'controller' as const, onStage: true }; + s.speakers = role === 'controller' ? (onStage ? [{ ...s.me }] : []) : [controller, ...(onStage ? [{ ...s.me }] : [])]; + s.audienceCount = role === 'audience' && !onStage ? 1 : 0; return s; +} +function hello(socket: StageSocket) { + socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); +} +async function join(h: App, s = state()) { + h.form.submit(); await flush(); const socket = StageSocket.instances.at(-1)!; hello(socket); + await h.until(() => socket.sent.some(m => m.type === 'join'), 'join request'); + const request = socket.sent.find(m => m.type === 'join')!; + socket.deliver({ type: 'room:state', ...s }); socket.deliver({ type: 'ok', requestId: request.requestId }); + await h.until(() => StagePeer.instances.at(-1)?.localDescription !== null && StagePeer.instances.at(-1)?.signalingState === 'stable', 'negotiation'); + await h.tick(20); return { socket, peer: StagePeer.instances.at(-1)!, client: h.clients.at(-1)! as StageClient }; +} +async function setup(t: TestContext, s = state(), options: Parameters[1] = {}) { + const h = await actualApp(t, options); return { h, ...await join(h, s) }; +} +function samples(peer: StagePeer, get: () => Promise) { Object.defineProperty(peer, 'getStats', { configurable: true, value: get }); } +function fake(client: StageClient, get: () => unknown) { Object.defineProperty(client, 'getStats', { configurable: true, value: get }); } +function busy(h: App, value: boolean): void { + assert.equal(h.node('connection-stats-update').disabled, value); + assert.equal(h.node('connection-stats-update').getAttribute('aria-busy'), String(value)); +} +function hidden(h: App): void { + assert.equal(h.node('connection-stats-panel').hidden, true); assert.equal(text(h), ''); + assert.equal(h.node('connection-stats-update').disabled, true); + assert.equal(h.node('connection-stats-update').getAttribute('aria-busy'), 'false'); +} +function media(h: App) { + return { captures: h.captures.length, plays: h.audio.plays, pauses: h.audio.pauses, peers: StagePeer.instances.length, + contexts: StageContext.instances.length, enumerations: h.enumerations.length, sinks: h.sinks.length, + stops: h.tracks.map(track => track.stops), offers: StagePeer.instances.map(peer => peer.offers.length), + replacements: StagePeer.instances.map(peer => peer.replacements.length) }; +} +function unaffected(h: App) { + return ['status', 'room-occupancy', 'own-connection-quality', 'speaking-summary', 'last-error', 'error-notice', + 'close-room', 'stage-management-list', 'mic-retry', 'audio-retry-status'].map(id => { + const n = h.node(id); return [id, n.textContent, n.hidden, n.disabled, { ...n.attributes }]; + }); +} +function setter(node: EmbedNode, property: 'hidden' | 'disabled' | 'textContent', action: () => void, late = false) { + let value: unknown = node[property], armed = true; + Object.defineProperty(node, property, { configurable: true, get: () => value, set(next: unknown) { + if (!late) value = next; + if (armed) { armed = false; action(); } + if (late) value = next; + } }); +} +function transfer(h: App): EmbedNode { + const button = h.node('stage-management-list').children[0]?.children[1]?.children[2]; assert.ok(button); return button; +} +async function controller(t: TestContext, options: Parameters[1] = {}) { + const s = state('controller'); s.speakers.push({ ...s.me, participantId: 'target', role: 'speaker', onStage: true }); + return setup(t, s, options); +} + +it('embed authors one ordinary independent panel and explicit native button, without an automatic read', async t => { + const html = readFileSync(new URL('../web/embed.html', import.meta.url), 'utf8'); + for (const id of ['connection-stats-panel', 'connection-stats-update', 'connection-stats-text']) assert.equal(html.match(new RegExp(`id="${id}"`, 'g'))?.length, 1); + const authored = html.match(/
/)![0]; + assert.match(authored, /]*type="button"[^>]*disabled>更新我的連線統計<\/button>/); + assert.match(authored, /aria-describedby="connection-stats-text"/); assert.doesNotMatch(authored, /role=|aria-live=|innerHTML/); + const { h, client, peer } = await setup(t); let reads = 0; samples(peer, async () => { reads++; return report(inbound()); }); + assert.equal(text(h), INITIAL); busy(h, false); + for (let i = 0; i < 4; i++) { + client.dispatchEvent(new CustomEvent('state', { detail: client.state })); + client.dispatchEvent(new CustomEvent('quality', { detail: { participants: [] } })); + client.dispatchEvent(new CustomEvent('speaking', { detail: { participantIds: [] } })); + client['setStatus']('waiting'); client['setStatus']('live'); await h.tick(200); + } + assert.equal(reads, 0); assert.equal(text(h), INITIAL); refresh(h); await flush(); assert.equal(reads, 1); +}); +for (const role of ['audience', 'speaker', 'controller'] as const) for (const onStage of [false, true]) + for (const active of ['waiting', 'live'] as const) it(`actual SDK manual ${role}/${onStage}/${active} read adds no commands, timers, media or old UI changes`, async t => { + const s = state(role, onStage); s.me.connected = false; s.me.muted = s.me.forceMuted = true; + const { h, client, peer, socket } = await setup(t, s); client['setStatus'](active); let calls = 0; + samples(peer, async () => { calls++; return report(inbound(), pair, ...(onStage ? [outbound, remote] : [])); }); + const oldUi = unaffected(h), oldMedia = media(h), frames = [...socket.sent]; + const timeout = t.mock.method(globalThis, 'setTimeout'), interval = t.mock.method(globalThis, 'setInterval'); + refresh(h); await flush(); assert.equal(calls, 1); assert.match(text(h), /接收掉包 —% · 接收 RTT 43 ms · 接收緩衝 — ms/); + assert.match(text(h), onStage ? /傳送掉包 12.5% · 傳送 RTT 100 ms/ : /傳送:暫無統計/); + assert.match(text(h), /上次手動讀取(SDK 取樣區間)/); assert.match(text(h), /RTT 不是端到端音訊延遲/); + assert.deepEqual(unaffected(h), oldUi); assert.deepEqual(media(h), oldMedia); assert.deepEqual(socket.sent, frames); + assert.equal(timeout.mock.callCount(), 0); assert.equal(interval.mock.callCount(), 0); + }); +it('actual SDK delta units use shared sampling baseline and last matching audio RTP, never aggregate streams', async t => { + const { h, peer, client } = await setup(t); let entries = report(inbound(), pair); samples(peer, async () => entries); + refresh(h); await flush(); + entries = report(inbound(2000, 119, 1, 2.4, 120), pair); refresh(h); await flush(); + assert.match(text(h), /接收掉包 5.0% · 接收 RTT 43 ms · 接收緩衝 20 ms/); + entries = report(inbound(3000, 219, 1, 4.4, 220), pair); await client.getStats(); + entries = report(inbound(4000, 318, 2, 4.9, 320), pair); refresh(h); await flush(); + assert.match(text(h), /接收掉包 1.0% · 接收 RTT 43 ms · 接收緩衝 5 ms/); + entries = report(inbound(5000), { ...inbound(5000), id: 'last-in', remoteId: 'last-remote' }, pair, + { id: 'last-remote', type: 'remote-outbound-rtp', roundTripTime: .222 }, outbound, remote, + { ...outbound, id: 'last-out', remoteId: 'last-out-remote' }, { ...remote, id: 'last-out-remote', fractionLost: .5 }, + { id: 'video', type: 'inbound-rtp', kind: 'video' }); + refresh(h); await flush(); assert.match(text(h), /接收 RTT 222 ms/); assert.match(text(h), /傳送掉包 50.0%/); +}); +it('actual SDK missing audio, partial and repeated/reset counters never invent zero loss', async t => { + const { h, peer } = await setup(t); let entries = report(); samples(peer, async () => entries); + refresh(h); await flush(); assert.match(text(h), /接收:暫無統計;傳送:暫無統計/); + entries = report({ id: 'video', type: 'inbound-rtp', kind: 'video' }); refresh(h); await flush(); assert.match(text(h), /接收:暫無統計/); + entries = report({ id: 'in', type: 'inbound-rtp', kind: 'audio' }); refresh(h); await flush(); assert.match(text(h), /接收掉包 —% · 接收 RTT — ms · 接收緩衝 — ms/); + for (const stamp of [1000, 1000, 500]) { entries = report(inbound(stamp)); refresh(h); await flush(); assert.match(text(h), /接收掉包 —%/); } + entries = report(inbound(2000, 1, 0, 0, 1)); refresh(h); await flush(); assert.match(text(h), /接收掉包 —%/); +}); +it('actual state before ACK and peer creation permits an explicitly empty manual result', async t => { + const h = await actualApp(t); h.form.submit(); await flush(); const socket = StageSocket.instances.at(-1)!; hello(socket); + await h.until(() => socket.sent.some(m => m.type === 'join'), 'join'); + const client = h.clients[0] as StageClient; + client.on('state', () => { assert.equal(StagePeer.instances.length, 0); refresh(h); }); + socket.deliver({ type: 'room:state', ...state() }); + await flush(); assert.match(text(h), /接收:暫無統計;傳送:暫無統計/); + socket.deliver({ type: 'ok', requestId: socket.sent.find(m => m.type === 'join')!.requestId }); await h.tick(20); +}); +for (const invalid of [undefined, null, '1', -1, NaN, Infinity, -Infinity, {}, new Number(1), 101]) + it(`projects only numeric primitives without coercion: ${String(invalid)}`, async t => { + const { h, client } = await setup(t); + fake(client, async () => ({ inbound: { lossPercent: invalid, rtt: invalid, bufferMs: invalid }, outbound: { lossPercent: invalid, rtt: invalid } })); + refresh(h); await flush(); assert.match(text(h), /接收掉包 —%/); assert.match(text(h), /傳送掉包 —%/); + if (invalid !== 101) assert.match(text(h), /接收 RTT — ms · 接收緩衝 — ms;傳送掉包 —% · 傳送 RTT — ms/); + assert.doesNotMatch(text(h), /NaN|Infinity|\[object|失敗/); + }); +it('preserves zero, rounds bounded percentages and milliseconds, and rejects conversion overflow', async t => { + const { h, client } = await setup(t); let raw: unknown = { inbound: { lossPercent: 0, rtt: 0, bufferMs: 0 }, outbound: { lossPercent: 100, rtt: 0 } }; + fake(client, async () => raw); refresh(h); await flush(); assert.match(text(h), /接收掉包 0.0% · 接收 RTT 0 ms · 接收緩衝 0 ms;傳送掉包 100.0% · 傳送 RTT 0 ms/); + raw = { inbound: { lossPercent: 1.26, rtt: .0126, bufferMs: 20.6 }, outbound: { lossPercent: 2.34, rtt: Number.MAX_VALUE } }; + refresh(h); await flush(); assert.match(text(h), /接收掉包 1.3% · 接收 RTT 13 ms · 接收緩衝 21 ms;傳送掉包 2.3% · 傳送 RTT — ms/); +}); +for (const malformed of [null, undefined, false, 42, 'private', [], { inbound: [] }, { outbound: 1 }, { inbound: () => 1 }]) + it(`malformed envelope/direction retains historical result: ${JSON.stringify(malformed)}`, async t => { + const { h, client } = await setup(t); let raw: unknown = stats(); fake(client, async () => raw); refresh(h); await flush(); + const historical = text(h); raw = malformed; refresh(h); await flush(); assert.equal(text(h), historical + RETAINED); busy(h, false); + }); +for (const [direction, field] of [['root', 'inbound'], ['root', 'outbound'], ['inbound', 'lossPercent'], ['inbound', 'rtt'], ['inbound', 'bufferMs'], ['outbound', 'lossPercent'], ['outbound', 'rtt']] as const) + it(`throwing used getter ${direction}.${field} produces only fixed local feedback`, async t => { + const { h, client } = await setup(t), raw = stats(), secret = { toString() { assert.fail('exception must not be coerced'); } }; + Object.defineProperty(direction === 'root' ? raw : raw[direction], field, { get() { throw secret; } }); + const old = unaffected(h); fake(client, async () => raw); refresh(h); await flush(); assert.equal(text(h), FAILED); assert.deepEqual(unaffected(h), old); busy(h, false); + }); +it('copies only five metrics, never reads RTC raw/private fields, and ignores later result mutation', async t => { + const { h, client } = await setup(t), raw = stats(); + for (const object of [raw, raw.inbound, raw.outbound]) for (const key of ['address', 'mimeType', 'jitter', 'bitrateKbps', 'packetsLost', 'timestamp', 'ssrc', 'token', 'toString']) + Object.defineProperty(object, key, { get() { assert.fail(`unused ${key}`); } }); + fake(client, async () => raw); refresh(h); await flush(); const historical = text(h); + raw.inbound.rtt = 999; raw.outbound.lossPercent = 88; client['setStatus']('waiting'); assert.equal(text(h), historical); + assert.equal((historical.match(/掉包| RTT \d|緩衝/g) ?? []).length, 5); +}); +it('busy and failure retain history; successful empty read replaces it without changing old error/quality/speaking', async t => { + const { h, client, socket } = await setup(t, state('speaker', true)); const work = Promise.withResolvers(); let calls = 0; + fake(client, () => ++calls === 1 ? Promise.resolve(stats()) : work.promise); + socket.deliver({ type: 'error', code: 'conflict', message: '既有操作失敗。' }); + socket.deliver({ type: 'quality', participants: [{ participantId: 'guest', rttMs: 77 }] }); socket.deliver({ type: 'speaking', participantIds: [] }); + const old = unaffected(h); refresh(h); await flush(); const historical = text(h); refresh(h); busy(h, true); assert.equal(text(h), historical); + refresh(h); h.node('connection-stats-update').disabled = false; refresh(h); assert.equal(calls, 2); busy(h, true); + work.reject({ message: 'PRIVATE_ADDRESS_TOKEN' }); await flush(); assert.equal(text(h), historical + RETAINED); assert.deepEqual(unaffected(h), old); + fake(client, async () => ({})); refresh(h); await flush(); assert.match(text(h), /接收:暫無統計;傳送:暫無統計/); assert.doesNotMatch(text(h), /失敗|40 ms/); +}); +for (const mode of ['missing', 'noncallable', 'sync', 'reject', 'getter']) + it(`missing or failing public capability (${mode}) has fixed local feedback and permits explicit retry`, async t => { + const { h, client } = await setup(t); + Object.defineProperty(client, 'getStats', { configurable: true, ...(mode === 'getter' ? { get() { throw 'PRIVATE'; } } + : { value: mode === 'missing' ? undefined : mode === 'noncallable' ? 1 : () => { if (mode === 'sync') throw 'PRIVATE'; return Promise.reject('PRIVATE'); } }) }); + refresh(h); await flush(); assert.equal(text(h), FAILED); busy(h, false); + fake(client, async () => stats()); refresh(h); await flush(); assert.match(text(h), /40 ms/); + }); +it('reserves before method getter, method call, and thenable assimilation can synthetically reclick', async t => { + const { h, client } = await setup(t); let getters = 0, calls = 0, thenReads = 0; + const again = () => { h.node('connection-stats-update').disabled = false; refresh(h); }; + Object.defineProperty(client, 'getStats', { configurable: true, get() { + getters++; again(); return () => { calls++; again(); return { get then() { thenReads++; again(); return (resolve: (value: unknown) => void) => resolve(stats()); } }; }; + } }); + refresh(h); await flush(); assert.deepEqual([getters, calls, thenReads], [1, 1, 1]); busy(h, false); assert.match(text(h), /40 ms/); +}); +for (const boundary of ['getter', 'call', 'inbound', 'outbound', 'inbound.lossPercent', 'inbound.rtt', 'inbound.bufferMs', 'outbound.lossPercent', 'outbound.rtt']) + it(`${boundary} reentry cannot publish stale projection or release a newer attempt`, async t => { + const { h, client } = await setup(t), newer = Promise.withResolvers(); let entered = false, calls = 0; + const replace = () => { entered = true; client['setStatus']('reconnecting'); client['setStatus']('live'); refresh(h); }; + Object.defineProperty(client, 'getStats', { configurable: true, get() { + if (boundary === 'getter' && !entered) { replace(); return () => assert.fail('retired getter cannot be invoked'); } + return () => { + calls++; if (entered) return newer.promise; + if (boundary === 'call') replace(); + const raw = stats(); + if (boundary !== 'call' && boundary !== 'getter') { + const [direction, field] = boundary.split('.'); const object = field ? Reflect.get(raw, direction!) : raw; + const key = field ?? direction!, value = Reflect.get(object, key); + Object.defineProperty(object, key, { get() { replace(); return value; } }); + } + return Promise.resolve(raw); + }; + } }); + refresh(h); await flush(); assert.ok(entered); assert.equal(calls, boundary === 'getter' ? 1 : 2); busy(h, true); assert.equal(text(h), INITIAL); + newer.resolve(stats(.123)); await flush(); busy(h, false); assert.match(text(h), /123 ms/); assert.doesNotMatch(text(h), /40 ms/); + }); +for (const cause of ['reconnecting', 'disconnected', 'closed', 'kicked', 'room', 'participant', 'leave', 'new-entry']) + for (const outcome of ['resolve', 'reject']) it(`${cause} retires history and ignores late ${outcome}`, async t => { + const { h, client } = await setup(t), old = Promise.withResolvers(); let calls = 0; + fake(client, () => ++calls === 1 ? Promise.resolve(stats()) : old.promise); refresh(h); await flush(); refresh(h); busy(h, true); + if (cause === 'reconnecting' || cause === 'disconnected') client['setStatus'](cause); + else if (cause === 'closed' || cause === 'kicked') client.dispatchEvent(new CustomEvent(cause, { detail: { reason: 'closed' } })); + else if (cause === 'room' || cause === 'participant') { + if (cause === 'room') client.state!.roomId = 'another-room'; else client.state!.me.participantId = 'another-seat'; + client['setStatus']('waiting'); + } else if (cause === 'leave') h.leave.click(); else h.form.submit(); + await flush(); if (outcome === 'resolve') old.resolve(stats(.999)); else old.reject('PRIVATE'); await flush(); + assert.doesNotMatch(text(h), /40 ms|999|失敗/); + if (cause === 'room' || cause === 'participant') { assert.equal(text(h), INITIAL); busy(h, false); } + else hidden(h); + }); +it('reconnect retains its captured binding and permits fresh manual work without a distinct state; old finally cannot unlock it', async t => { + const { h, client } = await setup(t), old = Promise.withResolvers(), newer = Promise.withResolvers(); let calls = 0; + fake(client, () => ++calls === 1 ? old.promise : newer.promise); const snapshot = client.state; + refresh(h); client['setStatus']('reconnecting'); hidden(h); client['setStatus']('live'); + assert.equal(client.state, snapshot); assert.equal(calls, 1); assert.equal(text(h), INITIAL); refresh(h); busy(h, true); + old.reject('PRIVATE'); await flush(); busy(h, true); assert.equal(text(h), INITIAL); + newer.resolve(stats(.073)); await flush(); busy(h, false); assert.match(text(h), /73 ms/); assert.equal(calls, 2); +}); +it('actual socket reconnect retains peer; stale native completion advances shared SDK baseline but cannot publish retired history', async t => { + const original = state(), { h, socket, peer, client } = await setup(t, original); let entries = report(inbound()); + samples(peer, async () => entries); refresh(h); await flush(); + const old = Promise.withResolvers(); samples(peer, () => old.promise); refresh(h); + socket.close(); hidden(h); assert.equal(peer.connectionState, 'connected'); await h.tick(500); + const resumed = StageSocket.instances.at(-1)!; hello(resumed); await h.until(() => resumed.sent.some(m => m.type === 'join'), 'resume'); + resumed.deliver({ type: 'room:state', ...original }); resumed.deliver({ type: 'ok', requestId: resumed.sent.find(m => m.type === 'join')!.requestId }); await h.tick(20); + assert.equal(client.status, 'live'); assert.equal(StagePeer.instances.length, 1); assert.equal(text(h), INITIAL); + old.resolve(report(inbound(2000, 190, 10, 4, 200))); await flush(); assert.equal(text(h), INITIAL); + entries = report(inbound(3000, 290, 10, 6, 300)); samples(peer, async () => entries); refresh(h); await flush(); + assert.match(text(h), /接收掉包 0.0%/); +}); +it('actual newer external completion suppresses older UI native result without failure or aggregate deltas', async t => { + const { h, peer, client } = await setup(t), old = Promise.withResolvers(); let count = 0; + samples(peer, () => ++count === 1 ? old.promise : Promise.resolve(report(inbound(2000)))); + refresh(h); await client.getStats(); old.resolve(report(inbound(1000))); await flush(); + assert.match(text(h), /接收:暫無統計;傳送:暫無統計/); assert.doesNotMatch(text(h), /失敗/); busy(h, false); +}); +for (const outcome of ['resolve', 'reject']) it(`new legitimate client reads despite unresolved old peer and shared-audio sink; late ${outcome} cannot change new stats`, async t => { + const { h, client, peer } = await setup(t, state(), { output: true }), old = Promise.withResolvers(); samples(peer, () => old.promise); + refresh(h); h.node('output-apply').click(); assert.equal(h.sinks.length, 1); + h.leave.click(); await flush(); const newer = await join(h); assert.notEqual(newer.client, client); assert.notEqual(newer.peer, peer); + assert.equal(client['audio'], newer.client['audio']); assert.equal(h.node('output-apply').disabled, true); + let entries = report(inbound(2000, 190, 10, 4, 200), pair); samples(newer.peer, async () => entries); refresh(h); await flush(); + assert.match(text(h), /接收掉包 —% · 接收 RTT 43 ms · 接收緩衝 — ms/); const historical = text(h); + if (outcome === 'resolve') old.resolve(report(inbound(9000, 9000, 90))); else old.reject('PRIVATE'); await flush(); + assert.equal(text(h), historical); busy(h, false); entries = report(inbound(3000, 290, 10, 6, 300)); refresh(h); await flush(); assert.match(text(h), /接收掉包 0.0%/); + h.sinks[0]!.work.resolve(); await flush(); +}); +for (const terminal of ['disconnected', 'closed', 'kicked', 'leave']) it(`${terminal} permanently retires embed binding even when actual same client directly rejoins`, async t => { + const { h, client, peer } = await setup(t); samples(peer, async () => report(inbound(), pair)); refresh(h); await flush(); + if (terminal === 'disconnected') await client.disconnect(); + else if (terminal === 'leave') h.leave.click(); + else { client.dispatchEvent(new CustomEvent(terminal, { detail: {} })); await client.disconnect(); } + await flush(); hidden(h); + const returning = client.join({ roomId: 'same-room', name: 'Returning' }); await flush(); const socket = StageSocket.instances.at(-1)!; hello(socket); + await h.until(() => socket.sent.some(m => m.type === 'join'), 'same-client join'); + socket.deliver({ type: 'room:state', ...state() }); socket.deliver({ type: 'ok', requestId: socket.sent.find(m => m.type === 'join')!.requestId }); await returning; await h.tick(20); + assert.equal(client.status, 'live'); hidden(h); let calls = 0; samples(StagePeer.instances.at(-1)!, async () => { calls++; return report(inbound(), pair); }); + h.node('connection-stats-update').disabled = false; h.node('connection-stats-panel').hidden = false; refresh(h); await flush(); assert.equal(calls, 0); hidden(h); + assert.equal(h.leave.disabled, true); assert.equal((await client.getStats()).inbound?.rtt, .043); +}); +it('actual initial state without ACK followed by disconnect cannot recreate controls from retained state/status', async t => { + const h = await actualApp(t); h.form.submit(); await flush(); const socket = StageSocket.instances.at(-1)!; hello(socket); + await h.until(() => socket.sent.some(m => m.type === 'join'), 'join'); socket.deliver({ type: 'room:state', ...state() }); + const client = h.clients[0] as StageClient; socket.close(); await flush(); assert.equal(client.status, 'disconnected'); assert.ok(client.state); hidden(h); + client['currentStatus'] = 'live'; client.dispatchEvent(new CustomEvent('state', { detail: client.state })); + client.dispatchEvent(new CustomEvent('status', { detail: 'live' })); hidden(h); + let reads = 0; fake(client, async () => { reads++; return stats(); }); h.node('connection-stats-update').disabled = false; + h.node('connection-stats-panel').hidden = false; refresh(h); await flush(); assert.equal(reads, 0); hidden(h); +}); +it('same-seat role/stage/connected/microphone changes preserve pending and historical own metrics', async t => { + const { h, client, socket } = await setup(t, state('speaker', true)), work = Promise.withResolvers(); let calls = 0; + fake(client, () => { calls++; return work.promise; }); refresh(h); + for (const role of ['audience', 'controller', 'speaker'] as const) { + const s = state(role, role === 'speaker'); s.me.connected = false; s.me.muted = s.me.forceMuted = true; + socket.deliver({ type: 'room:state', ...s }); await h.tick(20); busy(h, true); + } + client.dispatchEvent(new CustomEvent('micerror', { detail: { message: '麥克風不可用', name: 'NotAllowedError' } })); + const old = unaffected(h), oldMedia = media(h); work.resolve(stats()); await flush(); assert.match(text(h), /40 ms/); + assert.deepEqual(unaffected(h), old); assert.deepEqual(media(h), oldMedia); const historical = text(h); + client.dispatchEvent(new CustomEvent('state', { detail: client.state })); assert.equal(text(h), historical); assert.equal(calls, 1); +}); +for (const kind of ['disabled', 'button-hidden', 'panel-hidden', 'button-detached', 'text-detached', 'reparented']) + it(`rejects retained synthetic ${kind} click without stats lookup`, async t => { + const { h, client } = await setup(t); let calls = 0; fake(client, async () => { calls++; return stats(); }); + if (kind === 'disabled') h.node('connection-stats-update').disabled = true; + if (kind === 'button-hidden') h.node('connection-stats-update').hidden = true; + if (kind === 'panel-hidden') h.node('connection-stats-panel').hidden = true; + if (kind === 'button-detached') h.node('connection-stats-update').remove(); + if (kind === 'text-detached') h.node('connection-stats-text').remove(); + if (kind === 'reparented') new EmbedNode().append(h.node('connection-stats-update')); + refresh(h); await flush(); assert.equal(calls, 0); + }); +for (const id of ['connection-stats-panel', 'connection-stats-update', 'connection-stats-text']) + it(`exact document identity rejects replaced ${id} even with original panel containment`, async t => { + // Instrument only querySelector's result inside the actual production module seam. + const source = readFileSync(new URL('../web/src/embed.ts', import.meta.url), 'utf8') + + `\nconst originalQuery = document.querySelector.bind(document);\n(document as any).querySelector = (selector: string) => selector === '#${id}' ? document.createElement('div') : originalQuery(selector);\n`; + const h = embedApp({ source }); t.after(() => h.cleanup()); const client = await h.join(); let calls = 0; + Object.defineProperty(client, 'getStats', { value: async () => { calls++; return stats(); } }); + h.node('connection-stats-panel').hidden = false; h.node('connection-stats-update').disabled = false; h.node('connection-stats-update').invoke(); await flush(); assert.equal(calls, 0); + }); +for (const property of ['disabled', 'button-text', 'result-text', 'aria-busy', 'hidden']) for (const late of [false, true]) + it(`${property} setter reconciles nested newer winner, including late outer overwrite=${late}`, async t => { + const { h, client } = await setup(t), newer = Promise.withResolvers(); let calls = 0; + fake(client, () => { calls++; return property === 'result-text' && calls === 1 ? Promise.resolve(stats()) : newer.promise; }); + const replace = () => { client['setStatus']('reconnecting'); client['setStatus']('live'); refresh(h); }; + if (property === 'aria-busy') { + const node = h.node('connection-stats-update'), set = node.setAttribute.bind(node); let armed = true; + t.mock.method(node, 'setAttribute', (key: string, value: string) => { + if (!late) set(key, value); if (key === 'aria-busy' && armed) { armed = false; replace(); } if (late) set(key, value); + }); + } else if (property === 'hidden') { client['setStatus']('reconnecting'); setter(h.node('connection-stats-panel'), 'hidden', replace, late); client['setStatus']('live'); } + else setter(h.node(property === 'result-text' ? 'connection-stats-text' : 'connection-stats-update'), property === 'disabled' ? 'disabled' : 'textContent', replace, late); + refresh(h); await flush(); assert.equal(calls, property === 'result-text' ? 2 : 1); busy(h, true); assert.equal(text(h), INITIAL); + newer.resolve(stats(.081)); await flush(); busy(h, false); assert.match(text(h), /81 ms/); assert.doesNotMatch(text(h), /40 ms/); + }); +for (const boundary of ['result getter', 'result setter']) it(`${boundary} reconciles nested winner followed by silent disconnect and never restores old binding`, async t => { + const { h, client } = await setup(t), newer = Promise.withResolvers(); let calls = 0; + const replaceThenLose = () => { client['setStatus']('reconnecting'); client['setStatus']('live'); refresh(h); client['currentStatus'] = 'disconnected'; }; + fake(client, () => { + if (++calls > 1) return newer.promise; const raw = stats(); + if (boundary === 'result getter') Object.defineProperty(raw, 'inbound', { get() { replaceThenLose(); return stats().inbound; } }); + return Promise.resolve(raw); + }); + if (boundary === 'result setter') setter(h.node('connection-stats-text'), 'textContent', replaceThenLose, true); + refresh(h); await flush(); assert.equal(calls, 2); hidden(h); newer.resolve(stats(.999)); await flush(); hidden(h); + client['setStatus']('live'); hidden(h); assert.equal(calls, 2); +}); +for (const action of ['close', 'transfer']) for (const loss of ['none', 'reconnecting', 'disconnected', 'room', 'participant', 'role', 'connected', 'target']) + it(`${action} consent ${loss === 'none' ? 'survives ordinary diagnostics' : `retires on observed ${loss} before corrective stats setters restore it`}`, async t => { + let onConfirm = () => {}; + const { h, client } = await controller(t, { confirm: () => { onConfirm(); return true; } }); + const close = t.mock.method(client, 'closeRoom', async () => {}), handover = t.mock.method(client, 'transferControl', async () => {}); + const snapshot = client.state!, target = snapshot.speakers.find(p => p.participantId === 'target')!; + const restore = () => { + client['currentStatus'] = 'live'; snapshot.roomId = 'same-room'; snapshot.me.participantId = 'guest'; + snapshot.me.role = 'controller'; snapshot.me.connected = true; target.connected = true; + }; + Object.defineProperty(client, 'getStats', { configurable: true, get() { + if (loss === 'reconnecting' || loss === 'disconnected') client['currentStatus'] = loss; + if (loss === 'room') snapshot.roomId = 'other'; + if (loss === 'participant') snapshot.me.participantId = 'other'; + if (loss === 'role') snapshot.me.role = 'audience'; + if (loss === 'connected') snapshot.me.connected = false; + if (loss === 'target') target.connected = false; + return async () => stats(); + } }); + // Status/identity loss hides or clears; role/target loss preserves readership but changes busy text. + if (loss === 'reconnecting' || loss === 'disconnected') setter(h.node('connection-stats-panel'), 'hidden', restore, true); + else setter(h.node('connection-stats-update'), 'textContent', () => {}, false); + onConfirm = () => { + refresh(h); + // Synchronous getStats guard has already observed any loss before confirmation returns. + restore(); + }; + (action === 'close' ? h.node('close-room') : transfer(h)).click(); await flush(); + const preserved = loss === 'none' || (action === 'close' && loss === 'target'); + assert.equal(close.mock.callCount(), preserved && action === 'close' ? 1 : 0); + assert.equal(handover.mock.callCount(), preserved && action === 'transfer' ? 1 : 0); + }); +for (const cause of ['leave', 'new-entry', 'closed', 'kicked', 'disconnected']) for (const action of ['close', 'transfer']) + it(`${cause} purely fences diagnostics before early teardown callbacks and invalidates old ${action} before new DOM`, async t => { + const { h, client } = await controller(t, { confirm: () => true }); let reads = 0, inEarly = false, earlyPaints = 0; + fake(client, async () => { reads++; return stats(); }); const oldButton = action === 'close' ? h.node('close-room') : transfer(h); + const close = t.mock.method(client, 'closeRoom', async () => {}), handover = t.mock.method(client, 'transferControl', async () => {}); + setter(h.node('connection-stats-panel'), 'hidden', () => { + if (inEarly) earlyPaints++; + oldButton.disabled = false; oldButton.invoke(); + }); + const early = () => { inEarly = true; refresh(h); inEarly = false; }; + if (cause === 'disconnected') { + const descriptor = Object.getOwnPropertyDescriptor(StageClient.prototype, 'state')!; let armed = true; + Object.defineProperty(client, 'state', { configurable: true, get() { if (armed) { armed = false; early(); } return descriptor.get!.call(client); } }); + } else setter(h.node(cause === 'closed' || cause === 'kicked' ? 'cancel-join' : 'error-notice'), 'hidden', early); + if (cause === 'leave') h.leave.click(); else if (cause === 'new-entry') h.form.submit(); + else if (cause === 'disconnected') client['setStatus']('disconnected'); + else client.dispatchEvent(new CustomEvent(cause, { detail: {} })); + await flush(); assert.equal(reads, 0); assert.equal(earlyPaints, 0); assert.equal(close.mock.callCount(), 0); assert.equal(handover.mock.callCount(), 0); + Reflect.deleteProperty(client, 'state'); + }); +for (const cause of ['leave', 'new-entry', 'closed', 'kicked', 'disconnected']) for (const action of ['close', 'transfer']) + it(`${cause} inside pending ${action} confirmation cannot restore its consent via diagnostics`, async t => { + let onConfirm = () => {}; + const { h, client } = await controller(t, { confirm: () => { onConfirm(); return true; } }); + let reads = 0; fake(client, async () => { reads++; return stats(); }); + const close = t.mock.method(client, 'closeRoom', async () => {}), handover = t.mock.method(client, 'transferControl', async () => {}); + onConfirm = () => { + if (cause === 'leave') h.leave.click(); else if (cause === 'new-entry') h.form.submit(); + else if (cause === 'disconnected') client['setStatus']('disconnected'); else client.dispatchEvent(new CustomEvent(cause, { detail: {} })); + client['currentStatus'] = 'live'; h.node('connection-stats-panel').hidden = false; h.node('connection-stats-update').disabled = false; refresh(h); + }; + (action === 'close' ? h.node('close-room') : transfer(h)).click(); await flush(); + assert.equal(reads, 0); assert.equal(close.mock.callCount(), 0); assert.equal(handover.mock.callCount(), 0); + }); +it('installs actual new-client captured generation binding before its first syncUnlock callback', async t => { + const h = await actualApp(t); let reads = 0, entered = false; + const node = h.node('unlock'); let disabled = node.disabled; + Object.defineProperty(node, 'disabled', { configurable: true, get: () => disabled, set(value: boolean) { + disabled = value; + if (!entered && h.clients.length === 1) { + entered = true; const client = h.clients[0] as StageClient; client['snapshot'] = state(); client['currentStatus'] = 'live'; + fake(client, async () => { reads++; return stats(.064); }); + h.node('connection-stats-panel').hidden = false; h.node('connection-stats-update').disabled = false; refresh(h); + } + } }); + await join(h); await flush(); assert.ok(entered); assert.equal(reads, 1); assert.match(text(h), /64 ms/); +}); +for (const getter of ['state', 'status']) for (const cause of ['new-entry', 'leave']) + it(`public ${getter} getter starting ${cause} cannot manufacture a binding from the new global generation`, async t => { + const { h, client } = await setup(t); let reads = 0, armed = true; fake(client, async () => { reads++; return stats(); }); + const descriptor = Object.getOwnPropertyDescriptor(StageClient.prototype, getter)!; + Object.defineProperty(client, getter, { configurable: true, get() { + const value: unknown = descriptor.get!.call(client); + if (armed) { armed = false; if (cause === 'new-entry') h.form.submit(); else h.leave.click(); } + return value; + } }); + refresh(h); await flush(); assert.equal(reads, 0); Reflect.deleteProperty(client, getter); hidden(h); + }); +for (const getter of ['roomId', 'participantId']) + it(`public ${getter} identity getter reentry retires attempt before SDK method lookup`, async t => { + const { h, client } = await setup(t); let reads = 0, armed = true; fake(client, async () => { reads++; return stats(); }); + const target = getter === 'roomId' ? client.state! : client.state!.me, value = Reflect.get(target, getter); + Object.defineProperty(target, getter, { configurable: true, get() { + if (armed) { armed = false; h.leave.click(); } return value; + } }); + refresh(h); await flush(); assert.equal(reads, 0); hidden(h); + }); +it('preserves unrelated UI and avoids equal writes on repeated same-seat state and status receipts', async t => { + const { h, client } = await setup(t); fake(client, async () => stats()); refresh(h); await flush(); const historical = text(h); + const nodes = ['connection-stats-panel', 'connection-stats-update', 'connection-stats-text'].map(id => h.node(id)); + for (const node of nodes) node.resetWrites(); + for (let i = 0; i < 3; i++) client.dispatchEvent(new CustomEvent('state', { detail: client.state })); + assert.equal(text(h), historical); for (const node of nodes) assert.deepEqual(node.writes, { disabled: 0, textContent: 0, attributes: 0, unchanged: 0 }); +}); +it('genuine hidden initial DOM rejects early syncUnlock retained handler without damaging normal join', async t => { + const h = await actualApp(t); let early = 0, reads = 0; + const node = h.node('unlock'); let disabled = node.disabled; + Object.defineProperty(node, 'disabled', { configurable: true, get: () => disabled, set(value: boolean) { + disabled = value; + if (h.clients.length === 1 && early === 0) { + early++; const client = h.clients[0] as StageClient; assert.equal(client.status, 'disconnected'); + assert.equal(h.node('connection-stats-panel').hidden, true); assert.equal(h.node('connection-stats-update').disabled, true); + fake(client, async () => { reads++; return stats(); }); refresh(h); + } + } }); + await join(h); assert.equal(early, 1); assert.equal(reads, 0); assert.equal(text(h), INITIAL); + refresh(h); await flush(); assert.equal(reads, 1); assert.match(text(h), /40 ms/); +}); +it('host-forced initial disconnected visibility fails closed for that binding; a genuine new entry recovers', async t => { + const h = await actualApp(t); let early = 0, reads = 0; + const node = h.node('unlock'); let disabled = node.disabled; + Object.defineProperty(node, 'disabled', { configurable: true, get: () => disabled, set(value: boolean) { + disabled = value; + if (h.clients.length === 1 && early === 0) { + early++; const client = h.clients[0] as StageClient; assert.equal(client.status, 'disconnected'); + fake(client, async () => { reads++; return stats(); }); + h.node('connection-stats-panel').hidden = false; h.node('connection-stats-update').disabled = false; refresh(h); + } + } }); + const first = await join(h); assert.equal(early, 1); assert.equal(reads, 0); hidden(h); + first.client['setStatus']('waiting'); first.client['setStatus']('live'); hidden(h); assert.equal(reads, 0); + h.leave.click(); await flush(); const next = await join(h); fake(next.client, async () => { reads++; return stats(.088); }); + assert.equal(text(h), INITIAL); refresh(h); await flush(); assert.equal(reads, 1); assert.match(text(h), /88 ms/); +}); +it('cancelling initial entry retires captured binding before late old state and admits only a genuine new entry', async t => { + const h = await actualApp(t); h.form.submit(); await flush(); const client = h.clients[0] as StageClient; let reads = 0; + fake(client, async () => { reads++; return stats(); }); hidden(h); refresh(h); h.node('cancel-join').invoke(); await flush(); + assert.equal(client.status, 'disconnected'); client['snapshot'] = state(); client['currentStatus'] = 'live'; + client.dispatchEvent(new CustomEvent('state', { detail: client.state })); client.dispatchEvent(new CustomEvent('status', { detail: 'live' })); + h.node('connection-stats-panel').hidden = false; h.node('connection-stats-update').disabled = false; refresh(h); await flush(); assert.equal(reads, 0); hidden(h); + const newer = await join(h); fake(newer.client, async () => { reads++; return stats(.067); }); refresh(h); await flush(); assert.equal(reads, 1); assert.match(text(h), /67 ms/); +}); +for (const member of ['connection-stats-update', 'connection-stats-text']) it(`pending result cannot survive detached ${member} or seed reattached history`, async t => { + const { h, client } = await setup(t), old = Promise.withResolvers(); fake(client, () => old.promise); refresh(h); + h.node(member).remove(); old.resolve(stats(.999)); await flush(); hidden(h); + h.node('connection-stats-panel').append(h.node(member)); client['setStatus']('waiting'); assert.equal(text(h), INITIAL); + fake(client, async () => stats(.033)); refresh(h); await flush(); assert.match(text(h), /33 ms/); assert.doesNotMatch(text(h), /999/); +}); +for (const action of ['close', 'transfer']) for (const loss of ['reconnecting', 'disconnected', 'room', 'participant']) + for (const restoreAt of [2, 3]) it(`observed ${loss} on read1, restored on read${restoreAt}, permanently retires old ${action} consent`, async t => { + let inspect = () => {}; + const { h, client } = await controller(t, { confirm: () => { inspect(); return true; } }); + const close = t.mock.method(client, 'closeRoom', async () => {}), handover = t.mock.method(client, 'transferControl', async () => {}); + const target = loss === 'room' ? client.state! : loss === 'participant' ? client.state!.me : client; + const property = loss === 'room' ? 'roomId' : loss === 'participant' ? 'participantId' : 'status'; + const original = Reflect.get(target, property); let value = original, armed = false, reads = 0; + Object.defineProperty(target, property, { configurable: true, get() { if (armed && ++reads === restoreAt) value = original; return value; } }); + Object.defineProperty(client, 'getStats', { configurable: true, get() { + value = loss === 'room' || loss === 'participant' ? 'different-identity' : loss; reads = 0; armed = true; + return async () => stats(); + } }); + inspect = () => refresh(h); (action === 'close' ? h.node('close-room') : transfer(h)).click(); await flush(); + assert.ok(reads >= restoreAt); assert.equal(close.mock.callCount(), 0); assert.equal(handover.mock.callCount(), 0); + if (property === 'status') Reflect.deleteProperty(target, property); else Object.defineProperty(target, property, { configurable: true, writable: true, value: original }); + }); +for (const action of ['close', 'transfer']) it(`observed loss with no stats owner still retires old ${action} consent`, async t => { + let inspect = () => {}; + const source = readFileSync(new URL('../web/src/embed.ts', import.meta.url), 'utf8') + + '\nconnectionStatsUpdate.onclick = () => { retireConnectionStats(); refreshConnectionStats(); };\n'; + const h = embedApp({ source, confirm: () => { inspect(); return true; } }); t.after(() => h.cleanup()); const sdk = await h.join(); + const s = state('controller'); s.speakers.push({ ...s.me, participantId: 'target', role: 'speaker', onStage: true }); + sdk.state = s; sdk.me = s.me; sdk.emit('state', s); let value = sdk.status, armed = false, reads = 0; + Object.defineProperty(sdk, 'status', { configurable: true, get() { if (armed && ++reads === 2) value = 'live'; return value; }, set(next) { value = next; } }); + inspect = () => { value = 'reconnecting'; armed = true; h.node('connection-stats-update').invoke(); }; + const handover = h.node('stage-management-list').children[0]!.children[1]!.children[2]!; + (action === 'close' ? h.node('close-room') : handover).click(); await flush(); assert.equal(sdk.closes.length, 0); assert.equal(sdk.transfers.length, 0); +}); +for (const action of ['close', 'transfer']) it(`older status value cannot invalidate a fresh actual SDK ${action} token created in that getter`, async t => { + const { h, client } = await controller(t, { confirm: () => true }), work = Promise.withResolvers(); fake(client, async () => stats()); + const command = t.mock.method(client, action === 'close' ? 'closeRoom' : 'transferControl', () => work.promise); + const original = Object.getOwnPropertyDescriptor(StageClient.prototype, 'status')!; let armed = true; + Object.defineProperty(client, 'status', { configurable: true, get() { + if (!armed) return original.get!.call(client); + armed = false; (action === 'close' ? h.node('close-room') : transfer(h)).click(); return 'reconnecting'; + } }); + refresh(h); await flush(); assert.equal(command.mock.callCount(), 1); assert.equal(h.confirmations.length, 1); + work.reject(new Error('fresh authorized action failed')); await flush(); assert.match(h.node('last-error').textContent, /fresh authorized action failed/); + Reflect.deleteProperty(client, 'status'); +}); +for (const action of ['close', 'transfer']) it(`old identity getter starting a genuine new entry cannot invalidate its later fresh ${action} consent`, async t => { + const { h, client } = await controller(t, { confirm: () => true }); fake(client, async () => stats()); + const original = Object.getOwnPropertyDescriptor(StageClient.prototype, 'state')!; let armed = true; + Object.defineProperty(client, 'state', { configurable: true, get() { + const value: unknown = original.get!.call(client); + if (armed) { armed = false; h.form.submit(); } + return value; + } }); + refresh(h); await flush(); Reflect.deleteProperty(client, 'state'); + const next = h.clients.at(-1) as StageClient; assert.notEqual(next, client); const socket = StageSocket.instances.at(-1)!; hello(socket); + await h.until(() => socket.sent.some(m => m.type === 'join'), 'new entry join'); + const s = state('controller'); s.speakers.push({ ...s.me, participantId: 'target', role: 'speaker', onStage: true }); + socket.deliver({ type: 'room:state', ...s }); socket.deliver({ type: 'ok', requestId: socket.sent.find(m => m.type === 'join')!.requestId }); await h.tick(20); + const work = Promise.withResolvers(); const command = t.mock.method(next, action === 'close' ? 'closeRoom' : 'transferControl', () => work.promise); + fake(next, async () => stats(.062)); refresh(h); await flush(); assert.match(text(h), /62 ms/); + (action === 'close' ? h.node('close-room') : transfer(h)).click(); assert.equal(command.mock.callCount(), 1); + work.reject(new Error('new entry authorized action failed')); await flush(); assert.match(h.node('last-error').textContent, /new entry authorized action failed/); +}); diff --git a/test/webEmbedEntry.test.ts b/test/webEmbedEntry.test.ts index 19dfe44..8d21340 100644 --- a/test/webEmbedEntry.test.ts +++ b/test/webEmbedEntry.test.ts @@ -60,8 +60,9 @@ async function fixture(t: TestContext, options: { deferLibrary?: boolean } = {}) t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); let now = 1000; t.mock.method(performance, 'now', () => now); - const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'speaking-summary', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); + const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'speaking-summary', 'connection-stats-panel', 'connection-stats-update', 'connection-stats-text', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); const node = (id: string): EmbedNode => { const found = nodes.get(id); assert.ok(found); return found; }; + node('connection-stats-panel').append(node('connection-stats-update'), node('connection-stats-text')); node('error-notice').hidden = true; node('hand-rejected').hidden = true; node('room-occupancy').hidden = true; node('own-connection-quality').hidden = true; node('speaking-summary').hidden = true; const cancel = node('cancel-join'); cancel.hidden = cancel.disabled = true; cancel.textContent = '取消連線'; const form = node('join'), submit = new EmbedNode(), status = node('status'), leave = node('leave'), hand = node('hand'); diff --git a/test/webEmbedMicrophone.test.ts b/test/webEmbedMicrophone.test.ts index ca3fa74..3e8db1d 100644 --- a/test/webEmbedMicrophone.test.ts +++ b/test/webEmbedMicrophone.test.ts @@ -228,8 +228,9 @@ async function actualApp(t: TestContext) { for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value }); MicSocket.instances = []; t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); let now = 1000; t.mock.method(performance, 'now', () => now); - const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'speaking-summary', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); + const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'speaking-summary', 'connection-stats-panel', 'connection-stats-update', 'connection-stats-text', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); const node = (id: string): EmbedNode => { const found = nodes.get(id); assert.ok(found); return found; }; + node('connection-stats-panel').append(node('connection-stats-update'), node('connection-stats-text')); node('error-notice').hidden = true; node('hand-rejected').hidden = true; node('room-occupancy').hidden = true; node('own-connection-quality').hidden = true; node('speaking-summary').hidden = true; const cancel = node('cancel-join'); cancel.hidden = cancel.disabled = true; cancel.textContent = '取消連線'; const form = node('join'), mic = node('mic'), status = node('status'), leave = node('leave'); diff --git a/test/webEmbedWsPath.test.ts b/test/webEmbedWsPath.test.ts index 66d708a..a1ba5b0 100644 --- a/test/webEmbedWsPath.test.ts +++ b/test/webEmbedWsPath.test.ts @@ -213,8 +213,9 @@ for (const wsPath of ['/custom/stage', '/ws']) { } }); const { StageClient, decodeName } = await import(new URL('../packages/client/src/index.ts', import.meta.url).href); const joined = Promise.withResolvers(); - const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'speaking-summary', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); + const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'speaking-summary', 'connection-stats-panel', 'connection-stats-update', 'connection-stats-text', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); const node = (id: string): EmbedNode => { const value = nodes.get(id); assert.ok(value, `unmodeled node: ${id}`); return value; }; + node('connection-stats-panel').append(node('connection-stats-update'), node('connection-stats-text')); node('error-notice').hidden = true; node('hand-rejected').hidden = true; node('room-occupancy').hidden = true; node('own-connection-quality').hidden = true; node('speaking-summary').hidden = true; node('join').submitButton = new EmbedNode(); node('name').value = '嵌入聽眾'; Object.assign(node('audio'), { pause() {} }); diff --git a/web/embed.html b/web/embed.html index b480238..79b070f 100644 --- a/web/embed.html +++ b/web/embed.html @@ -9,6 +9,6 @@ -

尚未加入

關閉房間會讓所有人離場,且無法再加入此房間。離開房間只會讓你離場,房間可能保留並由其他人接任主控。

+

尚未加入

關閉房間會讓所有人離場,且無法再加入此房間。離開房間只會讓你離場,房間可能保留並由其他人接任主控。

diff --git a/web/src/embed.ts b/web/src/embed.ts index 525bcd3..5038195 100644 --- a/web/src/embed.ts +++ b/web/src/embed.ts @@ -8,6 +8,9 @@ const status = document.querySelector('#status')!; const roomOccupancy = document.querySelector('#room-occupancy')!; const ownConnectionQuality = document.querySelector('#own-connection-quality')!; const speakingSummary = document.querySelector('#speaking-summary')!; +const connectionStatsPanel = document.querySelector('#connection-stats-panel')!; +const connectionStatsUpdate = document.querySelector('#connection-stats-update')!; +const connectionStatsText = document.querySelector('#connection-stats-text')!; const errorNotice = document.querySelector('#error-notice')!; const lastError = document.querySelector('#last-error')!; const handRejectionNotice = document.querySelector('#hand-rejected')!; @@ -387,6 +390,200 @@ function renderSpeaking(): void { if (speakingSummary.textContent !== text) { speakingSummary.textContent = text; if (!valid()) return; } if (speakingSummary.hidden) { speakingSummary.hidden = false; if (!valid()) return; } } +interface ConnectionStatsBinding { client: Client; generation: number } +interface ConnectionStatsOwner extends ConnectionStatsBinding { roomId: string; participantId: string } +interface ConnectionStatsReport { + inbound: { loss: number | undefined; rtt: number | undefined; buffer: number | undefined } | null; + outbound: { loss: number | undefined; rtt: number | undefined } | null; +} +let connectionStatsBinding: ConnectionStatsBinding | undefined; +let connectionStatsOwner: ConnectionStatsOwner | undefined; +let connectionStatsAttempt: object | undefined; +let connectionStatsReport: ConnectionStatsReport | undefined; +let connectionStatsFailed = false; +let connectionStatsTeardown = false; +let connectionStatsRevision = 0; +function retireConnectionStats(): void { + connectionStatsOwner = undefined; connectionStatsAttempt = undefined; + connectionStatsReport = undefined; connectionStatsFailed = false; connectionStatsRevision++; +} +// Pure departure fence: legacy Close/Transfer teardown must run before any new surface setter. +function retireConnectionStatsBinding(): void { + connectionStatsBinding = undefined; connectionStatsTeardown = true; retireConnectionStats(); +} +function connectionStatsMounted(): boolean { + return document.querySelector('#connection-stats-panel') === connectionStatsPanel + && document.querySelector('#connection-stats-update') === connectionStatsUpdate + && document.querySelector('#connection-stats-text') === connectionStatsText + && connectionStatsPanel.contains(connectionStatsUpdate) && connectionStatsPanel.contains(connectionStatsText); +} +function reconcileConnectionStats(): void { + for (;;) { + const binding = connectionStatsBinding, owner = connectionStatsOwner, revision = connectionStatsRevision; + const current = client, controls = controlsClient, generation = entryGeneration; + const closeAttempt = pendingRoomClose, closeAuthority = roomCloseOwner; + const transferAttempt = pendingTransfer, transferAuthority = stageManagementOwner; + const stable = (): boolean => binding === connectionStatsBinding && owner === connectionStatsOwner + && revision === connectionStatsRevision && current === client && controls === controlsClient && generation === entryGeneration; + // Observed authority loss is sticky before any next public getter can restore it. + // Exact captured tokens cannot invalidate a genuinely newer nested consent. + const invalidateObserved = (lost: (authority: ConnectionStatsOwner) => boolean): void => { + if (closeAttempt && pendingRoomClose === closeAttempt && roomCloseOwner === closeAuthority + && (!closeAuthority || lost(closeAuthority))) closeAttempt.invalidated = true; + if (transferAttempt && pendingTransfer === transferAttempt && stageManagementOwner === transferAuthority + && (!transferAuthority || lost(transferAuthority))) transferAttempt.invalidated = true; + }; + const observe = () => { + const state = current?.state ?? null, status = current?.status ?? 'disconnected'; + invalidateObserved(authority => authority.client !== current || current !== controls || authority.generation !== generation + || (status !== 'waiting' && status !== 'live')); + if (stable() && current && status === 'disconnected' && binding?.client === current) { + connectionStatsBinding = undefined; retireConnectionStats(); + } else if (stable() && owner && status !== 'waiting' && status !== 'live') retireConnectionStats(); + const room: unknown = state?.roomId; + invalidateObserved(authority => authority.roomId !== room); + if (stable() && owner && owner.roomId !== room) retireConnectionStats(); + const participant: unknown = state?.me?.participantId; + invalidateObserved(authority => authority.participantId !== participant); + if (stable() && owner && owner.participantId !== participant) retireConnectionStats(); + return { state, status, room, participant }; + }; + try { + const identity = observe(); + if (!stable()) continue; + const { room, participant, status } = identity; + const eligible = Boolean(binding && binding.client === current && binding.generation === generation && current === controls + && typeof room === 'string' && room.trim() && typeof participant === 'string' && participant.trim() + && (status === 'waiting' || status === 'live') && connectionStatsMounted()); + if (!stable()) continue; + if (owner && (!eligible || owner.client !== current || owner.generation !== generation)) { retireConnectionStats(); continue; } + // Role/connected/target eligibility belongs to the existing pure consent check. + reconcileSpeakingConsents(); + if (!stable()) continue; + const checked = observe(); + if (!stable() || identity.state !== checked.state || status !== checked.status + || room !== checked.room || participant !== checked.participant) continue; + if (eligible && !connectionStatsMounted()) continue; + if (!stable()) continue; + if (eligible && !owner) { + connectionStatsOwner = { client: current!, generation, roomId: room as string, participantId: participant as string }; + connectionStatsRevision++; + } + return; + } catch { + invalidateObserved(() => true); + if (!stable()) continue; + if (owner) { retireConnectionStats(); continue; } + return; + } + } +} +function connectionStatsMessage(): string { + const report = connectionStatsReport; + if (!report) return connectionStatsFailed ? '無法讀取連線統計,請稍後手動再試。' : '尚未手動讀取連線統計。'; + const loss = (value: number | undefined): string => value === undefined ? '—' : value.toFixed(1); + const ms = (value: number | undefined): string => value === undefined ? '—' : String(Math.round(value)); + const inbound = report.inbound, outbound = report.outbound; + const receive = inbound ? `接收掉包 ${loss(inbound.loss)}% · 接收 RTT ${ms(inbound.rtt)} ms · 接收緩衝 ${ms(inbound.buffer)} ms` : '接收:暫無統計'; + const send = outbound ? `傳送掉包 ${loss(outbound.loss)}% · 傳送 RTT ${ms(outbound.rtt)} ms` : '傳送:暫無統計'; + return `上次手動讀取(SDK 取樣區間):${receive};${send}。RTT 不是端到端音訊延遲;不保證資料即時或目前可聽。` + + (connectionStatsFailed ? '本次讀取失敗,仍顯示上次手動讀取結果。' : ''); +} +function syncConnectionStats(): void { + for (;;) { + if (connectionStatsTeardown) return; + reconcileConnectionStats(); + if (connectionStatsTeardown) return; + const revision = connectionStatsRevision, owner = connectionStatsOwner, attempt = connectionStatsAttempt; + const hidden = !owner, busy = Boolean(attempt), disabled = hidden || busy; + const text = hidden ? '' : connectionStatsMessage(); + // Reconcile after every changed setter, even when an old setter completes after a nested render. + if (hidden && !connectionStatsPanel.hidden) { connectionStatsPanel.hidden = true; continue; } + if (connectionStatsText.textContent !== text) { connectionStatsText.textContent = text; continue; } + if (connectionStatsUpdate.disabled !== disabled) { connectionStatsUpdate.disabled = disabled; continue; } + if (connectionStatsUpdate.getAttribute('aria-busy') !== String(busy)) { + connectionStatsUpdate.setAttribute('aria-busy', String(busy)); continue; + } + const label = busy ? '讀取中…' : '更新我的連線統計'; + if (connectionStatsUpdate.textContent !== label) { connectionStatsUpdate.textContent = label; continue; } + if (connectionStatsPanel.hidden !== hidden) { connectionStatsPanel.hidden = hidden; continue; } + reconcileConnectionStats(); + if (revision === connectionStatsRevision && owner === connectionStatsOwner && attempt === connectionStatsAttempt) return; + } +} +function finishConnectionStatsTeardown(generation: number): void { + if (generation !== entryGeneration) return; + connectionStatsTeardown = false; syncConnectionStats(); +} +function projectConnectionStats(raw: unknown, current: () => boolean): ConnectionStatsReport | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(); + const direction = (key: 'inbound' | 'outbound') => { + const value: unknown = (raw as Record)[key]; + if (!current()) return undefined; + if (value === null || value === undefined) return null; + if (typeof value !== 'object' || Array.isArray(value)) throw new Error(); + return value as Record; + }; + const numeric = (value: unknown, max = Infinity, factor = 1): number | undefined => { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > max) return undefined; + const converted = value * factor; + return Number.isFinite(converted) ? converted : undefined; + }; + const inbound = direction('inbound'); + if (!current()) return; + let receive: ConnectionStatsReport['inbound'] = null; + if (inbound) { + const loss = numeric(inbound.lossPercent, 100); if (!current()) return; + const rtt = numeric(inbound.rtt, Infinity, 1000); if (!current()) return; + const buffer = numeric(inbound.bufferMs); if (!current()) return; + receive = { loss, rtt, buffer }; + } + const outbound = direction('outbound'); + if (!current()) return; + let send: ConnectionStatsReport['outbound'] = null; + if (outbound) { + const loss = numeric(outbound.lossPercent, 100); if (!current()) return; + const rtt = numeric(outbound.rtt, Infinity, 1000); if (!current()) return; + send = { loss, rtt }; + } + return { inbound: receive, outbound: send }; +} +function refreshConnectionStats(): void { + if (connectionStatsTeardown || connectionStatsUpdate.disabled || connectionStatsUpdate.hidden || connectionStatsPanel.hidden + || !connectionStatsMounted()) return; + syncConnectionStats(); + const owner = connectionStatsOwner; + if (!owner || connectionStatsAttempt || connectionStatsTeardown) return; + // Reserve before the public method getter, invocation, or busy setters can reenter. + const attempt = connectionStatsAttempt = {}; + connectionStatsFailed = false; connectionStatsRevision++; + const current = (): boolean => { + syncConnectionStats(); + return connectionStatsOwner === owner && connectionStatsAttempt === attempt && !connectionStatsTeardown; + }; + void (async () => { + try { + if (!current()) return; + const read = owner.client.getStats; + if (!current()) return; + if (typeof read !== 'function') throw new Error(); + const pending = read.call(owner.client); + const active = current(); + // Consume retired native work: a retained peer can still advance the SDK's shared delta baseline. + const raw: unknown = await pending; + if (!current() || !active) return; + const report = projectConnectionStats(raw, current); + if (!current() || !report) return; + connectionStatsReport = report; connectionStatsRevision++; + } catch { if (current()) { connectionStatsFailed = true; connectionStatsRevision++; } } + finally { + if (connectionStatsOwner === owner && connectionStatsAttempt === attempt) { + connectionStatsAttempt = undefined; connectionStatsRevision++; + } + syncConnectionStats(); + } + })(); +} let feedbackRevision = 0; let handRejectionOwner: { client: Client; roomId: string; participantId: string; generation: number } | undefined; function retireHandRejection(): void { @@ -415,6 +612,7 @@ function hideCancel(): void { cancelEntry = undefined; cancelJoin.hidden = cancelJoin.disabled = true; } function retireControls(): void { + retireConnectionStatsBinding(); controlsClient = undefined; roomCloseFence = undefined; retireRoomClose(); retireHandRejection(); retireHandQueue(); retireStageManagement(); pendingHand = undefined; pendingMic = undefined; pendingStage = undefined; micRetryParticipant = undefined; pendingMicRetry = undefined; @@ -422,6 +620,7 @@ function retireControls(): void { } /** Retire initial-join feedback before user or terminal callbacks can start another entry. */ function retireEntry(): number { + retireConnectionStatsBinding(); const generation = ++entryGeneration; retireOccupancy(); retireQuality(); retireSpeaking(); hideCancel(); submit.disabled = false; submit.setAttribute('aria-busy', 'false'); @@ -1146,6 +1345,7 @@ function signalingUrl(host: URL, value: string): URL { return url; } join.onsubmit = (event) => { + retireConnectionStatsBinding(); event.preventDefault(); const previous = client, generation = ++entryGeneration; retireOccupancy(); retireQuality(); retireSpeaking(); @@ -1174,6 +1374,7 @@ join.onsubmit = (event) => { renderOccupancy(); if (cancelled === entryGeneration) renderQuality(); if (cancelled === entryGeneration) renderSpeaking(); + finishConnectionStatsTeardown(cancelled); if (cancelled !== entryGeneration) return; try { const stopping = entryClient?.disconnect(); @@ -1192,6 +1393,7 @@ join.onsubmit = (event) => { renderOccupancy(); if (generation === entryGeneration) renderQuality(); if (generation === entryGeneration) renderSpeaking(); + finishConnectionStatsTeardown(generation); if (generation !== entryGeneration) return; await previous?.disconnect(); if (generation !== entryGeneration) return; @@ -1206,6 +1408,7 @@ join.onsubmit = (event) => { // This compact UI has no room-level meter; explicit preview APIs remain available. client = new library.StageClient({ url: ws.href, audioElement: audio, enableMicMeter: false }); const current = client; controlsClient = current; entryClient = current; decodeRoomName = library.decodeName; + connectionStatsBinding = { client: current, generation }; connectionStatsTeardown = false; maxGainDb = typeof library.MAX_GAIN_DB === 'number' && Number.isFinite(library.MAX_GAIN_DB) && library.MAX_GAIN_DB > 0 ? library.MAX_GAIN_DB : undefined; syncUnlock(); @@ -1247,6 +1450,7 @@ join.onsubmit = (event) => { leave.disabled = false; renderOccupancy(); if (renderingEntry === entryGeneration && client === current && controlsClient === current) renderQuality(); if (renderingEntry === entryGeneration && client === current && controlsClient === current) renderSpeaking(); + if (renderingEntry === entryGeneration && client === current && controlsClient === current) syncConnectionStats(); } }); client.on('quality', ({ detail }) => { @@ -1346,6 +1550,8 @@ join.onsubmit = (event) => { }); client.on('status', ({ detail }) => { if (client !== current || cancelledEntry || generation !== entryGeneration) return; + if (detail === 'disconnected') retireConnectionStatsBinding(); + else if (detail === 'reconnecting') retireConnectionStats(); if (detail === 'disconnected') { occupancyRetiredGeneration = generation; retireOccupancy(); retireQuality(); retireSpeaking(); } else { reconcileOccupancy(); @@ -1362,6 +1568,7 @@ join.onsubmit = (event) => { if (renderingEntry === entryGeneration && client === current) renderOccupancy(); if (renderingEntry === entryGeneration && client === current) renderQuality(); if (renderingEntry === entryGeneration && client === current) renderSpeaking(); + if (renderingEntry === entryGeneration && client === current) finishConnectionStatsTeardown(renderingEntry); }); client.on('error', ({ detail }) => { if (client !== current || cancelledEntry || generation !== entryGeneration) return; progress = false; report(detail); }); client.on('audioblocked', () => { if (cancelledEntry) return; progress = false; status.textContent = '請點擊啟用音訊。'; }); @@ -1372,6 +1579,7 @@ join.onsubmit = (event) => { renderOccupancy(); if (retired === entryGeneration && client === current) renderQuality(); if (retired === entryGeneration && client === current) renderSpeaking(); + if (retired === entryGeneration && client === current) finishConnectionStatsTeardown(retired); if (retired !== entryGeneration || client !== current) return; status.textContent = detail.reason === 'shutdown' ? '伺服器維護或重新啟動,房間已關閉。' : '房間已關閉。'; hand.disabled = leave.disabled = true; }); @@ -1382,6 +1590,7 @@ join.onsubmit = (event) => { renderOccupancy(); if (retired === entryGeneration && client === current) renderQuality(); if (retired === entryGeneration && client === current) renderSpeaking(); + if (retired === entryGeneration && client === current) finishConnectionStatsTeardown(retired); if (retired !== entryGeneration || client !== current) return; status.textContent = '你已被主控移出房間。'; hand.disabled = leave.disabled = true; }); @@ -1597,6 +1806,7 @@ leave.onclick = () => { renderOccupancy(); if (generation === entryGeneration && client === current) renderQuality(); if (generation === entryGeneration && client === current) renderSpeaking(); + if (generation === entryGeneration && client === current) finishConnectionStatsTeardown(generation); if (generation !== entryGeneration || client !== current) return; void current?.disconnect().then(() => { if (generation !== entryGeneration || client !== current) return; @@ -1605,3 +1815,6 @@ leave.onclick = () => { }; replaceOutputOptions([]); syncHand(); syncMic(); syncStage(); syncMicRetry(); syncRename(); syncRoomName(); syncHandQueue(); syncStageManagement(); syncRoomClose(); syncMicrophone(); syncAudioRetry(); syncOutput(); syncUnlock(); + +connectionStatsUpdate.onclick = refreshConnectionStats; +syncConnectionStats(); From deffd9c286861a6689c3491c04f7c8b8a4cf28c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=A5=E8=AA=9E=E3=81=BB=E3=81=97=E3=81=9E=E3=82=89?= <92282870+YueyuHoshizora@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:04:07 +0800 Subject: [PATCH 11/38] fix(client): preserve observed stats consent loss (batch 205) --- PLAN.md | 3 + README.md | 2 +- packages/client/README.md | 2 +- packages/client/src/element.ts | 48 +++++--- packages/client/test/element.test.ts | 172 +++++++++++++++++++++++++++ 5 files changed, 210 insertions(+), 17 deletions(-) diff --git a/PLAN.md b/PLAN.md index bde34f7..f9d197b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -850,3 +850,6 @@ N 路混音管線(decode→mix→encode)、不含自己的台上混音、lim 225. **最小 embed 手動本人連線統計(第204批,2026-10-16):** immutable203 完整 embed/實際 SDK 證明缺少手動 getStats 入口,觀眾無 publisher 品質回報但可讀自身 peer 統計。新增獨立普通面板與明確按鈕,僅 receive loss/RTT/buffer、send loss/RTT 五指標;零保留、非法/缺失 metric 為破折號、方向缺少為暫無、畸形 envelope/direction 或 throwing getter 固定局部失敗。只存有限 primitive;不顯示 raw/codec/counter/其他人資訊,不改全域 error/quality/speaking。標示上次手動歷史與 SDK 取樣區間,last matching audio RTP 非 aggregate,其他呼叫者共用基準,RTT 非端到端/可聽保證。stats-only captured client/generation controls binding 在新 client 綁定時建立;join/retireEntry/retireControls/disconnected 先純退役,不能把舊 controls 套上新 entry 世代。reconnect 保留 binding 但清 owner/attempt/history,之後有效席位明確 click 可讀,不需新 passive state;disconnected controls 不自行復活。room/participant 更換隔離舊結果,同席 role/stage/mic 變更保留。方法 getter 前 reserve,lookup/call/settle/projection/每個新 DOM setter 後核對 token/binding/owner,舊 native promise 不能解鎖或覆寫新 entry;不宣稱取消其 SDK 取樣或共用基準更新。fixed DOM 用 exact document query identity+panel containment 防 detached/replaced action;departure 純階段不新 paint,Close/Transfer consent 核對先於新/修正 DOM,nested 新 winner 與 silent loss 都依當前 owner 調和,不廣修既有生命週期。十檔限定 HTML/embed source/新測試/五 fixture map 精確新增節點與 README/PLAN,原 assertions 保留;無 SDK/server/widget/media/protocol/config/依賴變更。無 timer/poll/auto read/retry/timeout/新媒體或權限操作;native browser/AT/硬體音訊未驗收。 + + +226. **Widget 手動統計保留已觀察的確認失效(第205批,2026-10-16):** immutable204 完整 StageElement+實際 SDK 六例證明新 stats reconciliation 讀到 reconnect/roomId/participantId 失效後,下一個公開 getter 恢復舊值,首輪 consistency continue 先於原 Close/Transfer helper,舊 pending consent 未 invalid 且各送出一次;既有100 stats測試全過,屬未覆蓋同步回呼排序缺口。僅新 stats reconcile/局部支援捕獲精確 pending token 與 authority,確實讀到的 status/身分失效先純標記原 token,再任何重讀/continue/DOM;recheck/throwing getter 同樣隔離。不同 nested 新 token/新合法 entry 不受舊 observation 波及,不以 stale note 作用到新 tenure。保留有效 Close/Transfer、觀眾閱讀統計、原 lifecycle/共享SDK基準/local feedback/departure paint fence;不改既有權限 helper 本體或一般命令政策、不加入 role/connected stats gate。五檔 source/append-only test+三文件,無 SDK/server/media/protocol/依賴變更。測試須含六反例、有效確認、較新token/同client合法重入、重讀/例外與舊promise/finally隔離;原生browser/AT/硬體音訊未驗收。 diff --git a/README.md b/README.md index 092d500..1d9bbe1 100644 --- a/README.md +++ b/README.md @@ -267,7 +267,7 @@ Web Component 另以普通文字顯示最近收到的發言者回報,按目前 Web Component 的台上者收到伺服器本人品質回報後,可看到最近收到的上/下行掉包率及 RTT;只顯示本人,觀眾/台下主控不顯示。缺失值為「—」,不代表 0;RTT 並非端到端音訊延遲。重連、本人下台/轉為觀眾、換席或終止會清除舊摘要;須先收到目前 client 的新權威台上 state,再收到新的本人報告才重新顯示。單獨 status 恢復、角色通知或重放已接收/拒絕的事件物件不能還原。相同 client 合法離房重入仍可由新 state 與新報告恢復。麥克風錯誤僅清除報告,保留合格台位的接收資格,因此不必另等 state,後續新本人報告可顯示;`micready` 不會還原。報告沒有時間戳或 peer 世代,因此數字不是即時健康或麥克風恢復證明;不增加輪詢或音訊操作。 -Web Component 另有「更新我的連線統計」,觀眾與台上者都可手動讀取本人瀏覽器的接收掉包率/RTT/緩衝及傳送掉包率/RTT。只在明確按下時呼叫既有 `getStats()`,沒有輪詢或自動更新;數字標示為上次手動讀取及 SDK 取樣區間,RTT 不是端到端音訊延遲,也不證明目前可聽。每方向沿用 SDK 最後一筆符合的音訊 RTP 統計,並非全部串流加總;其他 `getStats()` 呼叫共用差值基準,因此區間不是兩次按鈕間隔。等待與讀取失敗保留上次結果,錯誤只顯示固定本地說明;缺失指標為「—」,沒有該方向則顯示暫無統計。重連、離房、終止或換席清除舊結果;恢復有效席位後可重新手動讀取,不自動恢復舊數字。既有原生讀取無取消能力,保留 peer 的遲到完成仍可能推進 SDK 共用基準,但不能回寫已退役 UI。不改原有品質回報、媒體或權限,原生瀏覽器/輔助科技與實體音訊未驗收。 +Web Component 另有「更新我的連線統計」,觀眾與台上者都可手動讀取本人瀏覽器的接收掉包率/RTT/緩衝及傳送掉包率/RTT。只在明確按下時呼叫既有 `getStats()`,沒有輪詢或自動更新;數字標示為上次手動讀取及 SDK 取樣區間,RTT 不是端到端音訊延遲,也不證明目前可聽。每方向沿用 SDK 最後一筆符合的音訊 RTP 統計,並非全部串流加總;其他 `getStats()` 呼叫共用差值基準,因此區間不是兩次按鈕間隔。等待與讀取失敗保留上次結果,錯誤只顯示固定本地說明;缺失指標為「—」,沒有該方向則顯示暫無統計。重連、離房、終止或換席清除舊結果;恢復有效席位後可重新手動讀取,不自動恢復舊數字。既有原生讀取無取消能力,保留 peer 的遲到完成仍可能推進 SDK 共用基準,但不能回寫已退役 UI。手動讀取的同步回呼若已觀察到重連或席位身分失效,當時尚未完成的關房/移交確認即失效;稍後 getter 恢復原值也不能沿用舊確認,但較新合法進房與重新確認不受舊操作影響。不改原有品質回報、媒體或權限,原生瀏覽器/輔助科技與實體音訊未驗收。 Web Component 的「離開房間」會停止本機收聽/擷取及自動重連,保留屬性與音量/收聽靜音偏好;同一元素更換屬性或移除再掛載後仍等待手動「重新加入」。重入採用最新屬性,不保證原台位/主控身分;關房/被踢不提供這個重入操作。離房先退休 client,因此與元件移除一樣不轉送其晚到事件(含 disconnect status),也不新增合成 SDK 狀態。此 UI 使用既有 disconnect,不代表伺服器房間立即關閉;受控 DOM/SDK 假媒體測試仍不等於真實瀏覽器音訊驗收。 diff --git a/packages/client/README.md b/packages/client/README.md index 18f54ea..c35884f 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -217,7 +217,7 @@ WebSocket 建構失敗、hello 逾時或尚未進房的連線關閉,也會釋 - 發言者摘要:所有已進房者可看到最近收到的伺服器發言偵測回報,按目前台上順序列出名稱與人數。尚未收到時顯示等待,明確空集合才顯示「未偵測到發言」;回報中的 ID 無法對應台上成員時另作說明,不公開未知 ID 或觀眾名稱。名稱依目前快照安全顯示,改名與移出台上會同步更新;已移除或當下無法對應的 ID 不會因之後上台而復活舊活動,須等新回報;控制連線暫時斷開但仍在台上的寬限席位不因此排除。重連、終止連線、離房或換席清除舊集合,只有新的目前權威 state 才重新接納後續新回報;status、任意重繪與已接收/拒絕的事件物件重放不開啟接收資格,不從 SDK 保留的 getter 還原。新身分快照在 SDK status 回呼先可讀時,仍可先顯示等待,但這個提示不授權提前採用回報。公開同 client 合法斷線/終止後重新加入同房同身分仍可由新 state 與新回報恢復。本人或他人的下台/轉觀眾通知先移除並暫時抑制該 ID;新的權威快照核對前,新回報不能借用舊台上名單,解除抑制也不重加舊活動,須之後新回報才重加。本人轉為觀眾仍可讀其他台上者,micerror/micready/收聽設定不冒充房間活動變化。這是伺服器活動偵測(VAD),沒有時間戳,不保證即時或實際可聽見音訊;空集合也不是無音訊的證明。本機收聽靜音、音量、自動播放與傳輸狀態可能不同。不增加輪詢或媒體操作,原 `stage-speaking` detail 仍原樣轉送;摘要為普通文字,未新增頻繁宣讀的 live region。可用 `::part(speaking)` 設定外觀;原生畫面、輔助科技與實體音訊仍需驗收。 - 本人連線品質:台上者收到自己的 `quality` 報告後,顯示最近收到的上/下行掉包率及 RTT;缺失或無效值顯示「—」,0 仍顯示 0,不據此推斷健康。只取本人 ID,不顯示其他發言者資料;觀眾與台下主控隱藏。重連、本人下台/轉為觀眾、離房/換席或終止會撤銷舊接收資格並清空;新的本人報告須在目前 client 的新權威台上 state 之後才可採用。status、角色/上台通知與任意重繪不開啟資格,已接收或拒絕的 state/quality 事件物件重放也不算新收件。公開 client 合法斷線後再加入同房同身分仍支援,新 state 加新報告即可顯示,並非永久封鎖該 client。房間麥克風錯誤僅清空報告,仍合格時可直接接收下一份新本人報告而不需另等 state;`micready` 不還原舊值。一般同席快照及保留台位的主控/講者移交保留已收到的數字。這是歷史回報而非即時狀態,RTT 不是端到端音訊延遲;事件沒有時間戳/peer 世代,麥克風錯誤後才到達的報告也不證明已恢復。伺服器無新報告時不自動推測過期或良好。沒有額外輪詢、媒體操作或控制請求,`stage-quality` 照原物件轉送;原生畫面、輔助科技與音訊仍需驗收。 -- 手動本人連線統計:所有有效已進房席位皆可按「更新我的連線統計」,明確呼叫一次既有 `getStats()`;觀眾也可讀取,不以台位、麥克風或 connected 旗標過濾。顯示接收掉包率/RTT/緩衝及傳送掉包率/RTT,掉包率一位小數、RTT 秒轉整數毫秒、緩衝沿用毫秒;0 保留,缺失/無效數值為「—」,缺少方向顯示暫無統計。每方向是 SDK 最後一筆符合的音訊 RTP,並非全部串流加總。不展示 bitrate、jitter、codec、原始報告或例外。數字是上次手動讀取的歷史結果,標示 SDK 取樣區間;其他呼叫者共用 SDK 差值基準,區間不等於兩次按鈕間隔,RTT 也不是端到端音訊延遲。等待時停用按鈕並標示 aria-busy,保留歷史;失敗顯示本功能固定說明並保留歷史,不覆寫全域錯誤、品質或發言摘要。重連/斷線/終止/離房/卸載/換席撤銷舊讀取和結果;恢復有效席位後可明確再讀,不需等待新 state,也不由 status 自動讀取。一般同席角色、台位或麥克風變更保留歷史。原生 promise 無取消能力,保留 peer 的舊讀取仍可能推進 SDK 共用基準;UI 只隔離舊結果,沒有新增 timeout、重試或基準重設。沒有輪詢、媒體、播放、裝置或權限操作,普通文字不新增 live region;可用 `::part(connection-stats)` 設定外觀。受控 SDK/DOM 驗證不等於原生版面、鍵盤、AT 或實際音訊驗收。 +- 手動本人連線統計:所有有效已進房席位皆可按「更新我的連線統計」,明確呼叫一次既有 `getStats()`;觀眾也可讀取,不以台位、麥克風或 connected 旗標過濾。顯示接收掉包率/RTT/緩衝及傳送掉包率/RTT,掉包率一位小數、RTT 秒轉整數毫秒、緩衝沿用毫秒;0 保留,缺失/無效數值為「—」,缺少方向顯示暫無統計。每方向是 SDK 最後一筆符合的音訊 RTP,並非全部串流加總。不展示 bitrate、jitter、codec、原始報告或例外。數字是上次手動讀取的歷史結果,標示 SDK 取樣區間;其他呼叫者共用 SDK 差值基準,區間不等於兩次按鈕間隔,RTT 也不是端到端音訊延遲。等待時停用按鈕並標示 aria-busy,保留歷史;失敗顯示本功能固定說明並保留歷史,不覆寫全域錯誤、品質或發言摘要。重連/斷線/終止/離房/卸載/換席撤銷舊讀取和結果;恢復有效席位後可明確再讀,不需等待新 state,也不由 status 自動讀取。一般同席角色、台位或麥克風變更保留歷史。原生 promise 無取消能力,保留 peer 的舊讀取仍可能推進 SDK 共用基準;UI 只隔離舊結果,沒有新增 timeout、重試或基準重設。統計核對已讀到目前連線/房間/本人身分失效時,會在下一個可重入 getter 前撤銷當時的舊關房/移交確認;同步回呼把狀態改回來也不能復活該確認。只影響精確的原操作,較新合法席位/新確認仍依原權限判斷,未改其他命令政策。沒有輪詢、媒體、播放、裝置或權限操作,普通文字不新增 live region;可用 `::part(connection-stats)` 設定外觀。受控 SDK/DOM 驗證不等於原生版面、鍵盤、AT 或實際音訊驗收。 - 舉手佇列位置來自目前 `state.hands` 的本人 ID;快照更新時重算,未找到本人則只顯示「已舉手」,重連期間標示「上次佇列」。保留寬限期的斷線者仍在佇列中;主控可任意核准人選,因此這不是保證的發言順序或等待時間。 - 輸出裝置選擇:已進房時可在「輸出裝置選擇(草稿)」手動重新整理揚聲器/耳機清單,再明確送出選擇;選單變更本身不呼叫 setSinkId。空字串沿用既有 SDK 的系統預設輸出,不支援 outputDeviceSupported 的瀏覽器顯示說明並停用此組控制。名稱以 textContent 顯示,匿名裝置有通用名稱;重新整理後缺失的草稿保留為未列出項,不偷偷改用其他裝置。重新整理與送出共用等待鎖,送出完成只恢復控制,草稿不表示目前實際輸出、已套用或可聽音訊;不會自動 enumerate、play、開麥克風或儲存裝置 ID,也不另呼叫權限申請 API;setSinkId 的裝置存取與權限仍由瀏覽器決定。 輸出設定作用於同一 client 的 audio 元件,因此等待/草稿/清單按 client 生命週期保留,而非按 room/participant 重設。公開 client 斷線、關房、換席或重入相同/不同房間時,沒有有效房間便隱藏停用;重新取得快照仍要等原輸出操作完成才可再次送出。一般重連保留顯示但停用。真正更換 client 或移除元件才退役舊 UI,既有 setSinkId 無取消 API,不能取消已交給瀏覽器的操作;舊 client 的遲到完成不操作新 client 的控制。永不完成的操作持續等待,沒有新增 timeout/強制解除;目前有效連線且可見 owner 的失敗可明確重試,隱藏或重連等待期間的失敗不另冒出錯誤。宿主直接呼叫 element.client.setOutputDevice 不經此 UI 鎖,不能保證與宿主並行選擇的先後結果。可用 ::part(output-device) 設定外觀;驗證限公開 SDK 與受控 DOM/audio/socket,原生裝置、權限、鍵盤、AT 與實際播放未驗收。 diff --git a/packages/client/src/element.ts b/packages/client/src/element.ts index 99eb29a..ee3e228 100644 --- a/packages/client/src/element.ts +++ b/packages/client/src/element.ts @@ -874,15 +874,40 @@ export class StageElement extends HTMLElement { && this.connectionStatsPanel.contains(this.connectionStatsUpdate) && this.connectionStatsPanel.contains(this.connectionStatsText); } + private connectionStatsIdentityReader() { + const stats = this.connectionStatsOwner; + const close = this.roomCloseAttempt, closeOwner = close?.owner; + const transfer = this.stageTransferAttempt, transferOwner = this.stageManagementOwner; + const observe = (field: string, value: unknown): void => { + const lost = (owner: NonNullable): boolean => + owner.client !== this.current || owner.generation !== this.generation || field === 'unreadable' + || ((field === 'state' || field === 'me') && !value) + || (field === 'status' && value !== 'waiting' && value !== 'live') + || (field === 'roomId' && value !== owner.roomId) || (field === 'participantId' && value !== owner.participantId); + // Consume each observation before another public getter can restore identity or admit a new dialog. + if (close && (!closeOwner || lost(closeOwner))) close.invalid = true; + if (transfer && (!transferOwner || lost(transferOwner))) transfer.invalid = true; + if (stats && this.connectionStatsOwner === stats && lost(stats)) this.retireConnectionStats(); + }; + return (field: 'state' | 'me' | 'status' | 'roomId' | 'participantId', get: () => T): T => { + let value: T; + try { value = get(); } + catch (error) { observe('unreadable', undefined); throw error; } + observe(field, value); return value; + }; + } + /** Manual readership needs a current seat, not a publisher, microphone, or new passive report. */ private reconcileConnectionStats(): void { for (;;) { const owner = this.connectionStatsOwner, client = this.current, generation = this.generation; - const state = client?.state, me = client?.me, status = client?.status; - const roomId = state?.roomId, participantId = me?.participantId; + const read = this.connectionStatsIdentityReader(); + const state = read('state', () => client?.state), me = read('me', () => client?.me), status = read('status', () => client?.status); + const roomId = read('roomId', () => state?.roomId), participantId = read('participantId', () => me?.participantId); if (owner !== this.connectionStatsOwner || client !== this.current || generation !== this.generation - || state !== client?.state || status !== client?.status || roomId !== client?.state?.roomId - || participantId !== client?.me?.participantId) continue; + || state !== read('state', () => client?.state) || status !== read('status', () => client?.status) + || roomId !== read('roomId', () => read('state', () => client?.state)?.roomId) + || participantId !== read('participantId', () => read('me', () => client?.me)?.participantId)) continue; const eligible = Boolean(client && state && me && typeof roomId === 'string' && typeof participantId === 'string' && (status === 'waiting' || status === 'live') && this.connectionStatsTerminalClient !== client && !this.connectionStatsLeaving && !this.leftRoom && !this.roomExit && this.connectionStatsMounted()); @@ -890,21 +915,14 @@ export class StageElement extends HTMLElement { || owner.roomId !== roomId || owner.participantId !== participantId)); if (lost) this.retireConnectionStats(); const retained = this.connectionStatsOwner; - const authorities = [this.roomCloseOwner, this.stageManagementOwner]; - const authorityLost = authorities.some(authority => authority && (!eligible || authority.client !== client - || authority.generation !== generation || authority.roomId !== roomId || authority.participantId !== participantId - || me?.role !== 'controller' || me.connected !== true)); - // Preserve ordinary helper traces; only observed loss needs corrective authority notes. - if (lost || authorityLost) { - this.noteRoomCloseEvent('status', status ?? 'disconnected'); - this.noteStageTransferEvent('status', status ?? 'disconnected'); - } this.reconcileRoomClose(); const transfer = this.stageTransferAttempt; if (transfer && !this.canTransferStage(transfer.id, transfer.node)) transfer.invalid = true; if (retained !== this.connectionStatsOwner || client !== this.current || generation !== this.generation - || state !== client?.state || status !== client?.status || roomId !== client?.state?.roomId - || participantId !== client?.me?.participantId || eligible !== Boolean(client && state && me + || state !== read('state', () => client?.state) || status !== read('status', () => client?.status) + || roomId !== read('roomId', () => read('state', () => client?.state)?.roomId) + || participantId !== read('participantId', () => read('me', () => client?.me)?.participantId) + || eligible !== Boolean(client && state && me && typeof roomId === 'string' && typeof participantId === 'string' && (status === 'waiting' || status === 'live') && this.connectionStatsTerminalClient !== client && !this.connectionStatsLeaving && !this.leftRoom && !this.roomExit && this.connectionStatsMounted())) continue; diff --git a/packages/client/test/element.test.ts b/packages/client/test/element.test.ts index 52e50db..300ce6c 100644 --- a/packages/client/test/element.test.ts +++ b/packages/client/test/element.test.ts @@ -13902,3 +13902,175 @@ describe('widget manual own connection statistics', () => { }); } }); + +describe('widget stats preserves observed consent loss', () => { + type Widget = InstanceType; + type Action = 'close' | 'transfer'; + type Loss = 'status' | 'roomId' | 'participantId'; + type Entry = Record; + const report = (timestamp = 1000) => new Map([ + ['in', { id: 'in', type: 'inbound-rtp', kind: 'audio', timestamp, bytesReceived: timestamp, + packetsReceived: timestamp / 10, packetsLost: 2, jitterBufferDelay: 2, jitterBufferEmittedCount: 100 }], + ['pair', { id: 'pair', type: 'candidate-pair', state: 'succeeded', nominated: true, currentRoundTripTime: .157 }], + ]) as unknown as RTCStatsReport; + const token = (element: Widget, action: Action) => action === 'close' ? element['roomCloseAttempt'] : element['stageTransferAttempt']; + const button = (element: Widget, action: Action) => action === 'close' ? element['roomClose'] : element['stageManagementRows'].get('target')!.transfer; + const refresh = (element: Widget) => click(element['connectionStatsUpdate']); + function pending() { + let resolve!: (value: T) => void, reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; + } + async function joined(t: TestContext) { + const f = await subscribedWidget(t), room = roomState(true); + room.me.role = 'controller'; room.controllerId = 'me'; + room.speakers = [{ ...room.me }, { ...room.me, participantId: 'target', name: 'Speaker', role: 'speaker' }]; + await f.client['receive']({ type: 'room:state', ...room }); + return { ...f, room }; + } + function accessor(t: TestContext, target: object, property: string, get: () => unknown) { + const descriptor = Object.getOwnPropertyDescriptor(target, property); + Object.defineProperty(target, property, { configurable: true, get }); + t.after(() => { if (descriptor) Object.defineProperty(target, property, descriptor); else Reflect.deleteProperty(target, property); }); + } + const boundary = (client: StageClient, loss: Loss) => loss === 'status' ? client : loss === 'roomId' ? client.state! : client.me!; + + for (const action of ['close', 'transfer'] as const) for (const loss of ['status', 'roomId', 'participantId'] as const) + for (const readAt of [1, 2]) for (const throws of [false, true]) { + it(`${action} consumes ${loss} ${throws ? 'throw' : 'loss'} on identity read ${readAt} before restoration or a stats request`, async t => { + const { element, client } = await joined(t), target = boundary(client, loss), good = Reflect.get(target, loss); + let armed = false, gets = 0, observations = 0, nextReadInvalid: boolean | undefined, nativeReads = 0; + let old: ReturnType; + client['pc'] = { close() {}, getStats: async () => { nativeReads++; return report(); } } as unknown as RTCPeerConnection; + accessor(t, target, loss, () => { + if (armed) { + gets++; + if (gets === readAt + 1) nextReadInvalid = old?.invalid; + if (gets === readAt) { observations++; if (throws) throw new Error('private identity getter'); return loss === 'status' ? 'reconnecting' : 'different'; } + } + return good; + }); + accessor(t, client, 'getStats', () => { armed = true; return StageClient.prototype.getStats; }); + const send = t.mock.method(client, action === 'close' ? 'closeRoom' : 'transferControl', async () => {}); + t.mock.method(window, 'confirm', () => { + old = token(element, action); assert.ok(old); assert.equal(old.invalid, false); + refresh(element); assert.equal(old.invalid, true); return true; + }); + click(button(element, action)); await settle(); + assert.equal(observations, 1); assert.ok(gets > readAt); assert.equal(nextReadInvalid, true); + assert.equal(old?.invalid, true); assert.equal(send.mock.callCount(), 0); assert.equal(nativeReads, 0); + assert.equal(element['connectionStatsAttempt'], undefined); assert.equal(element['connectionStatsPanel'].hidden, false); + assert.doesNotMatch(element['connectionStatsText'].textContent!, /private|失敗/); + }); + } + + for (const action of ['close', 'transfer'] as const) { + it(`valid ${action} consent survives a complete actual SDK stats read without media or extra control work`, async t => { + const { element, client } = await joined(t); let nativeReads = 0, old: ReturnType; + client['pc'] = { close() {}, getStats: async () => { nativeReads++; return report(); } } as unknown as RTCPeerConnection; + const send = t.mock.method(client, action === 'close' ? 'closeRoom' : 'transferControl', async () => {}); + const request = t.mock.method(client as unknown as { request(): never }, 'request', () => assert.fail('no unrelated request')); + const capture = t.mock.method(client as unknown as { acquireMic(): never }, 'acquireMic', () => assert.fail('no capture')); + const negotiate = t.mock.method(client as unknown as { negotiate(): never }, 'negotiate', () => assert.fail('no negotiation')); + const play = t.mock.method(client['audio'], 'play'), pause = t.mock.method(client['audio'], 'pause'); + const timeout = t.mock.method(globalThis, 'setTimeout'), interval = t.mock.method(globalThis, 'setInterval'); + t.mock.method(window, 'confirm', () => { + old = token(element, action); assert.ok(old); refresh(element); + assert.equal(token(element, action), old); assert.equal(old.invalid, false); return true; + }); + click(button(element, action)); await settle(); await settle(); + assert.equal(send.mock.callCount(), 1); assert.equal(nativeReads, 1); assert.equal(old?.invalid, false); + assert.match(element['connectionStatsText'].textContent!, /157 ms/); + for (const spy of [request, capture, negotiate, play, pause, timeout, interval]) assert.equal(spy.mock.callCount(), 0); + }); + + for (const loss of ['status', 'roomId', 'participantId'] as const) { + it(`new ${action} consent admitted within an old ${loss} getter is not invalidated by its stale return`, async t => { + const { element, client } = await joined(t), target = boundary(client, loss), good = Reflect.get(target, loss), actionResult = deferred(); + let armed = true, fresh: ReturnType, nativeReads = 0; + client['pc'] = { close() {}, getStats: async () => { nativeReads++; return report(); } } as unknown as RTCPeerConnection; + const send = t.mock.method(client, action === 'close' ? 'closeRoom' : 'transferControl', () => actionResult.promise); + t.mock.method(window, 'confirm', () => { fresh = token(element, action); assert.ok(fresh); return true; }); + accessor(t, target, loss, () => { + if (!armed) return good; armed = false; click(button(element, action)); + return loss === 'status' ? 'reconnecting' : 'different'; + }); + refresh(element); await settle(); await settle(); + assert.equal(send.mock.callCount(), 1); assert.equal(nativeReads, 1); + assert.ok(fresh); assert.equal(fresh.invalid, false); assert.equal(token(element, action), fresh); + actionResult.reject(new Error('New valid action failure')); await settle(); + assert.match(element['error'].textContent!, /New valid action failure/); + }); + } + + for (const completion of ['resolve', 'reject'] as const) { + it(`old pending ${action} ${completion} cannot clear the valid token created by a genuine same-client entry during stats observation`, async t => { + const { element, client, room } = await joined(t), first = deferred(), second = deferred(); + let sends = 0, armed = true, fresh: ReturnType; + t.mock.method(client, action === 'close' ? 'closeRoom' : 'transferControl', () => ++sends === 1 ? first.promise : second.promise); + t.mock.method(window, 'confirm', () => { fresh = token(element, action); return true; }); + click(button(element, action)); const old = token(element, action); assert.ok(old); + const oldStats = element['connectionStatsOwner']; + const oldOwner = action === 'close' ? element['roomCloseOwner'] : element['stageManagementOwner']; + const status = Object.getOwnPropertyDescriptor(StageClient.prototype, 'status')!.get!; + accessor(t, client, 'status', () => { + if (!armed) return status.call(client); armed = false; + void client.disconnect(); assert.equal(client.state, null); + void client['receive']({ type: 'room:state', ...room }); + assert.notEqual(element['connectionStatsOwner'], oldStats); + assert.notEqual(action === 'close' ? element['roomCloseOwner'] : element['stageManagementOwner'], oldOwner); + click(button(element, action)); return 'reconnecting'; + }); + refresh(element); await settle(); + assert.equal(sends, 2); assert.equal(old.invalid, true); assert.ok(fresh); assert.notEqual(fresh, old); + assert.equal(token(element, action), fresh); assert.equal(fresh.invalid, false); + if (completion === 'resolve') first.resolve(); else first.reject(new Error('Private old failure')); + await settle(); assert.equal(token(element, action), fresh); assert.equal(fresh.invalid, false); + assert.doesNotMatch(element['error'].textContent!, /Private old failure/); + second.reject(new Error('Current entry failure')); await settle(); + assert.match(element['error'].textContent!, /Current entry failure/); + }); + } + } + + for (const completion of ['resolve', 'reject'] as const) { + it(`observed stats retirement isolates the old native ${completion} and finally from a new pending read`, async t => { + const { element, client } = await joined(t), first = pending(), second = pending(); + let nativeReads = 0, armed = true; + client['pc'] = { close() {}, getStats: () => ++nativeReads === 1 ? first.promise : second.promise } as unknown as RTCPeerConnection; + refresh(element); const old = element['connectionStatsAttempt']; assert.ok(old); + accessor(t, client, 'status', () => { if (!armed) return 'live'; armed = false; return 'reconnecting'; }); + element['syncConnectionStats'](); assert.equal(element['connectionStatsAttempt'], undefined); + refresh(element); const fresh = element['connectionStatsAttempt']; assert.ok(fresh); assert.notEqual(fresh, old); + if (completion === 'resolve') first.resolve(report()); else first.reject(new Error('Private old stats failure')); + await settle(); await settle(); + assert.equal(nativeReads, 2); assert.equal(element['connectionStatsAttempt'], fresh); + assert.equal(element['connectionStatsUpdate'].disabled, true); assert.equal(element['connectionStatsUpdate'].getAttribute('aria-busy'), 'true'); + assert.equal(element['connectionStatsText'].textContent, '尚未手動讀取連線統計。'); + second.resolve(report(2000)); await settle(); await settle(); + assert.equal(element['connectionStatsAttempt'], undefined); assert.equal(element['connectionStatsUpdate'].disabled, false); + assert.match(element['connectionStatsText'].textContent!, /157 ms/); + }); + } + + for (const loss of ['status', 'roomId', 'participantId'] as const) { + it(`stale ${loss} returned after genuine reentry preserves its newer pending actual SDK read`, async t => { + const { element, client, room } = await joined(t), target = boundary(client, loss), good = Reflect.get(target, loss); + const result = pending(); let armed = true, nativeReads = 0; + let freshOwner: Widget['connectionStatsOwner'], freshAttempt: object | undefined; + accessor(t, target, loss, () => { + if (!armed) return good; armed = false; + void client.disconnect(); void client['receive']({ type: 'room:state', ...room }); + client['pc'] = { close() {}, getStats: () => { nativeReads++; return result.promise; } } as unknown as RTCPeerConnection; + refresh(element); freshOwner = element['connectionStatsOwner']; freshAttempt = element['connectionStatsAttempt']; + assert.ok(freshOwner); assert.ok(freshAttempt); return loss === 'status' ? 'reconnecting' : 'different'; + }); + refresh(element); await settle(); + assert.equal(nativeReads, 1); assert.equal(element['connectionStatsOwner'], freshOwner); + assert.equal(element['connectionStatsAttempt'], freshAttempt); assert.equal(element['connectionStatsUpdate'].getAttribute('aria-busy'), 'true'); + result.resolve(report()); await settle(); await settle(); + assert.match(element['connectionStatsText'].textContent!, /157 ms/); + assert.equal(element['connectionStatsUpdate'].getAttribute('aria-busy'), 'false'); + }); + } + +}); From ffef6814c6483cbb054ed45e899364d9d4c2e6b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=A5=E8=AA=9E=E3=81=BB=E3=81=97=E3=81=9E=E3=82=89?= <92282870+YueyuHoshizora@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:12:16 +0800 Subject: [PATCH 12/38] perf(client): skip equal playback ARIA writes (batch 206) --- PLAN.md | 3 + README.md | 2 + packages/client/README.md | 1 + packages/client/src/element.ts | 6 +- packages/client/test/element.test.ts | 247 +++++++++++++++++++++++++++ 5 files changed, 257 insertions(+), 2 deletions(-) diff --git a/PLAN.md b/PLAN.md index f9d197b..e424ee1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -853,3 +853,6 @@ N 路混音管線(decode→mix→encode)、不含自己的台上混音、lim 226. **Widget 手動統計保留已觀察的確認失效(第205批,2026-10-16):** immutable204 完整 StageElement+實際 SDK 六例證明新 stats reconciliation 讀到 reconnect/roomId/participantId 失效後,下一個公開 getter 恢復舊值,首輪 consistency continue 先於原 Close/Transfer helper,舊 pending consent 未 invalid 且各送出一次;既有100 stats測試全過,屬未覆蓋同步回呼排序缺口。僅新 stats reconcile/局部支援捕獲精確 pending token 與 authority,確實讀到的 status/身分失效先純標記原 token,再任何重讀/continue/DOM;recheck/throwing getter 同樣隔離。不同 nested 新 token/新合法 entry 不受舊 observation 波及,不以 stale note 作用到新 tenure。保留有效 Close/Transfer、觀眾閱讀統計、原 lifecycle/共享SDK基準/local feedback/departure paint fence;不改既有權限 helper 本體或一般命令政策、不加入 role/connected stats gate。五檔 source/append-only test+三文件,無 SDK/server/media/protocol/依賴變更。測試須含六反例、有效確認、較新token/同client合法重入、重讀/例外與舊promise/finally隔離;原生browser/AT/硬體音訊未驗收。 + + +227. **Widget 本機播放 ARIA 等值寫入省略(第206批,2026-10-16):** immutable205 重跑204發現證據,五例結果相同;限定 listen aria-pressed/volume aria-valuetext 兩個 setter 前讀目前 attribute 比較。實際 widget→公開SDK→validator→MockHub 的64秒普通presence/hand cooldown/mute序列72重繪,144等值ARIA寫入省略,新增144讀取/比較,216原text/value呼叫不變;四次slider8寫入降為4必要變更。120秒quiet keepalive原本0重繪,不宣稱idle或nativeCPU/time收益。五個hidden/disabled候選保持原setter,因effectiveBoolean相同仍可能需原字面canonicalization;textContent descendant替換、dirty input.value、style及所有lifecycle/媒體/控制政策不改。錯誤/缺失ARIA由live比較修復,等值observer通知刻意減少;不承諾任意宿主getter/setter副作用等價。五檔source兩site/append-only tests+三文件,完整原測試保留;actualSDK負載、必要變更、host repair、pending unlock/reconnect/retiredclient與Close/Transfer回歸納入驗證。無新timer/request/media/依賴;原生browser/AT/硬體音訊未驗收。 diff --git a/README.md b/README.md index 1d9bbe1..9a749ac 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,8 @@ Web Component 也顯示最近快照的房間席位:主控以外的總人數/ Web Component 另以普通文字顯示最近收到的發言者回報,按目前台上順序列出名稱與人數;尚未收到、明確空集合與無法對應台上成員分開呈現。改名或移出台上隨快照更新,控制連線寬限中仍在台上的成員不被排除;觀眾與未知 ID 不會洩露。重連、離房或換席清除舊集合,須新的目前權威 state 與後續新回報才恢復;status-only 與已接收/拒絕事件物件重放不恢復。本人或他人下台/轉觀眾通知先移除該 ID,新的權威快照核對前,即使新回報也不借用舊台上名單;重新核對後仍須下一份新回報才重加。本人下台只移除本人活動,不撤銷作為觀眾閱讀其他人回報的資格;合法同 client 斷線重入仍支援。這是伺服器活動偵測,沒有時間戳,並非即時或可聽音訊保證,空集合也不證明沒有音訊;不增加輪詢、媒體工作或 live region,原生輔助科技與實際音訊尚未驗收。 +Web Component 的本機收聽按鈕 `aria-pressed` 與音量滑桿 `aria-valuetext` 只在目前屬性文字不同時寫入;錯誤或缺失屬性仍會修復。其他 hidden/disabled、文字替換與滑桿 value 賦值保持原樣。受控實際 SDK/Hub 的 64 秒普通事件序列產生 72 次重繪,省去 144 次等值 ARIA setter,並新增 144 次屬性讀取;四次滑桿輸入由八次 ARIA 寫入降為四次必要變更。安靜的 120 秒 keepalive 原本就沒有重繪,這不是閒置 CPU 或原生瀏覽器速度改善的量測。等值屬性 observer 通知會減少;不承諾任意宿主 getter/setter 改寫的副作用等價,原生輔助科技未驗收。 + Web Component 的台上者收到伺服器本人品質回報後,可看到最近收到的上/下行掉包率及 RTT;只顯示本人,觀眾/台下主控不顯示。缺失值為「—」,不代表 0;RTT 並非端到端音訊延遲。重連、本人下台/轉為觀眾、換席或終止會清除舊摘要;須先收到目前 client 的新權威台上 state,再收到新的本人報告才重新顯示。單獨 status 恢復、角色通知或重放已接收/拒絕的事件物件不能還原。相同 client 合法離房重入仍可由新 state 與新報告恢復。麥克風錯誤僅清除報告,保留合格台位的接收資格,因此不必另等 state,後續新本人報告可顯示;`micready` 不會還原。報告沒有時間戳或 peer 世代,因此數字不是即時健康或麥克風恢復證明;不增加輪詢或音訊操作。 Web Component 另有「更新我的連線統計」,觀眾與台上者都可手動讀取本人瀏覽器的接收掉包率/RTT/緩衝及傳送掉包率/RTT。只在明確按下時呼叫既有 `getStats()`,沒有輪詢或自動更新;數字標示為上次手動讀取及 SDK 取樣區間,RTT 不是端到端音訊延遲,也不證明目前可聽。每方向沿用 SDK 最後一筆符合的音訊 RTP 統計,並非全部串流加總;其他 `getStats()` 呼叫共用差值基準,因此區間不是兩次按鈕間隔。等待與讀取失敗保留上次結果,錯誤只顯示固定本地說明;缺失指標為「—」,沒有該方向則顯示暫無統計。重連、離房、終止或換席清除舊結果;恢復有效席位後可重新手動讀取,不自動恢復舊數字。既有原生讀取無取消能力,保留 peer 的遲到完成仍可能推進 SDK 共用基準,但不能回寫已退役 UI。手動讀取的同步回呼若已觀察到重連或席位身分失效,當時尚未完成的關房/移交確認即失效;稍後 getter 恢復原值也不能沿用舊確認,但較新合法進房與重新確認不受舊操作影響。不改原有品質回報、媒體或權限,原生瀏覽器/輔助科技與實體音訊未驗收。 diff --git a/packages/client/README.md b/packages/client/README.md index c35884f..8e013c6 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -241,6 +241,7 @@ WebSocket 建構失敗、hello 逾時或尚未進房的連線關閉,也會釋 - 舉手/收回與麥克風靜音按鈕在接納 click 前重查目前席位、重連、冷卻與強制靜音限制。即使主頁的 `stage-state`/`stage-status` listener 在畫面重繪前同步點擊舊的 enabled 按鈕,已失效操作也不進入 pending、不清除目前錯誤、不送請求或占用 SDK 舉手冷卻;隱藏的這兩種控制同時停用。有效操作仍沿用目前快照選擇 raise/withdraw 或 mute/unmute,並保留各自等待 ACK 與錯誤處理。 - 連線結束提示:呼叫 `element.client.disconnect()` 會顯示「未連線」並隱藏房內操作;一般關房、被移出與伺服器維護/重啟各有提示,收聽偏好變更不會清掉提示。換房或重新掛載後清除舊提示,舊 client 的遲到事件不更新或重新派發到新 widget。 - 事件:所有 client 事件以 `stage-` 從元素派發(`bubbles`、`composed`,`detail` 相同),例如 `stage-state`、`stage-hand`、`stage-kicked`。 +- 本機播放控制僅對收聽 `aria-pressed` 與音量 `aria-valuetext` 做目前屬性值比較,等值時不重寫,缺失/錯誤值仍修復。hidden/disabled 的原始屬性修復、textContent 替換及 input.value 賦值照常;每次完整重繪增加兩次屬性讀取/比較,每次單獨音量同步增加一次。普通受控 SDK/Hub 72 次重繪省去 144 次等值 ARIA 寫入,四次滑桿輸入保留四次必要變更、移除四次等值寫入;安靜 keepalive 不產生此收益。這是 setter 計數,不是瀏覽器時間、CPU 或 AT 改善保證;等值 attribute observer 通知減少,不承諾任意宿主 getter/setter 副作用等價。 - 樣式:`::part(panel)`、`::part(status)`、`::part(error)`、`::part(error-dismiss)`、`::part(actions)`、`::part(playback)`、`::part(name)`、`::part(room-name)`、`::part(quality)`、`::part(speaking)`、`::part(connection-stats)`、`::part(audio-retry)`、`::part(audio-recovery)`,以及 CSS 變數 `--stage-fg`、`--stage-bg`、`--stage-border`、`--stage-radius`、`--stage-button-bg`、`--stage-accent`、`--stage-accent-fg`、`--stage-error`。 - 自訂標籤名:`import { defineStageElement } from 'https://host/lib/stage-element.js'` 後呼叫 `defineStageElement('my-stage')`(`stage-client` 已於載入時註冊,多個自訂標籤可並存)。每個自訂標籤使用 `StageElement` 的獨立子類別,因此仍是 `instanceof StageElement`,但 constructor 不與預設/其他標籤相同;重複註冊同名標籤為 no-op,既有其他元件不被覆寫。 diff --git a/packages/client/src/element.ts b/packages/client/src/element.ts index ee3e228..32b5c6d 100644 --- a/packages/client/src/element.ts +++ b/packages/client/src/element.ts @@ -2132,7 +2132,8 @@ export class StageElement extends HTMLElement { private renderVolume(): void { const percent = Math.round(this.playbackVolume * 100); this.volume.value = String(percent); - this.volume.setAttribute('aria-valuetext', `${percent}%`); + const valueText = `${percent}%`; + if (this.volume.getAttribute('aria-valuetext') !== valueText) this.volume.setAttribute('aria-valuetext', valueText); this.volumeValue.textContent = `${percent}%`; } @@ -2247,7 +2248,8 @@ export class StageElement extends HTMLElement { this.listen.hidden = !me; this.listen.disabled = !client || this.pendingActions.has(this.listen); this.listen.textContent = this.playbackMuted ? '恢復收聽' : '靜音收聽'; - this.listen.setAttribute('aria-pressed', String(this.playbackMuted)); + const pressed = String(this.playbackMuted); + if (this.listen.getAttribute('aria-pressed') !== pressed) this.listen.setAttribute('aria-pressed', pressed); this.playback.hidden = !me; this.volume.disabled = !client || !me; this.renderVolume(); diff --git a/packages/client/test/element.test.ts b/packages/client/test/element.test.ts index 300ce6c..d607806 100644 --- a/packages/client/test/element.test.ts +++ b/packages/client/test/element.test.ts @@ -14074,3 +14074,250 @@ describe('widget stats preserves observed consent loss', () => { } }); + +describe('widget local playback live ARIA guards', () => { + type Widget = InstanceType; + type Field = 'listen' | 'volume'; + const attribute = (field: Field) => field === 'listen' ? 'aria-pressed' : 'aria-valuetext'; + const raw = (element: Widget, field: Field) => Node.prototype.getAttribute.call(element[field], attribute(field)); + const corrupt = (element: Widget, field: Field, value: string | null) => { + const node = element[field] as unknown as Node; + if (value === null) node.attributes.delete(attribute(field)); else node.attributes.set(attribute(field), value); + }; + function audit(t: TestContext, element: Widget) { + const counts = { reads: 0, writes: 0, changed: 0, listenText: 0, volumeValue: 0, volumeText: 0 }; + for (const field of ['listen', 'volume'] as const) { + const node = element[field], key = attribute(field), get = node.getAttribute.bind(node), set = node.setAttribute.bind(node); + let setting = false; + t.mock.method(node, 'getAttribute', (name: string) => { + if (name === key && !setting) counts.reads++; + return get(name); + }); + t.mock.method(node, 'setAttribute', (name: string, value: string) => { + if (name === key) { counts.writes++; if (get(name) !== value) counts.changed++; } + setting = true; try { set(name, value); } finally { setting = false; } + }); + } + for (const [node, key, count] of [ + [element['listen'], 'textContent', 'listenText'], [element['volume'], 'value', 'volumeValue'], + [element['volumeValue'], 'textContent', 'volumeText'], + ] as const) { + let value = Reflect.get(node, key); + Object.defineProperty(node, key, { configurable: true, get: () => value, + set: (next: string) => { counts[count]++; value = next; } }); + } + return counts; + } + function once(t: TestContext, element: Widget, field: Field, method: 'getAttribute' | 'setAttribute', action: () => void) { + const node = element[field], original = node[method].bind(node); + let armed = true, calls = 0; + if (method === 'getAttribute') { + t.mock.method(node, 'getAttribute', (name: string) => { + if (armed && name === attribute(field)) { armed = false; calls++; action(); } + return (original as (name: string) => string | null)(name); + }); + } else { + t.mock.method(node, 'setAttribute', (name: string, value: string) => { + if (armed && name === attribute(field)) { armed = false; calls++; action(); } + return (original as (name: string, value: string) => void)(name, value); + }); + } + return () => calls; + } + + it('adds exactly two live reads per full render while retaining every text and value setter', async t => { + const { element, client } = await subscribedWidget(t), counts = audit(t, element); + element['render'](); + assert.deepEqual(counts, { reads: 2, writes: 0, changed: 0, listenText: 1, volumeValue: 1, volumeText: 1 }); + element['renderVolume'](); + assert.deepEqual(counts, { reads: 3, writes: 0, changed: 0, listenText: 1, volumeValue: 2, volumeText: 2 }); + const before = client.playbackVolume; + for (const value of [null, '', 'FALSE', 'incorrect']) { + corrupt(element, 'listen', value); corrupt(element, 'volume', value); + const writes = counts.writes, reads = counts.reads; element['render'](); + assert.equal(counts.writes - writes, 2); assert.equal(counts.reads - reads, 2); + assert.equal(raw(element, 'listen'), 'false'); assert.equal(raw(element, 'volume'), '100%'); + } + assert.equal(counts.writes, counts.changed); assert.equal(client.playbackVolume, before); + }); + + it('preserves preference getter and unconditional setter ordering at both guarded sites', async t => { + const { element } = await subscribedWidget(t), log: string[] = []; + const muted = Object.getOwnPropertyDescriptor(StageElement.prototype, 'playbackMuted')!.get!; + Object.defineProperty(element, 'playbackMuted', { configurable: true, get: () => { log.push('muted'); return muted.call(element); } }); + let text = element['listen'].textContent; + Object.defineProperty(element['listen'], 'textContent', { configurable: true, get: () => text, + set: (value: string) => { log.push('listen text'); text = value; } }); + const get = element['listen'].getAttribute.bind(element['listen']); + t.mock.method(element['listen'], 'getAttribute', (name: string) => { if (name === 'aria-pressed') log.push('pressed read'); return get(name); }); + element['render'](); assert.deepEqual(log, ['muted', 'listen text', 'muted', 'pressed read']); + log.length = 0; + const volume = Object.getOwnPropertyDescriptor(StageElement.prototype, 'playbackVolume')!.get!; + Object.defineProperty(element, 'playbackVolume', { configurable: true, get: () => { log.push('volume'); return volume.call(element); } }); + Object.defineProperty(element['volume'], 'value', { configurable: true, get: () => '100', set: () => { log.push('range value'); } }); + Object.defineProperty(element['volumeValue'], 'textContent', { configurable: true, get: () => '100%', set: () => { log.push('visible text'); } }); + const rangeGet = element['volume'].getAttribute.bind(element['volume']); + t.mock.method(element['volume'], 'getAttribute', (name: string) => { if (name === 'aria-valuetext') log.push('value text read'); return rangeGet(name); }); + element['renderVolume'](); assert.deepEqual(log, ['volume', 'range value', 'value text read', 'visible text']); + }); + + it('keeps boolean playback-muted presence and exact fractional volume preferences across rounded displays', async t => { + const { element, client } = await subscribedWidget(t), counts = audit(t, element); + element.setAttribute('playback-muted', 'false'); + assert.equal(element.playbackMuted, true); assert.equal(client.playbackMuted, true); assert.equal(raw(element, 'listen'), 'true'); + for (const value of [0, .0049, .005, .371, .372, .373, .374, .3749, .375, .99949, 1]) { + const before = raw(element, 'volume'), writes = counts.writes; + element.playbackVolume = value; + const desired = `${Math.round(value * 100)}%`; + assert.equal(raw(element, 'volume'), desired); assert.equal(counts.writes - writes, before === desired ? 0 : 1); + assert.equal(client.playbackVolume, value); assert.equal(element.getAttribute('playback-volume'), String(value)); + } + for (const value of ['', ' ', '\t\n', 'NaN', 'Infinity', '-Infinity', '1e309', 'invalid', '0.5oops', '-0.01', '1.01']) { + element.playbackVolume = .25; element.setAttribute('playback-volume', value); + assert.equal(raw(element, 'volume'), '100%'); assert.equal(element.playbackVolume, 1); assert.equal(client.playbackVolume, 1); + } + element.playbackVolume = .25; + for (const value of [NaN, Infinity, -Infinity, -.01, 1.01, '0.4', '', null, undefined, true, false, {}, [], 0n]) { + const before = { ...counts }; + assert.throws(() => { element.playbackVolume = value as number; }, RangeError); + assert.deepEqual(counts, before); assert.equal(raw(element, 'volume'), '25%'); + } + }); + + it('retains equal-text descendant replacement and same-value range dirty-setter effects', async t => { + const { element, client } = await subscribedWidget(t); + for (const target of [element['listen'], element['volumeValue']]) { + const node = target as unknown as Node; let text = node.textContent; + node.append(new Node()); + Object.defineProperty(node, 'textContent', { configurable: true, get: () => text, + set: (value: string) => { text = value; node.replaceChildren(); } }); + } + let value = element['volume'].value, dirty = false, writes = 0; + Object.defineProperty(element['volume'], 'value', { configurable: true, get: () => value, + set: (next: string) => { value = next; dirty = true; writes++; } }); + await client['receive']({ type: 'room:state', ...roomState() }); + assert.equal((element['listen'] as unknown as Node).children.length, 0); + assert.equal((element['volumeValue'] as unknown as Node).children.length, 0); + assert.equal(dirty, true); assert.ok(writes > 0); + }); + + it('retains all five boolean literal canonicalizers including until-found and pending unlock', async t => { + const { element, client } = await subscribedWidget(t), pending = deferred(); + t.mock.method(client, 'unlockAudio', () => pending.promise); + client.dispatchEvent(new CustomEvent('audioblocked', { detail: {} })); click(element['unlock']); + // Only these reflected properties are modeled; this is not a browser DOM implementation. + function reflect(node: HTMLElement, key: 'hidden' | 'disabled') { + const attributes = (node as unknown as Node).attributes; + if (Reflect.get(node, key)) attributes.set(key, ''); else attributes.delete(key); + Object.defineProperty(node, key, { configurable: true, + get: () => key === 'hidden' && attributes.get(key)?.toLowerCase() === 'until-found' ? 'until-found' : attributes.has(key), + set: (value: unknown) => { if (value) attributes.set(key, ''); else attributes.delete(key); } }); + return attributes; + } + const unlockAttributes = reflect(element['unlock'], 'disabled'); unlockAttributes.set('disabled', 'false'); + assert.equal(element['unlock'].disabled, true); element['render'](); assert.equal(element['unlock'].getAttribute('disabled'), ''); + pending.resolve(); await settle(); + Object.assign(element, { isConnected: false }); element.disconnectedCallback(); await settle(); + for (const [field, key] of [['listen', 'hidden'], ['playback', 'hidden'], ['listen', 'disabled'], ['volume', 'disabled']] as const) { + const node = element[field], attributes = reflect(node, key); attributes.set(key, 'not-canonical'); + assert.equal(Reflect.get(node, key), true); element['render'](); assert.equal(node.getAttribute(key), ''); + if (key === 'hidden') { attributes.set(key, 'until-found'); element['render'](); assert.equal(node.getAttribute(key), ''); } + } + }); + + it('does not admit a second listening action from its new live ARIA read while the first is pending', async t => { + const { element, client } = await subscribedWidget(t), apply = t.mock.method(client, 'setPlaybackMuted'); + const calls = once(t, element, 'listen', 'getAttribute', () => { + assert.equal(element['pendingActions'].has(element['listen']), true); click(element['listen']); + }); + click(element['listen']); assert.equal(calls(), 1); assert.equal(apply.mock.callCount(), 1); + await settle(); assert.equal(element['listen'].disabled, false); assert.equal(raw(element, 'listen'), 'true'); + }); + + for (const outcome of ['resolve', 'reject', 'throw'] as const) { + it(`retains synchronous unlock admission and ${outcome} settlement through redundant-render suppression`, async t => { + const { element, client } = await subscribedWidget(t), pending = deferred(); let inside = true; + const unlock = t.mock.method(client, 'unlockAudio', () => { + assert.equal(inside, true); if (outcome === 'throw') throw new Error('Unlock failure'); return pending.promise; + }); + client.dispatchEvent(new CustomEvent('audioblocked', { detail: {} })); click(element['unlock']); inside = false; + if (outcome !== 'throw') { + const attempt = element['unlockAttempt']; assert.ok(attempt); + await client['receive']({ type: 'room:state', ...roomState() }); + assert.equal(element['unlockAttempt'], attempt); assert.equal(element['unlock'].disabled, true); click(element['unlock']); + client.dispatchEvent(new CustomEvent('audioblocked', { detail: {} })); + if (outcome === 'resolve') pending.resolve(); else pending.reject(new Error('Unlock failure')); + } + await settle(); assert.equal(unlock.mock.callCount(), 1); assert.equal(element['unlockAttempt'], undefined); + assert.equal(element['unlock'].disabled, false); assert.equal(element['unlock'].hidden, false); + if (outcome !== 'resolve') assert.match(element['error'].textContent!, /Unlock failure/); + }); + } + + for (const field of ['listen', 'volume'] as const) for (const method of ['getAttribute', 'setAttribute'] as const) + for (const cause of ['disconnect', 'detach', 'leave', 'room', 'name', 'code', 'url', 'preference'] as const) { + it(`${field} ${method} ${cause} reentry preserves command ownership and repairs on the next current snapshot`, async t => { + const { element, client } = await subscribedWidget(t); + const commands = [t.mock.method(client, 'raiseHand', async () => {}), t.mock.method(client, 'mute', async () => {}), + t.mock.method(client, 'leaveStage', async () => {}), t.mock.method(client, 'unlockAudio', async () => {})]; + const request = t.mock.method(client as unknown as { request(): never }, 'request', () => assert.fail('no request')); + const capture = t.mock.method(client as unknown as { acquireMic(): never }, 'acquireMic', () => assert.fail('no capture')); + const negotiate = t.mock.method(client as unknown as { negotiate(): never }, 'negotiate', () => assert.fail('no negotiation')); + const play = t.mock.method(client['audio'], 'play'); + const timeout = t.mock.method(globalThis, 'setTimeout'), interval = t.mock.method(globalThis, 'setInterval'); + if (method === 'setAttribute') corrupt(element, field, 'host-corrupt'); + const calls = once(t, element, field, method, () => { + if (cause === 'disconnect') void client.disconnect(); + else if (cause === 'detach') { Object.assign(element, { isConnected: false }); element.disconnectedCallback(); } + else if (cause === 'leave') click(element['roomAction']); + else if (cause === 'preference') { element.playbackMuted = true; element.playbackVolume = .37; } + else element.setAttribute(cause, cause === 'url' ? 'wss://replacement.invalid/ws' : 'replacement'); + if (cause !== 'preference') { + for (const field of ['hand', 'mic', 'leaveStage', 'unlock'] as const) click(element[field]); + } + }); + element['render'](); await settle(); await settle(); assert.equal(calls(), 1); + for (const spy of [...commands, request, capture, negotiate, play, timeout, interval]) assert.equal(spy.mock.callCount(), 0); + // Host-overridden getters may nest renders; no atomic paint contract is asserted for that case. + const current = element.client; + if (current && cause !== 'disconnect') await current['receive']({ type: 'room:state', ...roomState() }); + else element['render'](); + assert.equal(raw(element, 'listen'), String(element.playbackMuted)); + assert.equal(raw(element, 'volume'), `${Math.round(element.playbackVolume * 100)}%`); + if (cause === 'preference') { + assert.equal(client.playbackMuted, true); assert.equal(client.playbackVolume, .37); + } else if (current !== client) { + const before = [raw(element, 'listen'), raw(element, 'volume'), element['listen'].hidden, element['volume'].disabled]; + const render = t.mock.method(element as unknown as { render(): void }, 'render'); + for (const [type, detail] of [['state', roomState()], ['status', 'disconnected'], ['audioblocked', {}]] as const) + client.dispatchEvent(new CustomEvent(type, { detail })); + assert.equal(render.mock.callCount(), 0); + assert.deepEqual([raw(element, 'listen'), raw(element, 'volume'), element['listen'].hidden, element['volume'].disabled], before); + } + }); + } + + for (const action of ['close', 'transfer'] as const) for (const invalid of [false, true]) { + it(`${action} ${invalid ? 'lost' : 'valid'} consent stays authoritative when both equal ARIA setters are omitted`, async t => { + const { element, client } = await subscribedWidget(t), state = roomState(true); + state.me.role = 'controller'; state.controllerId = 'me'; + state.speakers = [{ ...state.me }, { ...state.me, participantId: 'target', name: 'Speaker', role: 'speaker' }]; + await client['receive']({ type: 'room:state', ...state }); + const button = action === 'close' ? element['roomClose'] : element['stageManagementRows'].get('target')!.transfer; + const send = t.mock.method(client, action === 'close' ? 'closeRoom' : 'transferControl', async () => {}); + const counts = audit(t, element); + t.mock.method(window, 'confirm', () => { + const token = action === 'close' ? element['roomCloseAttempt'] : element['stageTransferAttempt']; assert.ok(token); + element['render'](); element['render'](); assert.equal(counts.writes, 0); assert.equal(token.invalid, false); + if (invalid) { + client.dispatchEvent(new CustomEvent('status', { detail: 'reconnecting' })); + client.dispatchEvent(new CustomEvent('status', { detail: 'live' })); + assert.equal(token.invalid, true); + } + return true; + }); + click(button); await settle(); assert.equal(send.mock.callCount(), invalid ? 0 : 1); + assert.equal(counts.writes, 0); assert.ok(counts.reads >= 4); + }); + } +}); From 39c9e1741ece9837455e753eea08038196c44076 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=A5=E8=AA=9E=E3=81=BB=E3=81=97=E3=81=9E=E3=82=89?= <92282870+YueyuHoshizora@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:26:54 +0800 Subject: [PATCH 13/38] fix(web): retire connection history on seat replacement (batch 207) --- PLAN.md | 3 + README.md | 2 +- test/webActions.test.ts | 2 +- test/webStatsRetirement.test.ts | 307 ++++++++++++++++++++++++++++++++ web/src/main.ts | 15 +- 5 files changed, 322 insertions(+), 7 deletions(-) create mode 100644 test/webStatsRetirement.test.ts diff --git a/PLAN.md b/PLAN.md index e424ee1..c9e5b92 100644 --- a/PLAN.md +++ b/PLAN.md @@ -856,3 +856,6 @@ N 路混音管線(decode→mix→encode)、不含自己的台上混音、lim 227. **Widget 本機播放 ARIA 等值寫入省略(第206批,2026-10-16):** immutable205 重跑204發現證據,五例結果相同;限定 listen aria-pressed/volume aria-valuetext 兩個 setter 前讀目前 attribute 比較。實際 widget→公開SDK→validator→MockHub 的64秒普通presence/hand cooldown/mute序列72重繪,144等值ARIA寫入省略,新增144讀取/比較,216原text/value呼叫不變;四次slider8寫入降為4必要變更。120秒quiet keepalive原本0重繪,不宣稱idle或nativeCPU/time收益。五個hidden/disabled候選保持原setter,因effectiveBoolean相同仍可能需原字面canonicalization;textContent descendant替換、dirty input.value、style及所有lifecycle/媒體/控制政策不改。錯誤/缺失ARIA由live比較修復,等值observer通知刻意減少;不承諾任意宿主getter/setter副作用等價。五檔source兩site/append-only tests+三文件,完整原測試保留;actualSDK負載、必要變更、host repair、pending unlock/reconnect/retiredclient與Close/Transfer回歸納入驗證。無新timer/request/media/依賴;原生browser/AT/硬體音訊未驗收。 + + +228. **完整範例本人統計歷史換席退役(第207批,2026-10-16):** immutable206 完整sample+實際SDK→validator→MockHub/controlledRTC證明非主控grace到期後合法resume取得新participant與peer,舊已畫出的20%掉包/180ms/10ms及error class仍在新席位;新peer首讀失敗會持續留舊資料。原pending結果的generation guard正確,缺口僅保留DOM歷史。限定room/participant identity改變後清空connectionLine文字與error狀態,先執行原純actiongeneration失效,不改同席state/reconnect歷史與可見2秒輪詢、hidden/resume watermark/SDKdelta或失敗政策。新席位首讀失敗保持空白,之後成功只顯示本席結果;無額外getStats/timer/media。新clear setter後核對generation,舊continuation不再更改新身分class;實際SDK統計為async,不為任意host同步thenable增加presentation cache或原子保證,不重寫一般room/action lifecycle。五檔sample source/完整模組test/既有webActions的最小classList fixture支援+README/PLAN,原assertions保留;actual grace expiry、room-only/participant-only替換、oldpending/newfail/newsuccess、same-seat/hiddenbaseline及setter/actionownership納入驗證。原生browser/AT/硬體音訊未驗收。 diff --git a/README.md b/README.md index 9a749ac..5ded5ae 100644 --- a/README.md +++ b/README.md @@ -293,7 +293,7 @@ Web Component 的目前主控另有獨立「關閉房間」,台上與台下皆 範例前端的建立/加入房間表單共用一個等待鎖;任一請求進行中,兩個提交按鈕一起停用並標示 `aria-busy`,鍵盤提交也不會重複送出,完成或失敗後恢復。 尚未收到房間狀態時可按「取消連線」,透過 SDK `disconnect()` 結束本機等待;這次取消的建立/加入錯誤不顯示,原請求結束後才能重新提交。收到房間狀態後不再提供取消,請使用房內的離開控制;若此時建立/加入尚未收到 ACK,主動離房也不會把這次已放棄的等待顯示成連線失敗,仍待原請求結束才解除提交鎖。當前真正失敗與關房/被踢的既有診斷照常保留;取消不保證回滾伺服器已接受的操作。 -範例前端的個人靜音、上下台、舉手與主控管理按鈕,在等待回覆期間保留停用狀態與 `aria-busy`,不會因冷卻 tick 或房間快照重繪而再次送出。離房/切換身分會清除舊 pending 狀態,舊完成事件不會解鎖新房操作;核准/婉拒/移出舞台共用每人的 pending key,強制靜音與解除亦共用;控制權移交在全列表保留單一 pending,換碼與關房也受保護。舊房間換碼完成不會覆寫新房通知。主控調整講者的音量 slider 在重連期間停用,恢復房間狀態後重新開放;事件 handler 也拒絕重連中的程式化 change。本機收聽音量與靜音仍可操作。gain 不套用 pending 按鈕鎖,連線正常時仍逐次送出放開時的值;離房後才完成的舊 gain 請求不再顯示錯誤到入口/新房,當前房間的失敗仍正常顯示。 舉手倒數只在畫面可見且尚未歸零時排程下一個顯示秒數邊界;沒有倒數時不執行冷卻輪詢,房內連線統計仍每 2 秒更新;連線數字採目前畫面生命週期內「已成功完成中的最高請求序號」:較新的請求尚未完成,不阻擋較早成功的樣本;較新成功結果已顯示後,舊結果不能覆寫。成功的空結果也會清除數字並退役較舊結果,失敗則保留畫面、不推進此界線。輪詢頻率不變,離房或隱藏前的延遲結果仍不會更新新畫面;不新增 timeout、取消底層統計請求或限制未完成請求數量。 +範例前端的個人靜音、上下台、舉手與主控管理按鈕,在等待回覆期間保留停用狀態與 `aria-busy`,不會因冷卻 tick 或房間快照重繪而再次送出。離房/切換身分會清除舊 pending 狀態,舊完成事件不會解鎖新房操作;核准/婉拒/移出舞台共用每人的 pending key,強制靜音與解除亦共用;控制權移交在全列表保留單一 pending,換碼與關房也受保護。舊房間換碼完成不會覆寫新房通知。主控調整講者的音量 slider 在重連期間停用,恢復房間狀態後重新開放;事件 handler 也拒絕重連中的程式化 change。本機收聽音量與靜音仍可操作。gain 不套用 pending 按鈕鎖,連線正常時仍逐次送出放開時的值;離房後才完成的舊 gain 請求不再顯示錯誤到入口/新房,當前房間的失敗仍正常顯示。 舉手倒數只在畫面可見且尚未歸零時排程下一個顯示秒數邊界;沒有倒數時不執行冷卻輪詢,房內連線統計仍每 2 秒更新;連線數字採目前畫面生命週期內「已成功完成中的最高請求序號」:較新的請求尚未完成,不阻擋較早成功的樣本;較新成功結果已顯示後,舊結果不能覆寫。成功的空結果也會清除數字並退役較舊結果,失敗則保留畫面、不推進此界線。房間或本人 participant 身分改變時,已顯示的舊席位數字與紅色警告立即清空;新席位第一次取樣失敗也不沿用舊歷史,直到其自身成功樣本才顯示。普通同席快照與保留原席位的重連仍保留歷史並沿用可見時輪詢,不重設 SDK 差值基準。輪詢頻率不變,離房或隱藏前的延遲結果仍不會更新新畫面;不新增 timeout、取消底層統計請求或限制未完成請求數量。 完整範例的房內連線資訊旁提供「重試音訊連線」:先前協商失敗但席位仍在時,可明確重送目前 peer 的 SDP offer,不必離房或用無關操作觸發快照。等待期間停用並保留跨重畫的 pending;重連時不接納新操作,離房/換席後的舊完成不更新新畫面。成功提示僅表示協商迴圈已收到 ACK、套用 answer 並處理緩衝 ICE,不表示已聽到聲音或 ICE 已連通;麥克風擷取與瀏覽器播放仍分別使用原「重試麥克風」及「點擊啟用音訊」。此入口不自動重試,不重新擷取、換 peer 或強制 ICE restart。SDK 的 `retryAudioConnection()` 會拒絕未進房、斷線、不可用 peer 及正在協商的呼叫;取消呼叫方等待不保證終止瀏覽器原生 SDP 操作,後續操作可能仍需等待原協商釋放。既有 error 事件與逾時政策保持;受控 SDK/Hub/DOM 驗證不等同原生瀏覽器、ICE、輔助科技或硬體音訊驗收。 diff --git a/test/webActions.test.ts b/test/webActions.test.ts index 2e9a5ca..3857807 100644 --- a/test/webActions.test.ts +++ b/test/webActions.test.ts @@ -60,7 +60,7 @@ function setup(): Harness { const visit = node => { if (node.dataset.roomAction) nodes.push(node); for (const child of node.children) visit(child); }; children.forEach(visit); } }; - const brand = {}, status = {}, connectionLine = { dataset: {}, children: [] }; + const brand = {}, status = {}, connectionLine = { dataset: {}, children: [], classList: { toggle() {} } }; const audioRetryNotice = { hidden: true, textContent: '', dataset: {}, children: [] }; const ownHandPosition = { hidden: true, textContent: '', dataset: {}, children: [] }; const statusLabels = {}, roleLabels = {}, createdRoom = null; diff --git a/test/webStatsRetirement.test.ts b/test/webStatsRetirement.test.ts new file mode 100644 index 0000000..3f59827 --- /dev/null +++ b/test/webStatsRetirement.test.ts @@ -0,0 +1,307 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { it, type TestContext } from 'node:test'; +import { setImmediate as nextTurn } from 'node:timers/promises'; +import { Deferred, flushSample, roomState, sampleApp, SampleNode } from './fixtures/sampleApp.ts'; +import { FakeSession, harness, testConfig } from './helpers.ts'; +import { parseClientMessage } from '../src/ws/validate.ts'; +import type { RoomStatePayload } from '../shared/protocol.ts'; + +// Complete production sample and actual SDK/validator/Hub execute unchanged. DOM, RTC, sockets +// and clocks are controlled boundaries; no native-browser, permission, or physical-audio claim. +const source = readFileSync(new URL('../web/src/main.ts', import.meta.url), 'utf8'); +interface Request { type: string; requestId: string; roomId?: string; resumeToken?: string } +class Socket { + static OPEN = 1; static instances: Socket[] = []; + readyState = 1; sent: Request[] = []; + onmessage: ((event: { data: string }) => void) | null = null; + onclose: (() => void) | null = null; onerror: (() => void) | null = null; + constructor(_url: string) { Socket.instances.push(this); } + send(data: string) { assert.equal(this.readyState, 1); this.sent.push(JSON.parse(data)); } + deliver(message: unknown) { this.onmessage?.({ data: JSON.stringify(message) }); } + close() { this.readyState = 3; this.onclose?.(); } +} +type Report = Map>; +class Peer { + static instances: Peer[] = []; + connectionState = 'connected'; signalingState = 'stable'; localDescription = null; + closes = 0; polls: Deferred[] = []; rejectOffer?: (error: Error) => void; + onconnectionstatechange?: () => void; + constructor() { Peer.instances.push(this); } + addTransceiver() { return { direction: 'recvonly', sender: { track: null, async replaceTrack() {} } }; } + createOffer(): Promise { return new Promise((_resolve, reject) => { this.rejectOffer = reject; }); } + getStats() { const work = new Deferred(); this.polls.push(work); return work.promise; } + close() { this.closes++; this.connectionState = 'closed'; this.rejectOffer?.(new Error('fixture teardown')); } +} +function report(time: number, rtt = 0.18, lost = time / 20): Report { + return new Map([ + ['in', { id: 'in', type: 'inbound-rtp', kind: 'audio', timestamp: time, bytesReceived: time * 8, + packetsReceived: time / 5, packetsLost: lost, jitterBufferDelay: time / 1000, jitterBufferEmittedCount: time / 10 }], + ['pair', { type: 'candidate-pair', state: 'succeeded', nominated: true, currentRoundTripTime: rtt }], + ]); +} +interface Sdk { readonly state: RoomStatePayload | null; readonly status: string; + disconnect(): Promise; getStats(): Promise<{ inbound: { lossPercent?: number; rtt?: number; bufferMs?: number } | null; outbound: unknown }>; } +async function actualSetup(t: TestContext) { + const { StageClient } = await import(new URL('../packages/client/src/index.ts', import.meta.url).href); + const config = testConfig(c => { c.rooms.qualityIntervalMs = 0; c.rooms.participantGraceMs = 1000; c.limits.controlPerSecond = 2000; }); + const wire = harness(config), host = await wire.create('Host', { codeRequired: false }); + const globals = { window: new EventTarget(), WebSocket: Socket, RTCPeerConnection: Peer, + navigator: { mediaDevices: { enumerateDevices: async () => [] } } }; + const originals = new Map(Object.keys(globals).map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value }); + Socket.instances = []; Peer.instances = []; + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); + let now = 0; t.mock.method(performance, 'now', () => now); + const audio = { volume: 0.37, muted: true, pauses: 0, srcObject: {} as unknown, pause() { this.pauses++; } }; + const sdk: Sdk = new StageClient({ url: 'ws://localhost/ws', audioElement: audio, enableMicMeter: false, + reconnect: { initialDelayMs: 25, maxDelayMs: 25 } }); + const h = sampleApp({ sdk, source }); + const sessions = new Map(); + const tick = async (ms = 1) => { now += ms; t.mock.timers.tick(ms); await flushSample(); }; + const until = async (predicate: () => boolean) => { + for (let n = 0; n < 100 && !predicate(); n++) await tick(); + assert.ok(predicate(), 'SDK must make expected socket progress'); + }; + const attach = (socket: Socket) => { const s = new FakeSession(); sessions.set(socket, s); wire.hub.attach(s); socket.deliver(s.last('hello')!); return s; }; + const accept = async (socket: Socket, request: Request) => { + const s = sessions.get(socket)!; const before = s.inbox.length; + await wire.hub.handle(s, parseClientMessage(JSON.stringify(request), config.limits)); + const replies = s.inbox.slice(before); + assert.ok(replies.some(m => m.type === 'ok' && m.requestId === request.requestId)); + for (const reply of replies) socket.deliver(reply); + await flushSample(); return s.last('room:state')!; + }; + t.after(async () => { + try { + await h.cleanup(); + for (const p of Peer.instances) for (const poll of p.polls) if (poll.status === 'pending') poll.reject(new Error('fixture cleanup')); + await flushSample(); await wire.hub.shutdown(); await wire.transport.close(); + assert.ok(Peer.instances.every(p => p.closes === 1)); + assert.ok(Peer.instances.every(p => p.polls.every(poll => poll.status !== 'pending'))); + } finally { for (const [key, old] of originals) { if (old) Object.defineProperty(globalThis, key, old); else Reflect.deleteProperty(globalThis, key); } } + }); + await flushSample(); + const form = h.find(n => n.tagName === 'form' && n.children.some(c => c.tagName === 'h2' && c.textContent === '加入房間')); + for (const input of form.descendants()) { if (input.name === 'name') input.value = 'Reader'; if (input.name === 'roomId') input.value = host.roomId; } + form.submit(); await flushSample(); const socket = Socket.instances.at(-1)!; attach(socket); + await until(() => socket.sent.some(m => m.type === 'join')); + await accept(socket, socket.sent.find(m => m.type === 'join')!); + assert.equal(sdk.status, sdk.state!.status); assert.equal(Peer.instances.length, 1); + const line = () => h.find(n => n.classList.contains('connection')); + const seed = async () => { + const peer = Peer.instances.at(-1)!; + h.clock.advance(2000); peer.polls.at(-1)!.resolve(report(2000)); await flushSample(); + h.clock.advance(2000); peer.polls.at(-1)!.resolve(report(4000)); await flushSample(); + assert.match(line().textContent, /收聽掉包 20\.0%.*延遲 180 ms.*緩衝 10 ms/); + assert.equal(line().classList.contains('error'), true); + return peer; + }; + const drop = async () => { socket.close(); await wire.hub.detach(sessions.get(socket)!); await flushSample(); assert.equal(sdk.status, 'reconnecting'); }; + const resume = async () => { + await tick(25); const resumed = Socket.instances.at(-1)!; assert.notEqual(resumed, socket); attach(resumed); + await until(() => resumed.sent.some(m => m.type === 'join')); + const request = resumed.sent.find(m => m.type === 'join')!; assert.ok(request.resumeToken); + const state = await accept(resumed, request); return { resumed, state }; + }; + return { ...h, audio, wire, config, host, sessions, socket, seed, line, drop, resume, tick, accept, attach }; +} + + +it('full sample actual retained-peer reconnect preserves rendered history, scheduler and shared delta baseline', async t => { + const h = await actualSetup(t), peer = await h.seed(), id = h.sdk.state!.me.participantId; + const timer = h.clock.intervals(2000)[0], beforePauses = h.audio.pauses, oldText = h.line().textContent; + const count = writes(h.line()); + h.clock.advance(2000); const beforeDrop = peer.polls.at(-1)!; + await h.drop(); assert.equal(peer.closes, 0); assert.equal(h.line().textContent, oldText); + assert.equal(count.text, 0); assert.equal(count.error, 0); assert.equal(h.clock.intervals(2000)[0], timer); + beforeDrop.resolve(report(6000, 0.04)); await flushSample(); assert.match(h.line().textContent, /收聽掉包 20\.0%.*延遲 40 ms.*緩衝 10 ms/); + h.clock.advance(2000); peer.polls.at(-1)!.resolve(report(8000, 0.03)); await flushSample(); + assert.match(h.line().textContent, /延遲 30 ms/); + const { state } = await h.resume(); assert.equal(state.me.participantId, id); assert.equal(Peer.instances.at(-1), peer); + assert.equal(h.audio.pauses, beforePauses); assert.equal(h.clock.intervals(2000)[0], timer); + assert.equal(count.text, 2); assert.equal(count.error, 2); assert.equal(peer.polls.length, 4); +}); + +for (const settlement of ['resolve', 'reject'] as const) { + it(`full sample actual grace expiry retires prior-seat history before old ${settlement} and a failed first new poll`, async t => { + const h = await actualSetup(t), old = await h.seed(), oldId = h.sdk.state!.me.participantId; + const timer = h.clock.intervals(2000)[0], count = writes(h.line()); + h.clock.advance(2000); const pendingOld = old.polls.at(-1)!; + await h.drop(); const expiry = h.wire.timers.find(timer => !timer.cleared && timer.ms === h.config.rooms.participantGraceMs)!; + assert.ok(expiry); expiry.cleared = true; expiry.fn(); await nextTurn(); + const { state } = await h.resume(); assert.notEqual(state.me.participantId, oldId); + const fresh = Peer.instances.at(-1)!; assert.notEqual(fresh, old); assert.equal(old.closes, 1); + cleared(h); assert.equal(count.text, 1); assert.equal(count.error, 1); + assert.equal(h.clock.intervals(2000)[0], timer); assert.equal(h.clock.jobs.get(timer!)!.due, 8000); + assert.equal(old.polls.length, 3); assert.equal(fresh.polls.length, 0, 'seat replacement adds no immediate stats poll'); + if (settlement === 'resolve') pendingOld.resolve(report(6000, 0.9)); + else pendingOld.reject(new Error('retired peer unavailable')); + await flushSample(); cleared(h); assert.equal(count.text, 1); assert.equal(count.error, 1); + h.clock.advance(2000); fresh.polls.at(-1)!.reject(new Error('new peer stats unavailable')); await flushSample(); + cleared(h); assert.equal(count.text, 1); assert.equal(count.error, 1); + h.clock.advance(2000); fresh.polls.at(-1)!.resolve(report(10000, 0.02)); await flushSample(); + assert.equal(h.line().textContent, '你的連線:延遲 20 ms'); assert.equal(h.line().classList.contains('error'), false); + h.clock.advance(2000); fresh.polls.at(-1)!.resolve(report(12000, 0.03)); await flushSample(); + assert.match(h.line().textContent, /收聽掉包 20\.0%.*延遲 30 ms.*緩衝 10 ms/); + assert.equal(count.text, 3); assert.equal(count.error, 3); assert.equal(fresh.polls.length, 3); + assert.equal(h.audio.volume, 0.37); assert.equal(h.audio.muted, true); + }); +} + +it('full sample actual public join to another room retires prior-room rendered diagnostics', async t => { + const h = await actualSetup(t), old = await h.seed(); + const another = await h.wire.create('Another', { codeRequired: false }); + const timer = h.clock.intervals(2000)[0], count = writes(h.line()); + await h.drop(); + const sdk = h.sdk as Sdk & { join(options: { roomId: string; name: string }): Promise }; + const join = sdk.join({ roomId: another.roomId, name: 'Reader' }); await flushSample(); + const socket = Socket.instances.at(-1)!; h.attach(socket); + for (let n = 0; n < 50 && !socket.sent.some(m => m.type === 'join'); n++) await h.tick(); + await h.accept(socket, socket.sent.find(m => m.type === 'join')!); await join; + assert.equal(h.sdk.state!.roomId, another.roomId); assert.equal(old.closes, 1); cleared(h); + assert.equal(count.text, 1); assert.equal(count.error, 1); assert.equal(h.clock.intervals(2000)[0], timer); + const fresh = Peer.instances.at(-1)!; assert.equal(fresh.polls.length, 0); + h.clock.advance(2000); fresh.polls.at(-1)!.reject(new Error('no current stats')); await flushSample(); cleared(h); + h.clock.advance(2000); fresh.polls.at(-1)!.resolve(report(8000, 0.025)); await flushSample(); + assert.equal(h.line().textContent, '你的連線:延遲 25 ms'); assert.equal(h.line().classList.contains('error'), false); +}); + +it('full sample actual hidden/resume and external stats retain SDK delta baselines and completion fencing', async t => { + const h = await actualSetup(t), peer = await h.seed(); + h.clock.advance(2000); const hiddenPending = peer.polls.at(-1)!; + const retired = h.clock.jobs.get(h.clock.intervals(2000)[0]!)!.callback; + h.setHidden(true); cleared(h); assert.equal(h.clock.intervals(2000).length, 0); + h.clock.advance(10000); assert.equal(peer.polls.length, 3); h.setHidden(false); assert.equal(peer.polls.length, 4); + const timer = h.clock.intervals(2000)[0]; retired(); assert.equal(peer.polls.length, 4); + peer.polls[3]!.resolve(report(16000, 0.05)); await flushSample(); + assert.match(h.line().textContent, /收聽掉包 20\.0%.*延遲 50 ms.*緩衝 10 ms/); + hiddenPending.resolve(report(6000, 0.9)); await flushSample(); assert.match(h.line().textContent, /延遲 50 ms/); + const external = h.sdk.getStats(); peer.polls[4]!.resolve(report(17000, 0.06, 850)); await external; + h.clock.advance(2000); peer.polls[5]!.resolve(report(18000, 0.07, 850)); await flushSample(); + assert.match(h.line().textContent, /收聽掉包 0\.0%.*延遲 70 ms/); assert.equal(h.line().classList.contains('error'), false); + assert.equal(h.clock.intervals(2000)[0], timer); assert.equal(peer.closes, 0); +}); + +type App = ReturnType; +type View = Pick; +const connection = (h: View) => h.find(node => node.classList.contains('connection')); +const result = (rtt = 0.18, lossPercent = 20) => ({ inbound: { rtt, lossPercent, bufferMs: 10 }, outbound: null }); +function cleared(h: View): void { + assert.equal(connection(h).textContent, '', 'retired seat history must not appear under the replacement owner'); + assert.equal(connection(h).classList.contains('error'), false, 'retired seat warning must also disappear'); +} +function writes(line: SampleNode) { + const count = { text: 0, error: 0 }, descriptor = Object.getOwnPropertyDescriptor(SampleNode.prototype, 'textContent')!; + Object.defineProperty(line, 'textContent', { configurable: true, get() { return descriptor.get!.call(line); }, + set(value: string) { count.text++; descriptor.set!.call(line, value); } }); + const toggle = line.classList.toggle; + line.classList.toggle = (name, force) => { if (name === 'error') count.error++; return toggle(name, force); }; + return count; +} +async function setup(t: TestContext) { + const h = sampleApp({ source }); t.after(() => h.cleanup()); await flushSample(); h.sdk.snapshot(roomState()); + h.clock.advance(2000); h.sdk.polls[0]!.resolve(result()); await flushSample(); + assert.match(connection(h).textContent, /收聽掉包 20\.0%.*延遲 180 ms.*緩衝 10 ms/); + assert.equal(connection(h).classList.contains('error'), true); + return h; +} + +for (const identity of ['room', 'participant'] as const) { + for (const settlement of ['resolve', 'reject'] as const) { + it(`full sample direct ${identity} replacement clears both history fields before old ${settlement}, new failure and ordered recovery`, async t => { + const h = await setup(t), line = connection(h), count = writes(line), timer = h.clock.intervals(2000)[0]!; + h.clock.advance(2000); const old = h.sdk.polls.at(-1)!; + const replacement = identity === 'room' ? { ...roomState(), roomId: 'next-room' } : roomState({ participantId: 'next-person' }); + h.sdk.snapshot(replacement); cleared(h); assert.equal(connection(h), line); + assert.equal(count.text, 1); assert.equal(count.error, 1); assert.equal(h.sdk.polls.length, 2); + assert.deepEqual(h.clock.intervals(2000), [timer]); assert.equal(h.clock.jobs.get(timer)!.due, 6000); + if (settlement === 'resolve') old.resolve(result(0.9, 90)); else old.reject(new Error('old failure')); + await flushSample(); cleared(h); assert.equal(count.text, 1); assert.equal(count.error, 1); + h.clock.advance(2000); h.sdk.polls.at(-1)!.reject(new Error('first new failure')); await flushSample(); cleared(h); + h.clock.advance(4000); const [earlier, later] = h.sdk.polls.slice(-2); + later!.resolve(result(0.02, 0)); await flushSample(); assert.match(line.textContent, /延遲 20 ms/); + assert.equal(line.classList.contains('error'), false); + earlier!.resolve(result(0.7, 70)); await flushSample(); assert.match(line.textContent, /延遲 20 ms/); + assert.equal(count.text, 2); assert.equal(count.error, 2); assert.equal(h.sdk.polls.length, 5); + assert.equal(h.sdk.joins.length, 0); assert.equal(h.sdk.raises.length, 0); assert.equal(h.sdk.previews.length, 0); + assert.deepEqual(h.sdk.inputDevices, []); assert.deepEqual(h.sdk.outputDevices, []); + }); + } +} + +it('full sample ordinary snapshots and same-seat reconnect add no history writes, timers or operations', async t => { + const h = await setup(t), line = connection(h), count = writes(line), text = line.textContent; + const jobs = [...h.clock.jobs.keys()]; + for (let i = 0; i < 5; i++) h.sdk.snapshot(roomState()); + h.sdk.snapshot({ ...roomState(), audienceCount: 3 }); h.sdk.setStatus('reconnecting'); + h.sdk.snapshot({ ...roomState(), name: 'Same seat renamed room' }); + assert.equal(line.textContent, text); assert.equal(line.classList.contains('error'), true); + assert.equal(count.text, 0); assert.equal(count.error, 0); assert.deepEqual([...h.clock.jobs.keys()], jobs); + assert.equal(h.sdk.polls.length, 1); h.clock.advance(2000); h.sdk.polls[1]!.resolve(result(0.03, 0)); await flushSample(); + assert.equal(count.text, 1); assert.equal(count.error, 1); assert.match(line.textContent, /延遲 30 ms/); + assert.equal(h.sdk.joins.length, 0); assert.equal(h.sdk.raises.length, 0); assert.equal(h.sdk.previews.length, 0); +}); + +it('full sample identity replacement while hidden keeps retired results fenced across resumed polling', async t => { + const h = await setup(t); h.clock.advance(2000); const old = h.sdk.polls[1]!; + h.setHidden(true); cleared(h); h.sdk.snapshot(roomState({ participantId: 'hidden-replacement' })); cleared(h); + assert.equal(h.clock.intervals(2000).length, 0); h.clock.advance(6000); assert.equal(h.sdk.polls.length, 2); + h.setHidden(false); assert.equal(h.sdk.polls.length, 3); + old.resolve(result(0.9, 90)); await flushSample(); cleared(h); + h.sdk.polls[2]!.reject(new Error('resumed sample failed')); await flushSample(); cleared(h); + h.clock.advance(2000); h.sdk.polls[3]!.resolve(result(0.02, 0)); await flushSample(); + assert.match(connection(h).textContent, /延遲 20 ms/); assert.equal(connection(h).classList.contains('error'), false); +}); + +for (const property of ['text', 'error'] as const) { + it(`full sample ${property} clear reentry retires old actions before the setter and preserves a legitimate new owner`, async t => { + const h = await setup(t), line = connection(h); + const oldAction = h.find(node => node.dataset.roomAction === 'hand'); + oldAction.click(); const oldPending = h.sdk.raises[0]!; + const descriptor = Object.getOwnPropertyDescriptor(SampleNode.prototype, 'textContent')!, toggle = line.classList.toggle; + let nested = false, callbackPoll = 0, newAction: SampleNode | undefined; + const enter = () => { + if (nested) return; + nested = true; + // Test observable host reentry; completions still use native promises, like actual SDK getStats. + oldAction.disabled = false; oldAction.onclick!(); assert.equal(h.sdk.raises.length, 1, 'old action already lost generation ownership'); + h.sdk.snapshot(roomState({ participantId: 'nested-owner' })); + newAction = h.find(node => node.dataset.roomAction === 'hand'); newAction.click(); + assert.equal(h.sdk.raises.length, 2); h.clock.advance(2000); callbackPoll = h.sdk.polls.length - 1; + h.sdk.polls[callbackPoll]!.resolve(result(0.025, 0)); + oldPending.reject(new Error('retired action error')); + }; + if (property === 'text') Object.defineProperty(line, 'textContent', { + configurable: true, get() { return descriptor.get!.call(line); }, + set(value: string) { descriptor.set!.call(line, value); if (value === '') enter(); }, + }); + else line.classList.toggle = (name, force) => { const value = toggle(name, force); if (name === 'error' && !force) enter(); return value; }; + h.sdk.snapshot(roomState({ participantId: 'superseded-owner' })); assert.equal(nested, true); + assert.equal(h.sdk.me!.participantId, 'nested-owner'); + assert.equal(h.find(node => node.dataset.roomAction === 'hand'), newAction, 'outer render cannot reinstall a superseded view'); + assert.equal(newAction!.disabled, true); assert.equal(newAction!.attributes['aria-busy'], 'true'); + await flushSample(); assert.match(line.textContent, /延遲 25 ms/); assert.equal(line.classList.contains('error'), false); + assert.equal(h.sdk.polls[callbackPoll]!.status, 'fulfilled'); + assert.equal(h.find(node => node.attributes.role === 'alert').hidden, true); + newAction!.onclick!(); assert.equal(h.sdk.raises.length, 2, 'old completion cannot unlock the new pending action'); + h.sdk.raises[1]!.resolve(undefined); await flushSample(); assert.equal(newAction!.disabled, false); + h.sdk.snapshot(roomState({ participantId: 'nested-owner' })); assert.match(line.textContent, /延遲 25 ms/); + }); +} + +it('full sample clear callback round-trip to the same IDs cannot reinstall an older generation', async t => { + const h = await setup(t), line = connection(h), descriptor = Object.getOwnPropertyDescriptor(SampleNode.prototype, 'textContent')!; + let nested = false, latest: SampleNode | undefined; + Object.defineProperty(line, 'textContent', { configurable: true, get() { return descriptor.get!.call(line); }, set(value: string) { + descriptor.set!.call(line, value); + if (value !== '' || nested) return; + nested = true; h.sdk.snapshot(roomState({ participantId: 'between' })); + h.sdk.snapshot(roomState({ participantId: 'target' })); latest = h.find(node => node.dataset.roomAction === 'hand'); + latest.click(); + } }); + h.sdk.snapshot(roomState({ participantId: 'target' })); + assert.equal(h.find(node => node.dataset.roomAction === 'hand'), latest); assert.equal(h.sdk.raises.length, 1); + assert.equal(latest!.attributes['aria-busy'], 'true'); cleared(h); + h.sdk.raises[0]!.resolve(undefined); await flushSample(); +}); diff --git a/web/src/main.ts b/web/src/main.ts index 4694d9e..bd6a510 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -447,13 +447,18 @@ async function run(action: () => Promise, target?: HTMLButtonElement, g finally { if (target?.isConnected) target.disabled = false; updateCooldown(); syncMeter(); } } /** Room controls retain their pending state when snapshots replace their DOM nodes. */ -function resetRoomActions(identity: string): void { - if (roomActionIdentity === identity) return; - roomActionIdentity = identity; roomActionGeneration++; pendingRoomActions.clear(); +function resetRoomActions(identity: string): number { + if (roomActionIdentity === identity) return roomActionGeneration; + roomActionIdentity = identity; const generation = ++roomActionGeneration; pendingRoomActions.clear(); audioRetryNotice.hidden = true; audioRetryNotice.textContent = ''; handRejectionRoomActive = identity !== ''; retireHandRejection(); pendingAudioUnlock = undefined; micRetryAvailable = micRetryPending = false; micNotice = ''; + // Retire the previous seat's history only after its actions and polls have lost ownership. + if (generation !== roomActionGeneration) return generation; + connectionLine.textContent = ''; + if (generation === roomActionGeneration) connectionLine.classList.toggle('error', false); + return generation; } function roomButton(key: string, label: string, action: () => Promise, className = '', canAct?: () => boolean): HTMLButtonElement { const node = element('button', className, label); @@ -724,7 +729,8 @@ function syncAudienceFilter(state: RoomStatePayload): void { } function renderRoom(state: RoomStatePayload): void { syncQualityDetails(); - resetRoomActions(JSON.stringify([state.roomId, state.me.participantId])); + const generation = resetRoomActions(JSON.stringify([state.roomId, state.me.participantId])); + if (generation !== roomActionGeneration) return; if (!state.me.onStage) { if (micRetryPending) micNotice = ''; micRetryAvailable = micRetryPending = false; @@ -744,7 +750,6 @@ function renderRoom(state: RoomStatePayload): void { } renderedGainInputs.length = 0; audienceFilterRows.length = 0; personRows.clear(); speakingIds = new Set(client.speaking); - const generation = roomActionGeneration; brand.textContent = decodeName(state.name); status.textContent = statusLabels[client.status]; cooldownButton = null; From 035f519adc9d24b348caec6f190204b3034f5234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=A5=E8=AA=9E=E3=81=BB=E3=81=97=E3=81=9E=E3=82=89?= <92282870+YueyuHoshizora@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:41:22 +0800 Subject: [PATCH 14/38] feat(web): show directional historical connection statistics (batch 208) --- PLAN.md | 3 + README.md | 2 +- test/webRoomRender.test.ts | 4 +- test/webStatsCompletion.test.ts | 2 +- test/webStatsDirectional.test.ts | 283 +++++++++++++++++++++++++++++++ test/webStatsRetirement.test.ts | 32 ++-- web/src/main.ts | 21 ++- 7 files changed, 320 insertions(+), 27 deletions(-) create mode 100644 test/webStatsDirectional.test.ts diff --git a/PLAN.md b/PLAN.md index c9e5b92..3c584b7 100644 --- a/PLAN.md +++ b/PLAN.md @@ -859,3 +859,6 @@ N 路混音管線(decode→mix→encode)、不含自己的台上混音、lim 228. **完整範例本人統計歷史換席退役(第207批,2026-10-16):** immutable206 完整sample+實際SDK→validator→MockHub/controlledRTC證明非主控grace到期後合法resume取得新participant與peer,舊已畫出的20%掉包/180ms/10ms及error class仍在新席位;新peer首讀失敗會持續留舊資料。原pending結果的generation guard正確,缺口僅保留DOM歷史。限定room/participant identity改變後清空connectionLine文字與error狀態,先執行原純actiongeneration失效,不改同席state/reconnect歷史與可見2秒輪詢、hidden/resume watermark/SDKdelta或失敗政策。新席位首讀失敗保持空白,之後成功只顯示本席結果;無額外getStats/timer/media。新clear setter後核對generation,舊continuation不再更改新身分class;實際SDK統計為async,不為任意host同步thenable增加presentation cache或原子保證,不重寫一般room/action lifecycle。五檔sample source/完整模組test/既有webActions的最小classList fixture支援+README/PLAN,原assertions保留;actual grace expiry、room-only/participant-only替換、oldpending/newfail/newsuccess、same-seat/hiddenbaseline及setter/actionownership納入驗證。原生browser/AT/硬體音訊未驗收。 + + +229. **完整範例方向別連線統計(第208批,2026-10-16):** immutable207 完整sample+實際SDK controlledRTC證明有效接收RTT43ms/傳送RTT100ms被合併為泛稱延遲43ms,且方向缺失與首樣本delta不可用無明確區別。僅既有refreshConnectionLine成功投影呈現五指標:receive loss/RTT/buffer、send loss/RTT;上次完成讀取/SDK取樣區間、RTT非端到端且非即時可聽保證。present方向缺metric顯示破折號,null方向暫無統計,bothnull仍整行清空並推進原watermark;零保留、loss一位小數、秒轉毫秒、buffer原毫秒,非有限轉換局部顯示破折號。不改SDK最後匹配RTP與共享delta基準,不冒充全串流聚合或固定2秒區間。紅警告原未round maxloss>=5保持,4.99顯示5.0但不紅,RTT不判健康。timer/hidden/same-seat reconnect/highercompletedsequence/失敗保留/207換席清歷史全部保持,無新增讀取/node/control/media。七檔main投影/新完整模組test/三既有精確文案expectation+README/PLAN,原數值與生命周期assertions不弱化,formatter留原slice內不擴fixture。原生browser/AT/硬體音訊未驗收,無延遲或音質改善宣稱。 diff --git a/README.md b/README.md index 5ded5ae..b8bdc4f 100644 --- a/README.md +++ b/README.md @@ -293,7 +293,7 @@ Web Component 的目前主控另有獨立「關閉房間」,台上與台下皆 範例前端的建立/加入房間表單共用一個等待鎖;任一請求進行中,兩個提交按鈕一起停用並標示 `aria-busy`,鍵盤提交也不會重複送出,完成或失敗後恢復。 尚未收到房間狀態時可按「取消連線」,透過 SDK `disconnect()` 結束本機等待;這次取消的建立/加入錯誤不顯示,原請求結束後才能重新提交。收到房間狀態後不再提供取消,請使用房內的離開控制;若此時建立/加入尚未收到 ACK,主動離房也不會把這次已放棄的等待顯示成連線失敗,仍待原請求結束才解除提交鎖。當前真正失敗與關房/被踢的既有診斷照常保留;取消不保證回滾伺服器已接受的操作。 -範例前端的個人靜音、上下台、舉手與主控管理按鈕,在等待回覆期間保留停用狀態與 `aria-busy`,不會因冷卻 tick 或房間快照重繪而再次送出。離房/切換身分會清除舊 pending 狀態,舊完成事件不會解鎖新房操作;核准/婉拒/移出舞台共用每人的 pending key,強制靜音與解除亦共用;控制權移交在全列表保留單一 pending,換碼與關房也受保護。舊房間換碼完成不會覆寫新房通知。主控調整講者的音量 slider 在重連期間停用,恢復房間狀態後重新開放;事件 handler 也拒絕重連中的程式化 change。本機收聽音量與靜音仍可操作。gain 不套用 pending 按鈕鎖,連線正常時仍逐次送出放開時的值;離房後才完成的舊 gain 請求不再顯示錯誤到入口/新房,當前房間的失敗仍正常顯示。 舉手倒數只在畫面可見且尚未歸零時排程下一個顯示秒數邊界;沒有倒數時不執行冷卻輪詢,房內連線統計仍每 2 秒更新;連線數字採目前畫面生命週期內「已成功完成中的最高請求序號」:較新的請求尚未完成,不阻擋較早成功的樣本;較新成功結果已顯示後,舊結果不能覆寫。成功的空結果也會清除數字並退役較舊結果,失敗則保留畫面、不推進此界線。房間或本人 participant 身分改變時,已顯示的舊席位數字與紅色警告立即清空;新席位第一次取樣失敗也不沿用舊歷史,直到其自身成功樣本才顯示。普通同席快照與保留原席位的重連仍保留歷史並沿用可見時輪詢,不重設 SDK 差值基準。輪詢頻率不變,離房或隱藏前的延遲結果仍不會更新新畫面;不新增 timeout、取消底層統計請求或限制未完成請求數量。 +範例前端的個人靜音、上下台、舉手與主控管理按鈕,在等待回覆期間保留停用狀態與 `aria-busy`,不會因冷卻 tick 或房間快照重繪而再次送出。離房/切換身分會清除舊 pending 狀態,舊完成事件不會解鎖新房操作;核准/婉拒/移出舞台共用每人的 pending key,強制靜音與解除亦共用;控制權移交在全列表保留單一 pending,換碼與關房也受保護。舊房間換碼完成不會覆寫新房通知。主控調整講者的音量 slider 在重連期間停用,恢復房間狀態後重新開放;事件 handler 也拒絕重連中的程式化 change。本機收聽音量與靜音仍可操作。gain 不套用 pending 按鈕鎖,連線正常時仍逐次送出放開時的值;離房後才完成的舊 gain 請求不再顯示錯誤到入口/新房,當前房間的失敗仍正常顯示。 舉手倒數只在畫面可見且尚未歸零時排程下一個顯示秒數邊界;沒有倒數時不執行冷卻輪詢,房內連線統計仍每 2 秒更新,分別呈現接收掉包/RTT/緩衝及傳送掉包/RTT;不再將兩個不同 RTT 合成一個泛稱延遲。畫面標示上次完成讀取與 SDK 取樣區間,RTT 不是端到端音訊延遲,也不保證即時或目前可聽。方向存在但指標缺少時顯示「—」,沒有該方向顯示暫無統計,兩方向皆無的成功結果仍清空整行;0 保留。SDK 每方向沿用最後符合的音訊 RTP,非全部串流加總;其他呼叫者共用差值基準,取樣區間不一定是 2 秒。紅色警告仍只按未四捨五入的掉包率至少 5% 判斷,因此 4.99% 顯示 5.0% 也不標紅;連線數字採目前畫面生命週期內「已成功完成中的最高請求序號」:較新的請求尚未完成,不阻擋較早成功的樣本;較新成功結果已顯示後,舊結果不能覆寫。成功的空結果也會清除數字並退役較舊結果,失敗則保留畫面、不推進此界線。房間或本人 participant 身分改變時,已顯示的舊席位數字與紅色警告立即清空;新席位第一次取樣失敗也不沿用舊歷史,直到其自身成功樣本才顯示。普通同席快照與保留原席位的重連仍保留歷史並沿用可見時輪詢,不重設 SDK 差值基準。輪詢頻率不變,離房或隱藏前的延遲結果仍不會更新新畫面;不新增 timeout、取消底層統計請求或限制未完成請求數量。 完整範例的房內連線資訊旁提供「重試音訊連線」:先前協商失敗但席位仍在時,可明確重送目前 peer 的 SDP offer,不必離房或用無關操作觸發快照。等待期間停用並保留跨重畫的 pending;重連時不接納新操作,離房/換席後的舊完成不更新新畫面。成功提示僅表示協商迴圈已收到 ACK、套用 answer 並處理緩衝 ICE,不表示已聽到聲音或 ICE 已連通;麥克風擷取與瀏覽器播放仍分別使用原「重試麥克風」及「點擊啟用音訊」。此入口不自動重試,不重新擷取、換 peer 或強制 ICE restart。SDK 的 `retryAudioConnection()` 會拒絕未進房、斷線、不可用 peer 及正在協商的呼叫;取消呼叫方等待不保證終止瀏覽器原生 SDP 操作,後續操作可能仍需等待原協商釋放。既有 error 事件與逾時政策保持;受控 SDK/Hub/DOM 驗證不等同原生瀏覽器、ICE、輔助科技或硬體音訊驗收。 diff --git a/test/webRoomRender.test.ts b/test/webRoomRender.test.ts index b0658c1..23e7a04 100644 --- a/test/webRoomRender.test.ts +++ b/test/webRoomRender.test.ts @@ -262,7 +262,7 @@ it('whole sample keeps microphone retry pending and independent meter and stats h.sdk.micLevelValue = 0.4; h.clock.advance(2000); assert.equal(h.id('mic-level').value, 0.4); assert.equal(h.sdk.polls.length, 1); assert.ok(h.sdk.micLevelReads >= 40); h.sdk.polls[0]!.resolve({ inbound: { lossPercent: 2, rtt: 0.02 }, outbound: null }); await flushSample(); - assert.match(h.app.textContent, /收聽掉包 2.0% · 延遲 20 ms/); assert.equal(retries, 1); + assert.match(h.app.textContent, /接收掉包 2.0% · 接收 RTT 20 ms/); assert.equal(retries, 1); h.sdk.emit('micready', undefined); assert.equal(h.app.descendants().some(node => node.id === 'mic-retry'), false); h.sdk.snapshot(clone(state)); assert.equal(h.counts.replacements, 0); assert.equal(retries, 1); const offstage = roomState(); offstage.status = 'live'; h.sdk.snapshot(offstage); @@ -596,7 +596,7 @@ it('whole sample count-only updates preserve pending hand, cooldown, notices, er h.sdk.polls[0]!.resolve({ inbound: { rtt: 0.02 }, outbound: null }); pending.reject(new Error('current count rejection')); await flushSample(); assert.equal(hand.disabled, false); assert.equal(errorBox(h).textContent, 'current count rejection'); - assert.equal(h.clock.timeouts().length, 0); assert.match(h.app.textContent, /延遲 20 ms/); + assert.equal(h.clock.timeouts().length, 0); assert.match(h.app.textContent, /接收 RTT 20 ms/); const raised = countOnlyState(true); h.sdk.snapshot(clone(raised)); const position = h.find(node => node.classList.contains('own-hand-position')), raisedRows = rows(h); h.reset(); assert.equal(position.textContent, '你的舉手位置:佇列第 8 位。'); diff --git a/test/webStatsCompletion.test.ts b/test/webStatsCompletion.test.ts index 4cbe2e6..843f40b 100644 --- a/test/webStatsCompletion.test.ts +++ b/test/webStatsCompletion.test.ts @@ -19,7 +19,7 @@ async function setup(t: TestContext): Promise { const h = sampleApp(); t.after(() => h.cleanup()); await flushSample(); h.sdk.snapshot(roomState()); return h; } function shown(h: Pick, rttMs: number, poor: boolean): void { - assert.match(connection(h).textContent, new RegExp(`延遲 ${rttMs} ms`)); + assert.match(connection(h).textContent, new RegExp(`接收 RTT ${rttMs} ms`)); assert.equal(connection(h).classList.contains('error'), poor); } function cleared(h: Pick): void { diff --git a/test/webStatsDirectional.test.ts b/test/webStatsDirectional.test.ts new file mode 100644 index 0000000..c7c7a79 --- /dev/null +++ b/test/webStatsDirectional.test.ts @@ -0,0 +1,283 @@ +import assert from 'node:assert/strict'; +import { it, type TestContext } from 'node:test'; +import type { RoomStatePayload } from '../shared/protocol.ts'; +import { Deferred, flushSample, roomState, sampleApp, SampleNode } from './fixtures/sampleApp.ts'; + +// Complete sample and actual public SDK; only DOM, clock, capture and RTC reports are controlled. +// Operation counts do not measure native layout, accessibility, real media, or end-to-end latency. +interface AudioStats { bitrateKbps?: number; lossPercent?: number; rtt?: number; bufferMs?: number } +interface Stats { inbound: AudioStats | null; outbound: AudioStats | null } +interface ActualClient { + readonly state: RoomStatePayload | null; + disconnect(): Promise; + getStats(): Promise; + dispatchEvent(event: Event): boolean; +} +type Entry = Record; +type Report = Map; +const report = (...entries: Entry[]): Report => new Map(entries.map(entry => [String(entry.id), entry])); +const inbound = (timestamp = 1000, received = 100, lost = 0, delay = 2, emitted = 100, extra: Entry = {}): Entry => + ({ id: 'in', type: 'inbound-rtp', kind: 'audio', timestamp, bytesReceived: timestamp * 8, + packetsReceived: received, packetsLost: lost, jitterBufferDelay: delay, jitterBufferEmittedCount: emitted, ...extra }); +const outbound = (timestamp = 1000, extra: Entry = {}): Entry => + ({ id: 'out', type: 'outbound-rtp', kind: 'audio', timestamp, bytesSent: timestamp, remoteId: 'remote', ...extra }); +const pair = (rtt = 0.043): Entry => ({ id: 'pair', type: 'candidate-pair', state: 'succeeded', nominated: true, currentRoundTripTime: rtt }); +const remote = (rtt = 0.1, fraction = 0.125): Entry => ({ id: 'remote', type: 'remote-inbound-rtp', roundTripTime: rtt, fractionLost: fraction }); +const text = (receive: string, send: string): string => + `你的連線(上次完成讀取;SDK 取樣區間):${receive};${send}。RTT 不是端到端音訊延遲;不保證資料即時或目前可聽。`; +const rx = (loss = '—', rtt = '—', buffer = '—'): string => `接收掉包 ${loss}% · 接收 RTT ${rtt} ms · 接收緩衝 ${buffer} ms`; +const tx = (loss = '—', rtt = '—'): string => `傳送掉包 ${loss}% · 傳送 RTT ${rtt} ms`; +const noReceive = '接收:暫無統計', noSend = '傳送:暫無統計'; +function writes(line: SampleNode): () => number { + const descriptor = Object.getOwnPropertyDescriptor(SampleNode.prototype, 'textContent')!; + let count = 0; + Object.defineProperty(line, 'textContent', { configurable: true, get() { return descriptor.get!.call(line); }, + set(value: string) { count++; descriptor.set!.call(line, value); } }); + return () => count; +} +async function setup(t: TestContext) { + // Dynamic import keeps browser-only SDK types outside the server-test TypeScript graph. + const { StageClient } = await import(new URL('../packages/client/src/index.ts', import.meta.url).href); + const originals = new Map(['window', 'navigator'].map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + let enumerations = 0, captures = 0, requests = 0; + Object.defineProperty(globalThis, 'window', { configurable: true, value: new EventTarget() }); + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { mediaDevices: { + async enumerateDevices() { enumerations++; return []; }, + async getUserMedia() { captures++; throw new Error('unexpected capture'); }, + } } }); + const listening = {}, audio = { volume: 0.37, muted: true, srcObject: listening as unknown, pauses: 0, pause() { this.pauses++; } }; + const sdk: ActualClient = new StageClient({ url: 'ws://localhost/ws', audioElement: audio, enableMicMeter: false }); + const h = sampleApp({ sdk }), pending: { at: number; work: Deferred }[] = [], completed: Stats[] = []; + const collect = sdk.getStats.bind(sdk); + sdk.getStats = async () => { const value = await collect(); completed.push(value); return value; }; + const pc = { closes: 0, close() { this.closes++; }, getStats() { + const work = new Deferred(); pending.push({ at: h.clock.now, work }); return work.promise; + } }; + const track = { enabled: true, stops: 0, stop() { this.stops++; } }; + const mic = { getTracks: () => [track], getAudioTracks: () => [track] }; + const state = roomState(); + Object.assign(sdk, { pc, mic, snapshot: state, currentStatus: state.status, + request() { requests++; return Promise.reject(new Error('unexpected control request')); } }); + const snapshot = (next: RoomStatePayload) => { + Object.assign(sdk, { snapshot: next, currentStatus: next.status }); + sdk.dispatchEvent(new CustomEvent('state', { detail: next })); + }; + snapshot(state); await flushSample(); + const line = () => h.find(node => node.classList.contains('connection')); + const timer = h.clock.intervals(2000)[0]!; + assert.deepEqual([...h.clock.jobs.keys()], [timer]); + const resolve = async (value: Report) => { pending.at(-1)!.work.resolve(value); await flushSample(); return completed.at(-1)!; }; + const poll = async (value: Report) => { h.clock.advance(2000); return resolve(value); }; + const unchanged = () => { + assert.equal(audio.srcObject, listening); assert.equal(audio.pauses, 0); assert.equal(audio.volume, 0.37); assert.equal(audio.muted, true); + assert.equal(pc.closes, 0); assert.equal(track.stops, 0); assert.equal(track.enabled, true); + assert.equal(captures, 0); assert.equal(requests, 0); assert.equal(enumerations, 1); + const internals = sdk as ActualClient & { pc: unknown; mic: unknown }; + assert.equal(internals.pc, pc); assert.equal(internals.mic, mic); + }; + t.after(async () => { + try { + unchanged(); await h.cleanup(); + for (const { work } of pending) if (work.status === 'pending') work.reject(new Error('controlled cleanup')); + await flushSample(); assert.equal(pc.closes, 1); assert.equal(track.stops, 1); + assert.ok(pending.every(({ work }) => work.status !== 'pending')); + } finally { + for (const [key, descriptor] of originals) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); + } + } + }); + return { ...h, pending, completed, line, timer, snapshot, poll, resolve, unchanged }; +} + +it('whole sample actual SDK displays distinct directional RTT and first-sample gaps, then exact delta units', async t => { + const h = await setup(t), line = h.line(), nodes = h.app.descendants(), count = writes(line); + const first = await h.poll(report(inbound(), outbound(), pair(), remote())); + assert.equal(first.inbound?.rtt, 0.043); assert.equal(first.outbound?.rtt, 0.1); + assert.equal(first.inbound?.lossPercent, undefined); assert.equal(first.inbound?.bufferMs, undefined); + assert.equal(first.outbound?.lossPercent, 12.5); + assert.equal(line.textContent, text(rx('—', '43'), tx('12.5', '100'))); + assert.equal(line.classList.contains('error'), true); + const second = await h.poll(report(inbound(2000, 148, 2, 3, 150), outbound(2000), pair(), remote())); + assert.equal(second.inbound?.lossPercent, 4); assert.equal(second.inbound?.bufferMs, 20); + assert.equal(second.inbound?.bitrateKbps, 64); assert.equal(second.outbound?.bitrateKbps, 8); + assert.equal(line.textContent, text(rx('4.0', '43', '20'), tx('12.5', '100'))); + assert.equal(count(), 2); assert.equal(h.line(), line); assert.deepEqual(h.app.descendants(), nodes); + assert.equal(line.tagName, 'p'); assert.equal(line.children.length, 0); + assert.deepEqual(h.pending.map(value => value.at), [2000, 4000]); assert.deepEqual([...h.clock.jobs.keys()], [h.timer]); + h.unchanged(); +}); + +it('whole sample actual SDK labels each sole direction and never borrows the other direction RTT', async t => { + const h = await setup(t); + let value = await h.poll(report(inbound(), pair())); + assert.equal(value.outbound, null); assert.equal(h.line().textContent, text(rx('—', '43'), noSend)); + value = await h.poll(report(outbound(2000), pair(), remote(0.1, 0.02))); + assert.equal(value.inbound, null); assert.equal(h.line().textContent, text(noReceive, tx('2.0', '100'))); + value = await h.poll(report(outbound(3000, { remoteId: undefined }), pair(0.05))); + assert.equal(value.outbound?.lossPercent, undefined); assert.equal(h.line().textContent, text(noReceive, tx('—', '50'))); + value = await h.poll(report(inbound(4000), outbound(4000), remote())); + assert.equal(value.inbound?.rtt, undefined); assert.equal(value.outbound?.rtt, 0.1); + assert.equal(h.line().textContent, text(rx(), tx('12.5', '100'))); + assert.equal(h.pending.length, 4); assert.deepEqual(h.clock.intervals(2000), [h.timer]); +}); + +it('whole sample actual SDK distinguishes present metricless directions from no directions', async t => { + const h = await setup(t); + let value = await h.poll(report(inbound())); + assert.ok(value.inbound); assert.equal(value.outbound, null); assert.equal(h.line().textContent, text(rx(), noSend)); + value = await h.poll(report({ id: 'out', type: 'outbound-rtp', kind: 'audio', timestamp: 2000 })); + assert.ok(value.outbound); assert.equal(value.inbound, null); assert.equal(h.line().textContent, text(noReceive, tx())); + value = await h.poll(report(inbound(3000), outbound(3000))); + assert.ok(value.inbound); assert.ok(value.outbound); assert.equal(h.line().textContent, text(rx(), tx())); + await h.poll(report(outbound(4000), remote())); assert.equal(h.line().classList.contains('error'), true); + value = await h.poll(report(pair())); + assert.deepEqual(value, { inbound: null, outbound: null }); + assert.equal(h.line().textContent, ''); assert.equal(h.line().classList.contains('error'), false); +}); + +it('whole sample actual SDK preserves zero and its candidate-pair fallback without collapsing directional labels', async t => { + const h = await setup(t); + let value = await h.poll(report(inbound(), outbound(), pair(), remote(0, 0))); + assert.equal(value.outbound?.rtt, 0); assert.equal(value.inbound?.rtt, 0.043); + assert.equal(h.line().textContent, text(rx('—', '43'), tx('0.0', '0'))); + value = await h.poll(report(inbound(2000, 150, 0, 2, 150), outbound(2000), pair(0), remote(0, 0))); + assert.equal(value.inbound?.lossPercent, 0); assert.equal(value.inbound?.bufferMs, 0); + assert.equal(h.line().textContent, text(rx('0.0', '0', '0'), tx('0.0', '0'))); + assert.equal(h.line().classList.contains('error'), false); + value = await h.poll(report(inbound(3000, 200, 0, 3, 200), outbound(3000, { remoteId: undefined }), pair(0.09))); + assert.equal(value.inbound?.rtt, 0.09); assert.equal(value.outbound?.rtt, 0.09); + assert.equal(h.line().textContent, text(rx('0.0', '90', '20'), tx('—', '90'))); +}); + +it('whole sample actual SDK rounds each RTT from seconds and the receive buffer already in milliseconds', async t => { + const h = await setup(t); await h.poll(report(inbound(), outbound(), pair(), remote())); + const value = await h.poll(report(inbound(2000, 150, 0, 3.0245, 150), outbound(2000), pair(0.04349), remote(0.1005, 0))); + assert.ok(Math.abs(value.inbound!.bufferMs! - 20.49) < 1e-10); + assert.equal(value.inbound?.rtt, 0.04349); assert.equal(value.outbound?.rtt, 0.1005); + assert.equal(h.line().textContent, text(rx('0.0', '43', '20'), tx('0.0', '101'))); +}); + +for (const direction of ['receive', 'send'] as const) { + it(`whole sample actual SDK ${direction} warning uses unrounded loss at 0, 4.99, 5 and 5.01 percent`, async t => { + const h = await setup(t); + let timestamp = 1000, received = 100, lost = 0; + if (direction === 'receive') await h.poll(report(inbound(timestamp, received, lost, 2, 100), outbound(timestamp), pair(10), remote(10, 0))); + for (const [loss, poor] of [[0, false], [4.99, false], [5, true], [5.01, true], [0, false]] as const) { + timestamp += 1000; received += 10000 - loss * 100; lost += loss * 100; + const value = await h.poll(direction === 'receive' + ? report(inbound(timestamp, received, lost, 2, 100), outbound(timestamp), pair(10), remote(10, 0)) + : report(outbound(timestamp), pair(10), remote(10, loss / 100))); + const measured = direction === 'receive' ? value.inbound!.lossPercent! : value.outbound!.lossPercent!; + assert.ok(Math.abs(measured - loss) < 1e-12); assert.equal(h.line().classList.contains('error'), poor); + assert.equal(h.line().textContent, direction === 'receive' + ? text(rx(loss.toFixed(1), '10000'), tx('0.0', '10000')) : text(noReceive, tx(loss.toFixed(1), '10000'))); + } + assert.equal(h.clock.timeouts().length, 0); assert.deepEqual(h.clock.intervals(2000), [h.timer]); + }); +} + +it('whole sample actual SDK keeps last matching RTP semantics instead of inventing an all-stream aggregate', async t => { + const h = await setup(t); + const value = await h.poll(report(inbound(), { ...inbound(), id: 'in-last', remoteId: 'receive-remote' }, + outbound(), { ...outbound(), id: 'out-last', remoteId: 'send-remote' }, pair(), remote(), + { ...remote(0.2, 0), id: 'receive-remote' }, { ...remote(0.3, 0.02), id: 'send-remote' })); + assert.equal(value.inbound?.rtt, 0.2); assert.equal(value.outbound?.rtt, 0.3); assert.equal(value.outbound?.lossPercent, 2); + assert.equal(h.line().textContent, text(rx('—', '200'), tx('2.0', '300'))); + assert.equal(h.line().classList.contains('error'), false); +}); + +it('whole sample actual SDK uses its shared completed baseline rather than promising a two-second delta', async t => { + const h = await setup(t); await h.poll(report(inbound(), pair())); + await h.poll(report(inbound(2000, 148, 2, 3, 150), pair())); + const historical = h.line().textContent; + assert.equal(historical, text(rx('4.0', '43', '20'), noSend)); + h.clock.advance(1000); const external = h.sdk.getStats(); + await h.resolve(report(inbound(3000, 198, 2, 4, 200), pair())); await external; + assert.equal(h.line().textContent, historical, 'an external SDK read only advances the shared delta baseline'); + h.clock.advance(1000); const value = await h.resolve(report(inbound(4000, 243, 7, 6, 250), pair())); + assert.equal(value.inbound?.lossPercent, 10); assert.equal(value.inbound?.bufferMs, 40); + assert.equal(h.line().textContent, text(rx('10.0', '43', '40'), noSend)); + assert.deepEqual(h.pending.map(value => value.at), [2000, 4000, 5000, 6000]); + assert.deepEqual(h.clock.intervals(2000), [h.timer]); assert.equal(h.clock.timeouts().length, 0); +}); + +it('whole sample actual SDK retains exact historical text on ordinary snapshots, same-seat reconnect and failed reads', async t => { + const h = await setup(t); await h.poll(report(inbound(), outbound(), pair(), remote())); + const line = h.line(), historical = line.textContent, count = writes(line), jobs = [...h.clock.jobs.keys()]; + for (let i = 0; i < 3; i++) h.snapshot(roomState()); + Object.assign(h.sdk, { currentStatus: 'reconnecting' }); + h.sdk.dispatchEvent(new CustomEvent('status', { detail: 'reconnecting' })); + assert.equal(line.textContent, historical); assert.equal(count(), 0); assert.equal(line.classList.contains('error'), true); + assert.deepEqual([...h.clock.jobs.keys()], jobs); assert.equal(h.pending.length, 1); + h.clock.advance(2000); h.pending.at(-1)!.work.reject(new Error('controlled failure')); await flushSample(); + assert.equal(line.textContent, historical); assert.equal(count(), 0); assert.equal(line.classList.contains('error'), true); + const next = await h.poll(report(inbound(3000, 148, 2, 3, 150), outbound(3000), pair(0.02), remote(0.03, 0))); + assert.equal(next.inbound?.lossPercent, 4); assert.equal(next.inbound?.bufferMs, 20); + assert.equal(line.textContent, text(rx('4.0', '20', '20'), tx('0.0', '30'))); assert.equal(count(), 1); + assert.equal(line.classList.contains('error'), false); assert.deepEqual([...h.clock.jobs.keys()], jobs); +}); + +it('whole sample actual SDK hidden resume keeps the shared delta interval and fences a retired completion', async t => { + const h = await setup(t); await h.poll(report(inbound(), pair())); + h.clock.advance(2000); const old = h.pending.at(-1)!.work; + const retired = h.clock.jobs.get(h.timer)!.callback; + h.setHidden(true); assert.equal(h.line().textContent, ''); assert.equal(h.line().classList.contains('error'), false); + assert.equal(h.clock.jobs.size, 0); h.clock.advance(10000); assert.equal(h.pending.length, 2); + h.setHidden(false); assert.equal(h.pending.length, 3); const timer = h.clock.intervals(2000)[0]!; + retired(); assert.equal(h.pending.length, 3); + const value = await h.resolve(report(inbound(14000, 243, 7, 6, 250), pair())); + assert.equal(value.inbound?.lossPercent, 100 * 7 / 150); assert.equal(value.inbound?.bufferMs, 1000 * 4 / 150); + assert.equal(h.line().textContent, text(rx('4.7', '43', '27'), noSend)); + old.resolve(report(inbound(3000), pair(0.9))); await flushSample(); + assert.deepEqual(h.completed.at(-1), { inbound: null, outbound: null }); + assert.equal(h.line().textContent, text(rx('4.7', '43', '27'), noSend)); + assert.deepEqual([...h.clock.jobs.keys()], [timer]); assert.equal(h.visibilityListeners.size, 1); +}); + +for (const identity of ['room', 'participant'] as const) { + it(`whole sample actual SDK ${identity} replacement retires both directional histories without an extra read`, async t => { + const h = await setup(t); await h.poll(report(inbound(), outbound(), pair(), remote())); + const line = h.line(), count = writes(line); h.clock.advance(2000); const old = h.pending.at(-1)!.work; + h.snapshot(identity === 'room' ? { ...roomState(), roomId: 'new-room' } : roomState({ participantId: 'new-person' })); + assert.equal(line.textContent, ''); assert.equal(line.classList.contains('error'), false); assert.equal(count(), 1); + assert.equal(h.pending.length, 2); assert.deepEqual(h.clock.intervals(2000), [h.timer]); + old.resolve(report(inbound(2000), outbound(2000), pair(0.9), remote(0.8, 0.9))); await flushSample(); + assert.equal(line.textContent, ''); assert.equal(line.classList.contains('error'), false); assert.equal(count(), 1); + h.clock.advance(2000); h.pending.at(-1)!.work.reject(new Error('new identity has no result')); await flushSample(); + assert.equal(line.textContent, ''); assert.equal(count(), 1); + await h.poll(report(inbound(4000, 148, 2, 3, 150), outbound(4000), pair(0.02), remote(0.03, 0))); + assert.equal(line.textContent, text(rx('4.0', '20', '20'), tx('0.0', '30'))); + assert.equal(line.classList.contains('error'), false); assert.equal(count(), 2); assert.equal(h.pending.length, 4); + }); +} + +it('whole sample successful no-direction result clears warning and advances the UI completion watermark', async t => { + const h = sampleApp(); t.after(() => h.cleanup()); await flushSample(); h.sdk.snapshot(roomState()); + const line = h.find(node => node.classList.contains('connection')); + h.clock.advance(2000); h.sdk.polls[0]!.resolve({ inbound: { rtt: 0.9, lossPercent: 90, bufferMs: 30 }, outbound: null }); + await flushSample(); assert.equal(line.textContent, text(rx('90.0', '900', '30'), noSend)); assert.equal(line.classList.contains('error'), true); + const count = writes(line), timer = h.clock.intervals(2000)[0]!; + h.clock.advance(6000); h.sdk.polls[2]!.resolve({ inbound: null, outbound: null }); await flushSample(); + assert.equal(line.textContent, ''); assert.equal(line.classList.contains('error'), false); assert.equal(count(), 1); + h.sdk.polls[1]!.resolve({ inbound: { rtt: 0.8, lossPercent: 80 }, outbound: null }); await flushSample(); + assert.equal(line.textContent, ''); assert.equal(line.classList.contains('error'), false); assert.equal(count(), 1); + assert.equal(h.sdk.polls[3]!.status, 'pending'); assert.deepEqual(h.clock.intervals(2000), [timer]); + h.sdk.polls[3]!.resolve({ inbound: { rtt: 0, lossPercent: 0, bufferMs: 0 }, outbound: null }); await flushSample(); + assert.equal(line.textContent, text(rx('0.0', '0', '0'), noSend)); assert.equal(count(), 2); + assert.deepEqual(h.sdk.joins, []); assert.deepEqual(h.sdk.raises, []); assert.deepEqual(h.sdk.previews, []); + assert.deepEqual(h.sdk.inputDevices, []); assert.deepEqual(h.sdk.outputDevices, []); +}); + +it('whole sample actual SDK omissions and finite RTT conversion overflow display unavailable metrics', async t => { + const h = await setup(t); + let value = await h.poll(report(inbound(1000, 100, 0, 2, 100, { jitter: Infinity }), outbound(), pair(-1), remote(NaN, 1.1))); + assert.equal(value.inbound?.rtt, undefined); assert.equal(value.outbound?.rtt, undefined); assert.equal(value.outbound?.lossPercent, undefined); + assert.equal(h.line().textContent, text(rx(), tx())); assert.equal(h.line().classList.contains('error'), false); + value = await h.poll(report(outbound(2000), remote(Number.MAX_VALUE, 0))); + assert.equal(value.outbound?.rtt, Number.MAX_VALUE); assert.equal(Number.isFinite(value.outbound?.rtt), true); + assert.equal(h.line().textContent, text(noReceive, tx('0.0'))); assert.equal(h.line().classList.contains('error'), false); + value = await h.poll(report(inbound(3000), pair(Number.MAX_VALUE))); + assert.equal(value.inbound?.rtt, Number.MAX_VALUE); assert.equal(h.line().textContent, text(rx(), noSend)); + assert.doesNotMatch(h.line().textContent, /NaN|Infinity/); +}); diff --git a/test/webStatsRetirement.test.ts b/test/webStatsRetirement.test.ts index 3f59827..6781207 100644 --- a/test/webStatsRetirement.test.ts +++ b/test/webStatsRetirement.test.ts @@ -93,7 +93,7 @@ async function actualSetup(t: TestContext) { const peer = Peer.instances.at(-1)!; h.clock.advance(2000); peer.polls.at(-1)!.resolve(report(2000)); await flushSample(); h.clock.advance(2000); peer.polls.at(-1)!.resolve(report(4000)); await flushSample(); - assert.match(line().textContent, /收聽掉包 20\.0%.*延遲 180 ms.*緩衝 10 ms/); + assert.match(line().textContent, /接收掉包 20\.0%.*接收 RTT 180 ms.*接收緩衝 10 ms/); assert.equal(line().classList.contains('error'), true); return peer; }; @@ -115,9 +115,9 @@ it('full sample actual retained-peer reconnect preserves rendered history, sched h.clock.advance(2000); const beforeDrop = peer.polls.at(-1)!; await h.drop(); assert.equal(peer.closes, 0); assert.equal(h.line().textContent, oldText); assert.equal(count.text, 0); assert.equal(count.error, 0); assert.equal(h.clock.intervals(2000)[0], timer); - beforeDrop.resolve(report(6000, 0.04)); await flushSample(); assert.match(h.line().textContent, /收聽掉包 20\.0%.*延遲 40 ms.*緩衝 10 ms/); + beforeDrop.resolve(report(6000, 0.04)); await flushSample(); assert.match(h.line().textContent, /接收掉包 20\.0%.*接收 RTT 40 ms.*接收緩衝 10 ms/); h.clock.advance(2000); peer.polls.at(-1)!.resolve(report(8000, 0.03)); await flushSample(); - assert.match(h.line().textContent, /延遲 30 ms/); + assert.match(h.line().textContent, /接收 RTT 30 ms/); const { state } = await h.resume(); assert.equal(state.me.participantId, id); assert.equal(Peer.instances.at(-1), peer); assert.equal(h.audio.pauses, beforePauses); assert.equal(h.clock.intervals(2000)[0], timer); assert.equal(count.text, 2); assert.equal(count.error, 2); assert.equal(peer.polls.length, 4); @@ -141,9 +141,9 @@ for (const settlement of ['resolve', 'reject'] as const) { h.clock.advance(2000); fresh.polls.at(-1)!.reject(new Error('new peer stats unavailable')); await flushSample(); cleared(h); assert.equal(count.text, 1); assert.equal(count.error, 1); h.clock.advance(2000); fresh.polls.at(-1)!.resolve(report(10000, 0.02)); await flushSample(); - assert.equal(h.line().textContent, '你的連線:延遲 20 ms'); assert.equal(h.line().classList.contains('error'), false); + assert.equal(h.line().textContent, '你的連線(上次完成讀取;SDK 取樣區間):接收掉包 —% · 接收 RTT 20 ms · 接收緩衝 — ms;傳送:暫無統計。RTT 不是端到端音訊延遲;不保證資料即時或目前可聽。'); assert.equal(h.line().classList.contains('error'), false); h.clock.advance(2000); fresh.polls.at(-1)!.resolve(report(12000, 0.03)); await flushSample(); - assert.match(h.line().textContent, /收聽掉包 20\.0%.*延遲 30 ms.*緩衝 10 ms/); + assert.match(h.line().textContent, /接收掉包 20\.0%.*接收 RTT 30 ms.*接收緩衝 10 ms/); assert.equal(count.text, 3); assert.equal(count.error, 3); assert.equal(fresh.polls.length, 3); assert.equal(h.audio.volume, 0.37); assert.equal(h.audio.muted, true); }); @@ -164,7 +164,7 @@ it('full sample actual public join to another room retires prior-room rendered d const fresh = Peer.instances.at(-1)!; assert.equal(fresh.polls.length, 0); h.clock.advance(2000); fresh.polls.at(-1)!.reject(new Error('no current stats')); await flushSample(); cleared(h); h.clock.advance(2000); fresh.polls.at(-1)!.resolve(report(8000, 0.025)); await flushSample(); - assert.equal(h.line().textContent, '你的連線:延遲 25 ms'); assert.equal(h.line().classList.contains('error'), false); + assert.equal(h.line().textContent, '你的連線(上次完成讀取;SDK 取樣區間):接收掉包 —% · 接收 RTT 25 ms · 接收緩衝 — ms;傳送:暫無統計。RTT 不是端到端音訊延遲;不保證資料即時或目前可聽。'); assert.equal(h.line().classList.contains('error'), false); }); it('full sample actual hidden/resume and external stats retain SDK delta baselines and completion fencing', async t => { @@ -175,11 +175,11 @@ it('full sample actual hidden/resume and external stats retain SDK delta baselin h.clock.advance(10000); assert.equal(peer.polls.length, 3); h.setHidden(false); assert.equal(peer.polls.length, 4); const timer = h.clock.intervals(2000)[0]; retired(); assert.equal(peer.polls.length, 4); peer.polls[3]!.resolve(report(16000, 0.05)); await flushSample(); - assert.match(h.line().textContent, /收聽掉包 20\.0%.*延遲 50 ms.*緩衝 10 ms/); - hiddenPending.resolve(report(6000, 0.9)); await flushSample(); assert.match(h.line().textContent, /延遲 50 ms/); + assert.match(h.line().textContent, /接收掉包 20\.0%.*接收 RTT 50 ms.*接收緩衝 10 ms/); + hiddenPending.resolve(report(6000, 0.9)); await flushSample(); assert.match(h.line().textContent, /接收 RTT 50 ms/); const external = h.sdk.getStats(); peer.polls[4]!.resolve(report(17000, 0.06, 850)); await external; h.clock.advance(2000); peer.polls[5]!.resolve(report(18000, 0.07, 850)); await flushSample(); - assert.match(h.line().textContent, /收聽掉包 0\.0%.*延遲 70 ms/); assert.equal(h.line().classList.contains('error'), false); + assert.match(h.line().textContent, /接收掉包 0\.0%.*接收 RTT 70 ms/); assert.equal(h.line().classList.contains('error'), false); assert.equal(h.clock.intervals(2000)[0], timer); assert.equal(peer.closes, 0); }); @@ -202,7 +202,7 @@ function writes(line: SampleNode) { async function setup(t: TestContext) { const h = sampleApp({ source }); t.after(() => h.cleanup()); await flushSample(); h.sdk.snapshot(roomState()); h.clock.advance(2000); h.sdk.polls[0]!.resolve(result()); await flushSample(); - assert.match(connection(h).textContent, /收聽掉包 20\.0%.*延遲 180 ms.*緩衝 10 ms/); + assert.match(connection(h).textContent, /接收掉包 20\.0%.*接收 RTT 180 ms.*接收緩衝 10 ms/); assert.equal(connection(h).classList.contains('error'), true); return h; } @@ -220,9 +220,9 @@ for (const identity of ['room', 'participant'] as const) { await flushSample(); cleared(h); assert.equal(count.text, 1); assert.equal(count.error, 1); h.clock.advance(2000); h.sdk.polls.at(-1)!.reject(new Error('first new failure')); await flushSample(); cleared(h); h.clock.advance(4000); const [earlier, later] = h.sdk.polls.slice(-2); - later!.resolve(result(0.02, 0)); await flushSample(); assert.match(line.textContent, /延遲 20 ms/); + later!.resolve(result(0.02, 0)); await flushSample(); assert.match(line.textContent, /接收 RTT 20 ms/); assert.equal(line.classList.contains('error'), false); - earlier!.resolve(result(0.7, 70)); await flushSample(); assert.match(line.textContent, /延遲 20 ms/); + earlier!.resolve(result(0.7, 70)); await flushSample(); assert.match(line.textContent, /接收 RTT 20 ms/); assert.equal(count.text, 2); assert.equal(count.error, 2); assert.equal(h.sdk.polls.length, 5); assert.equal(h.sdk.joins.length, 0); assert.equal(h.sdk.raises.length, 0); assert.equal(h.sdk.previews.length, 0); assert.deepEqual(h.sdk.inputDevices, []); assert.deepEqual(h.sdk.outputDevices, []); @@ -239,7 +239,7 @@ it('full sample ordinary snapshots and same-seat reconnect add no history writes assert.equal(line.textContent, text); assert.equal(line.classList.contains('error'), true); assert.equal(count.text, 0); assert.equal(count.error, 0); assert.deepEqual([...h.clock.jobs.keys()], jobs); assert.equal(h.sdk.polls.length, 1); h.clock.advance(2000); h.sdk.polls[1]!.resolve(result(0.03, 0)); await flushSample(); - assert.equal(count.text, 1); assert.equal(count.error, 1); assert.match(line.textContent, /延遲 30 ms/); + assert.equal(count.text, 1); assert.equal(count.error, 1); assert.match(line.textContent, /接收 RTT 30 ms/); assert.equal(h.sdk.joins.length, 0); assert.equal(h.sdk.raises.length, 0); assert.equal(h.sdk.previews.length, 0); }); @@ -251,7 +251,7 @@ it('full sample identity replacement while hidden keeps retired results fenced a old.resolve(result(0.9, 90)); await flushSample(); cleared(h); h.sdk.polls[2]!.reject(new Error('resumed sample failed')); await flushSample(); cleared(h); h.clock.advance(2000); h.sdk.polls[3]!.resolve(result(0.02, 0)); await flushSample(); - assert.match(connection(h).textContent, /延遲 20 ms/); assert.equal(connection(h).classList.contains('error'), false); + assert.match(connection(h).textContent, /接收 RTT 20 ms/); assert.equal(connection(h).classList.contains('error'), false); }); for (const property of ['text', 'error'] as const) { @@ -281,12 +281,12 @@ for (const property of ['text', 'error'] as const) { assert.equal(h.sdk.me!.participantId, 'nested-owner'); assert.equal(h.find(node => node.dataset.roomAction === 'hand'), newAction, 'outer render cannot reinstall a superseded view'); assert.equal(newAction!.disabled, true); assert.equal(newAction!.attributes['aria-busy'], 'true'); - await flushSample(); assert.match(line.textContent, /延遲 25 ms/); assert.equal(line.classList.contains('error'), false); + await flushSample(); assert.match(line.textContent, /接收 RTT 25 ms/); assert.equal(line.classList.contains('error'), false); assert.equal(h.sdk.polls[callbackPoll]!.status, 'fulfilled'); assert.equal(h.find(node => node.attributes.role === 'alert').hidden, true); newAction!.onclick!(); assert.equal(h.sdk.raises.length, 2, 'old completion cannot unlock the new pending action'); h.sdk.raises[1]!.resolve(undefined); await flushSample(); assert.equal(newAction!.disabled, false); - h.sdk.snapshot(roomState({ participantId: 'nested-owner' })); assert.match(line.textContent, /延遲 25 ms/); + h.sdk.snapshot(roomState({ participantId: 'nested-owner' })); assert.match(line.textContent, /接收 RTT 25 ms/); }); } diff --git a/web/src/main.ts b/web/src/main.ts index bd6a510..9e8f74a 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1065,13 +1065,20 @@ function refreshConnectionLine(): void { // A newer pending poll must not starve completed samples; older completions cannot replace newer results. if (!client.state || document.hidden || generation !== roomActionGeneration || sequence <= connectionResultWatermark) return; connectionResultWatermark = sequence; - const parts: string[] = []; - if (inbound?.lossPercent !== undefined) parts.push(`收聽掉包 ${inbound.lossPercent.toFixed(1)}%`); - if (outbound?.lossPercent !== undefined) parts.push(`發言掉包 ${outbound.lossPercent.toFixed(1)}%`); - const rtt = inbound?.rtt ?? outbound?.rtt; - if (rtt !== undefined) parts.push(`延遲 ${Math.round(rtt * 1000)} ms`); - if (inbound?.bufferMs !== undefined) parts.push(`緩衝 ${Math.round(inbound.bufferMs)} ms`); - connectionLine.textContent = parts.length ? `你的連線:${parts.join(' · ')}` : ''; + const milliseconds = (value: number | undefined, scale = 1): string => { + if (value === undefined) return '—'; + const ms = value * scale; + return Number.isFinite(ms) ? String(Math.round(ms)) : '—'; + }; + const receive = inbound + ? `接收掉包 ${inbound.lossPercent?.toFixed(1) ?? '—'}% · 接收 RTT ${milliseconds(inbound.rtt, 1000)} ms · 接收緩衝 ${milliseconds(inbound.bufferMs)} ms` + : '接收:暫無統計'; + const send = outbound + ? `傳送掉包 ${outbound.lossPercent?.toFixed(1) ?? '—'}% · 傳送 RTT ${milliseconds(outbound.rtt, 1000)} ms` + : '傳送:暫無統計'; + connectionLine.textContent = inbound || outbound + ? `你的連線(上次完成讀取;SDK 取樣區間):${receive};${send}。RTT 不是端到端音訊延遲;不保證資料即時或目前可聽。` + : ''; connectionLine.classList.toggle('error', Math.max(inbound?.lossPercent ?? 0, outbound?.lossPercent ?? 0) >= POOR_LOSS_PERCENT); }, () => {}); } From e70aee26491898f72d5b7b2a09fa35c8f0276f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=A5=E8=AA=9E=E3=81=BB=E3=81=97=E3=81=9E=E3=82=89?= <92282870+YueyuHoshizora@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:01:12 +0800 Subject: [PATCH 15/38] fix(web): retire stage quality on viewer departure (batch 209) --- PLAN.md | 3 + README.md | 2 +- test/webQualityDeparture.test.ts | 433 +++++++++++++++++++++++++++++++ web/src/main.ts | 72 ++++- 4 files changed, 495 insertions(+), 15 deletions(-) create mode 100644 test/webQualityDeparture.test.ts diff --git a/PLAN.md b/PLAN.md index 3c584b7..6a7e8c0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -862,3 +862,6 @@ N 路混音管線(decode→mix→encode)、不含自己的台上混音、lim 229. **完整範例方向別連線統計(第208批,2026-10-16):** immutable207 完整sample+實際SDK controlledRTC證明有效接收RTT43ms/傳送RTT100ms被合併為泛稱延遲43ms,且方向缺失與首樣本delta不可用無明確區別。僅既有refreshConnectionLine成功投影呈現五指標:receive loss/RTT/buffer、send loss/RTT;上次完成讀取/SDK取樣區間、RTT非端到端且非即時可聽保證。present方向缺metric顯示破折號,null方向暫無統計,bothnull仍整行清空並推進原watermark;零保留、loss一位小數、秒轉毫秒、buffer原毫秒,非有限轉換局部顯示破折號。不改SDK最後匹配RTP與共享delta基準,不冒充全串流聚合或固定2秒區間。紅警告原未round maxloss>=5保持,4.99顯示5.0但不紅,RTT不判健康。timer/hidden/same-seat reconnect/highercompletedsequence/失敗保留/207換席清歷史全部保持,無新增讀取/node/control/media。七檔main投影/新完整模組test/三既有精確文案expectation+README/PLAN,原數值與生命周期assertions不弱化,formatter留原slice內不擴fixture。原生browser/AT/硬體音訊未驗收,無延遲或音質改善宣稱。 + + +230. **完整範例舞台品質讀者下台即退役(第209批,2026-10-16):** immutable208 完整sample+實際SDK→validator→MockHub證明被移下、主動下台與台下主控移交三條真實role/stageleft→state次序,在晚到權威快照前仍展開顯示舊8%/2%/37ms品質,甚至移下提示已出現。限定舞台品質details的pure viewer-loss fence,own role audience/own stageleft非controller/own offstage outgoing transferred先保留失效事實再清報告/rows/text並hide/collapse;以event.role保留controller下台例外,onstage移交與他人delta不退休讀者。綁舊snapshot/seat,普通status/quality/notice/render不能借舊state重開,新的目前權威state再核資格,須新report才有數字。不套用widget完整receipt集合,不宣稱任意無epoch重放可辨識;未發現真實舊socket/Hub async report重灌,亦不改server收件政策。既有reconnect清報告但保留展開、rowqualitybadges、本人stats、timer/SDK/media皆保持;新reset/paint在原roomactiongeneration護欄後,舊continuation不得清較新合法entry,不廣修控制生命周期。四檔main/新完整模組test+README/PLAN,原assertions保留;實際三反例、controller/transfer例外、same-seat/newstate/report恢復、socket/reconnect、callback/actionownership及無額外操作列回歸。原生browser/AT/硬體音訊未驗收。 diff --git a/README.md b/README.md index b8bdc4f..51763ce 100644 --- a/README.md +++ b/README.md @@ -385,7 +385,7 @@ npm audit 完整範例的「點擊啟用音訊」會在播放要求尚未完成時維持停用與 `aria-busy`,即使房間快照、上台邀請或其他提示重新繪製,也不會再次呼叫播放。失敗仍顯示原有錯誤並可手動重試;離房或換身分撤銷舊操作,信令重連保留目前收聽與 pending 狀態。播放呼叫仍同步發生於點擊處理內,不新增自動重試或改變瀏覽器 autoplay 規則。驗證使用完整範例與公開 SDK 的受控 audio/socket,未驗證原生瀏覽器音訊效果。 -完整範例提供可展開的「舞台連線品質」面板,只對主控與台上者顯示。使用既有伺服器品質事件,列出目前發言者最近收到的上行/下行掉包率與 RTT;缺少或無效數值顯示「—」,尚未收到資料時顯示等待提示,不增加輪詢。RTT 不是端到端音訊延遲,回報也沒有時間戳,不能視為即時音訊保證。有效更新保留展開狀態與對應列;離房、換身分或失去檢視資格清空並收合。重連時清空面板資料、保留展開狀態,恢復後等待下一次收到的回報;該回報的收集仍可能早於恢復連線。名稱取目前舞台名單並以文字呈現,未知/已離台的 ID 不顯示;既有品質警示不變。驗證涵蓋模型/Hub 收件角色與受控 DOM 生命週期,未完成原生瀏覽器、鍵盤或輔助科技驗收。 +完整範例提供可展開的「舞台連線品質」面板,只對主控與台上者顯示。使用既有伺服器品質事件,列出目前發言者最近收到的上行/下行掉包率與 RTT;缺少或無效數值顯示「—」,尚未收到資料時顯示等待提示,不增加輪詢。RTT 不是端到端音訊延遲,回報也沒有時間戳,不能視為即時音訊保證。有效更新保留展開狀態與對應列;離房、換身分或失去檢視資格清空並收合。本人轉為觀眾、收到非主控下台通知,或台下主控已移交控制權時,即使其後的房間快照尚未抵達,也先清空、隱藏並收合;普通重繪或仍保留的舊快照不會重新開啟。新的目前權威快照重新核對資格後,仍須新品質回報才有數字。主控下台仍可閱讀、保留台位的控制權移交與其他人的下台不撤銷本人的閱讀資格。這是現有通知順序的畫面隔離,不替沒有房間世代/時間戳的事件新增任意重放保證。仍具檢視資格時,重連會清空面板資料並保留展開狀態,恢復後等待下一次收到的回報;該回報的收集仍可能早於恢復連線。名稱取目前舞台名單並以文字呈現,未知/已離台的 ID 不顯示;既有品質警示不變。驗證涵蓋模型/Hub 收件角色與受控 DOM 生命週期,未完成原生瀏覽器、鍵盤或輔助科技驗收。 範例的品質警示沿用每次 render 的 participant→rows 索引,只更新前次/本次品質報告涉及的人,省去每則事件掃描所有觀眾列;省略的人仍會移除舊警示,同人出現在舉手與觀眾名單時會一併更新。`test/webQuality.test.ts` 以 301 人、593 個顯示列、100 次單人品質報告計數:完整列查詢 100 → 0 次,badge 查詢 59,300 → 100 次。這是 fake DOM 操作數,不是瀏覽器 CPU/延遲量測;房間重繪、品質資料本身的更新頻率與 badge 文案不變。 diff --git a/test/webQualityDeparture.test.ts b/test/webQualityDeparture.test.ts new file mode 100644 index 0000000..9878d9b --- /dev/null +++ b/test/webQualityDeparture.test.ts @@ -0,0 +1,433 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { it, type TestContext } from 'node:test'; +import { setImmediate as nextTurn } from 'node:timers/promises'; +import { StageClient, decodeName } from '../packages/client/src/index.ts'; +import { FakeSession, harness, SENDRECV_OFFER, testConfig } from './helpers.ts'; +import { sampleApp, flushSample, roomState, SampleNode } from './fixtures/sampleApp.ts'; +import { parseClientMessage } from '../src/ws/validate.ts'; +import type { ClientMessage, ConnectionQuality, ParticipantView, RoomStatePayload, ServerMessage } from '../shared/protocol.ts'; + +// Complete production sample, public SDK, validator and Hub execute unchanged. Only DOM, +// timers, frame delivery and native RTC are controlled; native rendering/audio is unverified. +const source = readFileSync(process.env.B209_SOURCE ?? new URL('../web/src/main.ts', import.meta.url), 'utf8'); +class Socket { + static OPEN = 1; static instances: Socket[] = []; + readyState = 1; sent: ClientMessage[] = []; + onmessage: ((event: { data: string }) => void) | null = null; + onclose: (() => void) | null = null; + constructor() { Socket.instances.push(this); } + send(data: string) { assert.equal(this.readyState, 1); this.sent.push(JSON.parse(data)); } + deliver(message: ServerMessage) { this.onmessage?.({ data: JSON.stringify(message) }); } + close() { this.readyState = 3; this.onclose?.(); } +} +class Peer { + static instances: Peer[] = []; + connectionState = 'connected'; signalingState = 'stable'; localDescription = null; + rejectOffer?: (reason: Error) => void; + constructor() { Peer.instances.push(this); } + addTransceiver() { return { direction: 'recvonly', sender: { track: null, async replaceTrack() {} } }; } + createOffer(): Promise { return new Promise((_resolve, reject) => { this.rejectOffer = reject; }); } + getStats() { return Promise.resolve(new Map()); } + close() { this.connectionState = 'closed'; this.rejectOffer?.(new Error('controlled cleanup')); } +} +let requestSequence = 0; +async function actualSetup(t: TestContext, mode: 'speaker' | 'offstage-controller' | 'onstage-controller' = 'speaker') { + const config = testConfig(c => { c.rooms.presenceBroadcastMs = 0; c.limits.controlPerSecond = 2000; c.rooms.participantGraceMs = 1000; }); + const wire = harness(config), host = await wire.create('Publishing Host', { codeRequired: false }); + const successor = await wire.join(host.roomId, undefined, 'Successor'); + Socket.instances = []; Peer.instances = []; + const globals = { window: new EventTarget(), WebSocket: Socket, RTCPeerConnection: Peer, + navigator: { mediaDevices: { async enumerateDevices() { return []; }, async getUserMedia() { throw new DOMException('Controlled capture refusal', 'NotAllowedError'); } } } }; + const saved = new Map(Object.keys(globals).map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value }); + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); + let now = 1000; t.mock.method(performance, 'now', () => now); + const sdk = new StageClient({ url: 'wss://sample.test/ws', audioElement: { srcObject: null, volume: 1, muted: false, pause() {}, async play() {} } as unknown as HTMLAudioElement, + enableMicMeter: false, reconnect: { initialDelayMs: 25, maxDelayMs: 25 } }); + const h = sampleApp({ sdk, source, decodeName }); + const sessions = new Map(), cursors = new Map(); + const tick = async (ms = 1) => { now += ms; t.mock.timers.tick(ms); await flushSample(); }; + const until = async (predicate: () => boolean) => { for (let i = 0; i < 100 && !predicate(); i++) await tick(); assert.ok(predicate(), 'expected SDK progress'); }; + const attach = (socket: Socket) => { const session = new FakeSession(); sessions.set(socket, session); cursors.set(socket, 0); wire.hub.attach(session); drain(socket); return session; }; + function drain(socket: Socket, stopBeforeState = false): ServerMessage[] { + const session = sessions.get(socket)!; const delivered: ServerMessage[] = []; + let cursor = cursors.get(socket)!; + while (cursor < session.inbox.length) { + const message = session.inbox[cursor]!; + if (stopBeforeState && message.type === 'room:state') break; + cursor++; cursors.set(socket, cursor); delivered.push(message); socket.deliver(message); + } + return delivered; + } + async function command(session: FakeSession, data: Record) { + const message = parseClientMessage(JSON.stringify({ ...data, requestId: data.requestId ?? `discovery-${requestSequence++}` }), config.limits); + await wire.hub.handle(session, message); + assert.ok('requestId' in message, 'test commands use acknowledged protocol messages'); + const result = session.inbox.find(item => (item.type === 'ok' || item.type === 'error') && item.requestId === message.requestId); + assert.equal(result?.type, 'ok', JSON.stringify(result)); + } + async function publicRequest(work: Promise, socket: Socket, type: string, hold = false) { + void work.catch(() => {}); + await until(() => socket.sent.some(message => message.type === type && !handled.has(message))); + const request = socket.sent.find(message => message.type === type && !handled.has(message))!; handled.add(request); + await command(sessions.get(socket)!, request as unknown as Record); + const messages = drain(socket, hold); if (!hold) { await work; await flushSample(); } + return { work, messages }; + } + const handled = new Set(); + const join = sdk.join({ roomId: host.roomId, name: 'Viewer' }); await flushSample(); + const socket = Socket.instances.at(-1)!, session = attach(socket); + await publicRequest(join, socket, 'join'); + const id = sdk.me!.participantId; + if (mode !== 'offstage-controller') { + await publicRequest(sdk.raiseHand(), socket, 'hand:raise'); + await command(host.s, { type: 'stage:approve', targetId: id }); drain(socket); await flushSample(); + } + if (mode !== 'speaker') { await command(host.s, { type: 'control:transfer', targetId: id }); drain(socket); await flushSample(); } + await command(host.s, { type: 'rtc:offer', payload: SENDRECV_OFFER }); drain(socket); await flushSample(); + let received = 92, lost = 8; + wire.transport.getStats = async () => ({ inbound: { packetsReceived: received, packetsLost: lost }, outbound: { fractionLost: 0.02 }, rttMs: 37 }); + const fireQuality = async (deliver = true) => { + const timer = wire.timers.find(item => !item.cleared && item.ms === config.rooms.qualityIntervalMs); assert.ok(timer); + timer.cleared = true; timer.fn(); await nextTurn(); if (deliver) drain(socket); await flushSample(); received += 92; lost += 8; + }; + const panel = h.id('stage-quality'); + const list = h.find(node => node.classList.contains('stage-quality-list')); + const view = () => ({ hidden: panel.hidden, open: panel.attributes.open === '', rows: list.children.map(row => row.textContent), role: sdk.me?.role, onStage: sdk.me?.onStage }); + await fireQuality(); panel.setAttribute('open', ''); + assert.equal(panel.hidden, false); assert.equal(list.children.length, 1); assert.match(list.textContent, /8\.0%.*2\.0%.*37 ms/); + t.after(async () => { + try { await h.cleanup(); await wire.hub.shutdown(); await wire.transport.close(); await flushSample(); } + finally { for (const [key, descriptor] of saved) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); } } + }); + return { ...h, wire, config, host, successor, socket, session, sdk, id, panel, list, view, command, publicRequest, drain, fireQuality, tick, until, attach, sessions, cursors }; +} +function actualReset(h: Awaited>) { + assert.equal(h.panel.hidden, true); assert.equal(h.panel.attributes.open, undefined); assert.equal(h.list.children.length, 0); +} +it('actual SDK/validator/Hub removal clears the panel at own role before the delayed state', async t => { + const h = await actualSetup(t); + await h.command(h.host.s, { type: 'stage:remove', targetId: h.id }); + const roleObservations: unknown[] = []; + h.sdk.on('role', event => { if (event.detail.participantId === h.id) roleObservations.push(h.view()); }); + const messages = h.drain(h.socket, true); await h.tick(250); + assert.equal(roleObservations.length, 1); + assert.deepEqual(roleObservations[0], { hidden: true, open: false, rows: [], role: 'speaker', onStage: true }); + assert.deepEqual(messages.map(m => m.type), ['role:update', 'stage:left']); + assert.match(h.app.textContent, /主控已將你移出舞台/); + assert.equal(h.sdk.me!.role, 'speaker'); assert.equal(h.sdk.me!.onStage, true); + actualReset(h); + h.drain(h.socket); await flushSample(); actualReset(h); + const qualityBefore = h.session.all('quality').length; await h.fireQuality(); + assert.equal(h.session.all('quality').length, qualityBefore, 'Hub excludes now-audience recipient'); actualReset(h); +}); + +it('actual public SDK leaveStage clears at the loss prefix, before state and request ACK', async t => { + const h = await actualSetup(t); + const result = await h.publicRequest(h.sdk.leaveStage(), h.socket, 'stage:leave', true); + await h.tick(250); assert.deepEqual(result.messages.map(m => m.type), ['role:update', 'stage:left']); + actualReset(h); + const tail = h.drain(h.socket); await result.work; actualReset(h); + assert.equal(tail[0]!.type, 'room:state'); assert.ok(tail.some(m => m.type === 'ok')); +}); + +it('actual public SDK offstage transfer clears at transferred before own role and delayed state', async t => { + const h = await actualSetup(t, 'offstage-controller'); + const observations: unknown[] = []; + h.sdk.on('transferred', () => observations.push({ event: 'transferred', ...h.view() })); + h.sdk.on('role', event => { if (event.detail.participantId === h.id) observations.push({ event: 'own-role', ...h.view() }); }); + const result = await h.publicRequest(h.sdk.transferControl(h.successor.id), h.socket, 'control:transfer', true); + await h.tick(250); + assert.deepEqual(observations, ['transferred', 'own-role'].map(event => ({ event, hidden: true, open: false, rows: [], role: 'controller', onStage: false }))); assert.deepEqual(result.messages.map(m => m.type), ['control:transferred', 'role:update', 'role:update']); + actualReset(h); + h.drain(h.socket); await result.work; actualReset(h); + const count = h.session.all('quality').length; await h.fireQuality(); assert.equal(h.session.all('quality').length, count); actualReset(h); +}); + +it('actual SDK control: onstage controller transfer preserves eligibility, historical rows and expansion', async t => { + const h = await actualSetup(t, 'onstage-controller'), row = h.list.children[0]; + const result = await h.publicRequest(h.sdk.transferControl(h.successor.id), h.socket, 'control:transfer', true); + assert.equal(h.panel.hidden, false); assert.equal(h.list.children[0], row); assert.equal(h.panel.attributes.open, ''); + h.drain(h.socket); await result.work; assert.equal(h.sdk.me!.role, 'speaker'); assert.equal(h.sdk.me!.onStage, true); + assert.equal(h.panel.hidden, false); assert.equal(h.list.children[0], row); assert.equal(h.panel.attributes.open, ''); +}); + +it('actual SDK control: controller stepping down remains eligible and retains another publisher history', async t => { + const h = await actualSetup(t, 'onstage-controller'), row = h.list.children[0]; + const result = await h.publicRequest(h.sdk.leaveStage(), h.socket, 'stage:leave', true); + assert.deepEqual(result.messages.map(m => m.type), ['stage:left']); + assert.equal(h.panel.hidden, false); assert.equal(h.list.children[0], row); + h.drain(h.socket); await result.work; assert.equal(h.sdk.me!.role, 'controller'); assert.equal(h.sdk.me!.onStage, false); + assert.equal(h.panel.hidden, false); assert.equal(h.list.children[0], row); assert.equal(h.panel.attributes.open, ''); +}); + +it('actual SDK control: unrelated departure, same-seat mic and rename changes do not revoke eligible viewer history', async t => { + const h = await actualSetup(t, 'offstage-controller'), row = h.list.children[0], text = row!.textContent; + await h.publicRequest(h.sdk.setName('Renamed Viewer'), h.socket, 'participant:rename'); + assert.equal(h.list.children[0], row); assert.equal(row!.textContent, text); + await h.command(h.session, { type: 'stage:approve', targetId: h.id }); h.drain(h.socket); await flushSample(); + await h.publicRequest(h.sdk.mute(), h.socket, 'mic:mute'); assert.equal(h.list.children[0], row); + assert.equal(h.list.children[0], row, 'microphone rejection from onstage promotion preserves historical transport report'); + await h.command(h.host.s, { type: 'stage:leave' }); const messages = h.drain(h.socket, true); + assert.deepEqual(messages.map(m => m.type), ['role:update', 'stage:left']); + assert.equal(h.panel.hidden, false); assert.equal(h.list.children[0], row, 'another participant departure does not retire own viewer'); + h.drain(h.socket); assert.equal(h.panel.hidden, false); assert.equal(h.list.children.length, 0, 'current roster prunes departed publisher'); +}); + +it('actual SDK control: an in-flight old stats round settling after demotion is not sent to demoted viewer', async t => { + const h = await actualSetup(t); const pending = Promise.withResolvers>>(); + h.wire.transport.getStats = () => pending.promise; + const timer = h.wire.timers.find(item => !item.cleared && item.ms === h.config.rooms.qualityIntervalMs)!; + const before = h.session.all('quality').length; timer.cleared = true; timer.fn(); await flushSample(); + await h.command(h.host.s, { type: 'stage:remove', targetId: h.id }); + pending.resolve({ inbound: { packetsReceived: 184, packetsLost: 16 }, rttMs: 99 }); await nextTurn(); + assert.equal(h.session.all('quality').length, before); + const messages = h.drain(h.socket); assert.equal(messages.filter(m => m.type === 'quality').length, 0); actualReset(h); +}); + +it('actual SDK control: reconnect clears reports and old socket frames cannot repopulate; resume waits for next report', async t => { + const h = await actualSetup(t); const oldQuality = h.session.last('quality')!; + h.socket.close(); await h.wire.hub.detach(h.session); await flushSample(); + assert.equal(h.sdk.status, 'reconnecting'); assert.equal(h.panel.hidden, false); assert.equal(h.list.children.length, 0); assert.equal(h.panel.attributes.open, ''); + h.socket.deliver(oldQuality); assert.equal(h.list.children.length, 0, 'SDK rejects frames from retired socket'); + await h.tick(25); const resumed = Socket.instances.at(-1)!; assert.notEqual(resumed, h.socket); const session = h.attach(resumed); + await h.until(() => resumed.sent.some(m => m.type === 'join')); + const request = resumed.sent.find(m => m.type === 'join')!; + await h.command(session, request as unknown as Record); + h.drain(resumed); await flushSample(); assert.equal(h.sdk.me!.participantId, h.id); + assert.equal(h.list.children.length, 0); assert.equal(h.panel.attributes.open, ''); + await h.fireQuality(false); h.drain(resumed); assert.equal(h.list.children.length, 1); +}); + +it('actual SDK control: public room replacement clears reports and expansion; retired socket report cannot revive', async t => { + const h = await actualSetup(t), oldQuality = h.session.last('quality')!; + const another = await h.wire.create('Second Host', { codeRequired: false }); + h.socket.close(); await h.wire.hub.detach(h.session); await flushSample(); + const join = h.sdk.join({ roomId: another.roomId, name: 'Second Viewer' }); await flushSample(); + const socket = Socket.instances.at(-1)!; h.attach(socket); await h.publicRequest(join, socket, 'join'); + actualReset(h); h.socket.deliver(oldQuality); actualReset(h); + const id = h.sdk.me!.participantId; await h.command(another.s, { type: 'control:transfer', targetId: id }); h.drain(socket); await flushSample(); + assert.equal(h.panel.hidden, false); assert.equal(h.list.children.length, 0); assert.equal(h.panel.attributes.open, undefined); +}); + + +it('actual SDK control: role loss then transport loss stays hidden against the retained old snapshot until resume state', async t => { + const h = await actualSetup(t); + await h.command(h.host.s, { type: 'stage:remove', targetId: h.id }); h.drain(h.socket, true); + h.socket.close(); await h.wire.hub.detach(h.session); await flushSample(); + assert.equal(h.sdk.status, 'reconnecting'); assert.equal(h.sdk.me!.onStage, true); + actualReset(h); + await h.tick(25); const resumed = Socket.instances.at(-1)!; const session = h.attach(resumed); + await h.until(() => resumed.sent.some(m => m.type === 'join')); + await h.command(session, resumed.sent.find(m => m.type === 'join')! as unknown as Record); + h.drain(resumed); await flushSample(); assert.equal(h.sdk.me!.onStage, false); actualReset(h); +}); + +it('actual SDK control: same-seat reapproval needs a new quality event and does not restore predeparture history', async t => { + const h = await actualSetup(t); + await h.command(h.host.s, { type: 'stage:remove', targetId: h.id }); h.drain(h.socket); actualReset(h); + await h.command(h.session, { type: 'hand:raise' }); h.drain(h.socket); + await h.command(h.host.s, { type: 'stage:approve', targetId: h.id }); h.drain(h.socket); await flushSample(); + assert.equal(h.panel.hidden, false); assert.equal(h.panel.attributes.open, undefined); assert.equal(h.list.children.length, 0); + await h.fireQuality(); assert.equal(h.list.children.length, 1); +}); +// The following are synthetic host-callback probes, not claims about wire ordering or replay. +type App = ReturnType; +type View = Pick; +const clone = (value: T): T => structuredClone(value); +function eligibleState(me: Partial = {}): RoomStatePayload { + const state = roomState({ participantId: 'viewer', name: 'Viewer', role: 'speaker', onStage: true, ...me }); + state.speakers = [{ ...state.me, participantId: 'publisher', name: 'Publisher', role: 'speaker', onStage: true }]; + if (state.me.onStage) state.speakers.unshift(state.me); + state.status = 'live'; return state; +} +const report = (rttMs = 37): ConnectionQuality => ({ participantId: 'publisher', uplinkLossPercent: 8, downlinkLossPercent: 2, rttMs }); +const panel = (h: View) => h.id('stage-quality'); +const list = (h: View) => h.find(node => node.classList.contains('stage-quality-list')); +const statusLine = (h: View) => h.find(node => node.classList.contains('stage-quality-status')); +function syntheticReset(h: View): void { + assert.equal(panel(h).hidden, true); assert.equal(panel(h).attributes.open, undefined); + assert.equal(list(h).children.length, 0); assert.equal(statusLine(h).textContent, ''); +} +async function syntheticSetup(t: TestContext, state = eligibleState()) { + const h = sampleApp({ source, decodeName }); t.after(() => h.cleanup()); await flushSample(); + h.sdk.snapshot(state); h.sdk.emit('quality', { participants: [report()] }); panel(h).setAttribute('open', ''); + assert.equal(list(h).children.length, 1); return h; +} +type Loss = 'role' | 'stageleft' | 'transferred'; +function lose(h: App, kind: Loss, participantId = h.sdk.me!.participantId): void { + if (kind === 'role') h.sdk.emit('role', { participantId, role: 'audience' }); + else if (kind === 'stageleft') { + const detail = { participantId, role: 'audience' as const, reason: 'removed' }; h.sdk.emit('stageleft', detail); + } else h.sdk.emit('transferred', { fromId: participantId, toId: 'successor' }); +} +function lossState(kind: Loss): RoomStatePayload { + return eligibleState(kind === 'transferred' ? { role: 'controller', onStage: false } : {}); +} + +type Surface = 'hidden' | 'open' | 'status' | 'list'; +function onReset(h: View, surface: Surface, callback: () => void): () => boolean { + let fired = false; + const invoke = () => { if (!fired) { fired = true; callback(); } }; + if (surface === 'hidden') { + const node = panel(h); let hidden = node.hidden; + Object.defineProperty(node, 'hidden', { configurable: true, get: () => hidden, + set(value: boolean) { hidden = value; if (value) invoke(); } }); + } else if (surface === 'open') { + const node = panel(h), remove = node.removeAttribute.bind(node); + node.removeAttribute = name => { remove(name); if (name === 'open') invoke(); }; + } else if (surface === 'status') { + const node = statusLine(h), descriptor = Object.getOwnPropertyDescriptor(SampleNode.prototype, 'textContent')!; + Object.defineProperty(node, 'textContent', { configurable: true, get() { return descriptor.get!.call(node); }, + set(value: string) { descriptor.set!.call(node, value); if (value === '') invoke(); } }); + } else { + const node = list(h), replace = node.replaceChildren.bind(node); + node.replaceChildren = (...nodes) => { replace(...nodes); if (!nodes.length) invoke(); }; + } + return () => fired; +} + +for (const kind of ['role', 'stageleft', 'transferred'] as const) { + it(`synthetic ${kind} loss blocks old-snapshot status/report/ordinary render and releases only at new current state`, async t => { + const state = lossState(kind), h = await syntheticSetup(t, state), jobs = [...h.clock.jobs.keys()]; + lose(h, kind); syntheticReset(h); assert.equal(h.sdk.state, state); + for (const status of ['waiting', 'live', 'reconnecting', 'live'] as const) { h.sdk.setStatus(status); syntheticReset(h); } + h.sdk.emit('quality', { participants: [report(90)] }); syntheticReset(h); + h.sdk.emit('state', state); syntheticReset(h); + h.sdk.emit('state', clone(state)); syntheticReset(h); + h.sdk.emit('micerror', { name: 'NotAllowedError', message: 'permission denied' }); syntheticReset(h); + assert.deepEqual([...h.clock.jobs.keys()], jobs); + assert.equal(h.sdk.polls.length, 0); assert.equal(h.sdk.raises.length, 0); assert.equal(h.sdk.previews.length, 0); + const ineligible = eligibleState({ role: 'audience', onStage: false }); + h.sdk.snapshot(ineligible); syntheticReset(h); h.sdk.emit('quality', { participants: [report(80)] }); syntheticReset(h); + h.sdk.snapshot(clone(state)); assert.equal(panel(h).hidden, false); assert.equal(panel(h).attributes.open, undefined); + assert.equal(list(h).children.length, 0); h.sdk.emit('quality', { participants: [report(15)] }); + assert.match(list(h).textContent, /RTT 15 ms/); assert.doesNotMatch(list(h).textContent, /37 ms|80 ms|90 ms/); + }); + + for (const surface of ['hidden', 'open', 'status', 'list'] as const) { + it(`synthetic ${kind} reserves suppression before ${surface} reset callback reenters old status/report`, async t => { + const state = lossState(kind), h = await syntheticSetup(t, state); + const fired = onReset(h, surface, () => { + h.sdk.setStatus('waiting'); h.sdk.emit('quality', { participants: [report(90)] }); h.sdk.emit('state', state); + }); + lose(h, kind); assert.equal(fired(), true); syntheticReset(h); + h.sdk.snapshot(clone(state)); assert.equal(panel(h).hidden, false); assert.equal(list(h).children.length, 0); + }); + + it(`synthetic ${kind} ${surface} reset cannot erase a nested new eligible state and its new report`, async t => { + const h = await syntheticSetup(t, lossState(kind)); let retained: SampleNode | undefined; + const fired = onReset(h, surface, () => { + h.sdk.snapshot(eligibleState({ participantId: 'new-viewer', role: 'controller', onStage: false })); + h.sdk.emit('quality', { participants: [report(12)] }); panel(h).setAttribute('open', ''); + retained = list(h).children[0]; + }); + lose(h, kind); assert.equal(fired(), true); assert.equal(h.sdk.me!.participantId, 'new-viewer'); + assert.equal(panel(h).hidden, false); assert.equal(panel(h).attributes.open, ''); + assert.equal(list(h).children[0], retained); assert.match(list(h).textContent, /RTT 12 ms/); + assert.equal(statusLine(h).textContent, '最近收到的伺服器回報;RTT 不是端到端音訊延遲。'); + h.sdk.emit('status', 'live'); assert.equal(list(h).children[0], retained); + }); + } +} + +it('synthetic stageleft trusts its resulting controller role rather than the retained snapshot role', async t => { + const h = await syntheticSetup(t, eligibleState({ role: 'controller' })); + lose(h, 'stageleft'); syntheticReset(h); + h.sdk.snapshot(eligibleState({ role: 'speaker' })); h.sdk.emit('quality', { participants: [report()] }); panel(h).setAttribute('open', ''); + const row = list(h).children[0], detail = { participantId: 'viewer', role: 'controller' as const }; + h.sdk.emit('stageleft', detail); assert.equal(panel(h).hidden, false); assert.equal(list(h).children[0], row); + assert.equal(panel(h).attributes.open, ''); +}); + +it('synthetic other-participant losses, incoming transfer and onstage transfer preserve the reader history', async t => { + const h = await syntheticSetup(t, eligibleState({ role: 'controller' })), row = list(h).children[0]; + for (const kind of ['role', 'stageleft', 'transferred'] as const) lose(h, kind, 'someone-else'); + h.sdk.emit('transferred', { fromId: 'someone-else', toId: 'viewer' }); lose(h, 'transferred'); + h.sdk.emit('role', { participantId: 'viewer', role: 'speaker' }); + assert.equal(panel(h).hidden, false); assert.equal(panel(h).attributes.open, ''); assert.equal(list(h).children[0], row); +}); + +for (const terminal of ['closed', 'kicked', 'leave'] as const) { + it(`synthetic ${terminal} after loss permits same-seat rejoin but never restores the old report`, async t => { + const initial = eligibleState(), h = await syntheticSetup(t, initial); lose(h, 'role'); + if (terminal === 'leave') await h.sdk.disconnect(); else await h.sdk.terminal(terminal); + syntheticReset(h); h.sdk.emit('quality', { participants: [report(99)] }); syntheticReset(h); + h.sdk.snapshot(clone(initial)); assert.equal(panel(h).hidden, false); assert.equal(panel(h).attributes.open, undefined); + assert.equal(list(h).children.length, 0); h.sdk.emit('quality', { participants: [report(11)] }); assert.match(list(h).textContent, /RTT 11 ms/); + }); +} + +it('synthetic early loss preserves ordinary row quality warnings, own statistics, hidden-page policy and timers', async t => { + const h = await syntheticSetup(t); h.clock.advance(2000); + h.sdk.polls[0]!.resolve({ inbound: { lossPercent: 8, rtt: .05, bufferMs: 3 }, outbound: null }); await flushSample(); + const connection = h.find(node => node.classList.contains('connection')), text = connection.textContent; + const badge = h.find(node => node.classList.contains('badge') && node.classList.contains('quality')), warning = badge.textContent; + const jobs = [...h.clock.jobs.keys()]; lose(h, 'role'); syntheticReset(h); + assert.equal(connection.textContent, text); assert.equal(connection.classList.contains('error'), true); + assert.equal(badge.textContent, warning); assert.equal(badge.isConnected, true); assert.deepEqual([...h.clock.jobs.keys()], jobs); + h.setHidden(true); syntheticReset(h); assert.equal(connection.textContent, ''); + h.setHidden(false); syntheticReset(h); assert.equal(h.sdk.polls.length, 2); + h.sdk.polls[1]!.resolve({ inbound: null, outbound: null }); await flushSample(); +}); + +for (const surface of ['hidden', 'open', 'status', 'list'] as const) { + it(`synthetic replacement ${surface} quality reset retires old actions before admitting a newer entry`, async t => { + const h = await syntheticSetup(t, eligibleState({ role: 'controller' })); + const oldAction = h.button('離開舞台'); let forwarded = 0, latest: SampleNode | undefined; + Object.assign(h.sdk, { async leaveStage() { forwarded++; } }); + const fired = onReset(h, surface, () => { + oldAction.click(); + const next = eligibleState({ participantId: 'latest', role: 'controller', onStage: false }); next.roomId = 'latest-room'; + h.sdk.snapshot(next); h.sdk.emit('quality', { participants: [report(13)] }); panel(h).setAttribute('open', ''); + latest = h.button('返回舞台'); + }); + const replacement = eligibleState({ role: 'controller' }); replacement.roomId = 'superseded-room'; + h.sdk.snapshot(replacement); await flushSample(); assert.equal(fired(), true); assert.equal(forwarded, 0); + assert.equal(h.sdk.state!.roomId, 'latest-room'); assert.equal(h.button('返回舞台'), latest); + assert.equal(panel(h).hidden, false); assert.equal(panel(h).attributes.open, ''); assert.match(list(h).textContent, /RTT 13 ms/); + assert.match(h.app.textContent, /latest-room/); assert.doesNotMatch(h.app.textContent, /superseded-room/); + }); +} + +for (const surface of ['row', 'list', 'status'] as const) { + it(`synthetic ${surface} report paint cannot continue over nested loss or a new eligible report`, async t => { + const h = await syntheticSetup(t); let fired = false, retained: SampleNode | undefined; + const enter = () => { + if (fired) return; fired = true; lose(h, 'role'); + h.sdk.snapshot(eligibleState({ participantId: 'new-viewer' })); h.sdk.emit('quality', { participants: [report(14)] }); + retained = list(h).children[0]; panel(h).setAttribute('open', ''); + }; + if (surface === 'list') { + const node = list(h), replace = node.replaceChildren.bind(node); + node.replaceChildren = (...nodes) => { replace(...nodes); enter(); }; + h.sdk.emit('quality', { participants: [] }); + } else { + const node = surface === 'row' ? list(h).children[0]! : statusLine(h); + const descriptor = Object.getOwnPropertyDescriptor(SampleNode.prototype, 'textContent')!; + Object.defineProperty(node, 'textContent', { configurable: true, get() { return descriptor.get!.call(node); }, + set(value: string) { descriptor.set!.call(node, value); enter(); } }); + h.sdk.emit('quality', { participants: [report(90)] }); + } + assert.equal(fired, true); assert.equal(panel(h).hidden, false); assert.equal(panel(h).attributes.open, ''); + assert.equal(list(h).children[0], retained); assert.match(list(h).textContent, /RTT 14 ms/); + assert.equal(statusLine(h).textContent, '最近收到的伺服器回報;RTT 不是端到端音訊延遲。'); + }); +} + +it('synthetic replacement quality reset cannot let an old action rejection unlock a newer pending action', async t => { + const h = await syntheticSetup(t, eligibleState({ role: 'controller' })); + h.id('audio-retry').click(); const old = h.sdk.audioRetries[0]!; let latest: SampleNode | undefined; + const fired = onReset(h, 'hidden', () => { + const next = eligibleState({ participantId: 'latest-owner', role: 'controller', onStage: false }); next.roomId = 'latest-room'; + h.sdk.snapshot(next); h.sdk.emit('quality', { participants: [report(16)] }); + latest = h.id('audio-retry'); latest.click(); old.reject(new Error('old action rejection')); + }); + const next = eligibleState({ role: 'controller' }); next.roomId = 'superseded-room'; h.sdk.snapshot(next); + assert.equal(fired(), true); await flushSample(); assert.equal(h.id('audio-retry'), latest); + assert.equal(latest!.disabled, true); assert.equal(latest!.attributes['aria-busy'], 'true'); + assert.equal(h.find(node => node.attributes.role === 'alert').hidden, true); assert.match(list(h).textContent, /RTT 16 ms/); + latest!.onclick!(); assert.equal(h.sdk.audioRetries.length, 2); + h.sdk.audioRetries[1]!.resolve(undefined); await flushSample(); assert.equal(latest!.disabled, false); +}); diff --git a/web/src/main.ts b/web/src/main.ts index 9e8f74a..fa7c104 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -164,29 +164,55 @@ const stageQualityList = element('ul', 'stage-quality-list'); stageQuality.append(stageQualitySummary, stageQualityStatus, stageQualityList); app.append(stageQuality); let stageQualityIdentity = ''; +let stageQualitySuppressedState: RoomStatePayload | undefined; +let stageQualityRevision = 0; let stageQualityReconnecting = false; let stageQualityReports = new Map(); const stageQualityRows = new Map(); let stageQualityOrder: string[] = []; +function qualityDetailsUpdate(): () => boolean { + const revision = ++stageQualityRevision, state = client.state, generation = roomActionGeneration; + return () => revision === stageQualityRevision && state === client.state && generation === roomActionGeneration; +} function clearQualityDetailsReports(): void { stageQualityReports.clear(); stageQualityRows.clear(); stageQualityOrder = []; stageQualityList.replaceChildren(); } -function resetQualityDetails(): void { +function resetQualityDetails(current = qualityDetailsUpdate()): boolean { stageQualityIdentity = ''; stageQualityReconnecting = false; - stageQuality.hidden = true; stageQuality.removeAttribute('open'); - stageQualityStatus.textContent = ''; clearQualityDetailsReports(); + stageQualityReports.clear(); stageQualityRows.clear(); stageQualityOrder = []; + stageQuality.hidden = true; + if (!current()) return false; + stageQuality.removeAttribute('open'); + if (!current()) return false; + stageQualityStatus.textContent = ''; + if (!current()) return false; + stageQualityList.replaceChildren(); + return current(); +} +function suppressQualityDetails(): void { + const state = client.state; + if (!state || stageQualitySuppressedState === state) return; + // Role/stage deltas precede their snapshot; reserve loss before any observable reset. + stageQualitySuppressedState = state; + resetQualityDetails(); } /** Reports have no timestamp or room epoch; accept them only in the current eligible view. */ function syncQualityDetails(reports?: readonly ConnectionQuality[]): void { - const state = client.state, me = state?.me; - if (!state || !me || (me.role !== 'controller' && !me.onStage)) { resetQualityDetails(); return; } + const state = client.state, me = state?.me, current = qualityDetailsUpdate(); + if (stageQualitySuppressedState || !state || !me || (me.role !== 'controller' && !me.onStage)) { + resetQualityDetails(current); return; + } const identity = JSON.stringify([state.roomId, me.participantId]); - if (identity !== stageQualityIdentity) { resetQualityDetails(); stageQualityIdentity = identity; } + if (identity !== stageQualityIdentity) { + if (!resetQualityDetails(current)) return; + stageQualityIdentity = identity; + } stageQuality.hidden = false; - const reconnecting = client.status === 'reconnecting'; - if (reconnecting && !stageQualityReconnecting) clearQualityDetailsReports(); + if (!current()) return; + const reconnecting = client.status === 'reconnecting', clearReports = reconnecting && !stageQualityReconnecting; stageQualityReconnecting = reconnecting; + if (clearReports) { clearQualityDetailsReports(); if (!current()) return; } const speakers = state.speakers.filter(person => person.onStage); const ids = new Set(speakers.map(person => person.participantId)); if (reports !== undefined && !reconnecting) { @@ -208,11 +234,13 @@ function syncQualityDetails(reports?: readonly ConnectionQuality[]): void { if (!row) { row = element('li'); row.dataset.participantId = person.participantId; stageQualityRows.set(person.participantId, row); } const text = `${decodeName(person.name)}:上行掉包 ${percent(report.uplinkLossPercent)} · 下行掉包 ${percent(report.downlinkLossPercent)} · RTT ${rtt(report.rttMs)}`; if (row.textContent !== text) row.textContent = text; + if (!current()) return; } for (const id of stageQualityRows.keys()) if (!order.includes(id)) stageQualityRows.delete(id); if (order.length !== stageQualityOrder.length || order.some((id, index) => id !== stageQualityOrder[index])) { - stageQualityList.replaceChildren(...order.map(id => stageQualityRows.get(id)!)); stageQualityOrder = order; + stageQualityList.replaceChildren(...order.map(id => stageQualityRows.get(id)!)); + if (!current()) return; } stageQualityStatus.textContent = order.length ? '最近收到的伺服器回報;RTT 不是端到端音訊延遲。' @@ -558,7 +586,7 @@ function field(form: HTMLFormElement, label: string, name: string, value = '', r function landing(): void { handRejectionRoomActive = false; retireHandRejection(); stopRoomTimers(); - resetQualityDetails(); + stageQualitySuppressedState = undefined; resetQualityDetails(); stagePermissionOwner = undefined; landingRevision += 1; ownHandPosition.hidden = true; ownHandPosition.textContent = ''; @@ -728,9 +756,11 @@ function syncAudienceFilter(state: RoomStatePayload): void { applyAudienceFilter(); } function renderRoom(state: RoomStatePayload): void { - syncQualityDetails(); const generation = resetRoomActions(JSON.stringify([state.roomId, state.me.participantId])); if (generation !== roomActionGeneration) return; + const qualityState = client.state; + syncQualityDetails(); + if (generation !== roomActionGeneration || qualityState !== client.state) return; if (!state.me.onStage) { if (micRetryPending) micNotice = ''; micRetryAvailable = micRetryPending = false; @@ -989,7 +1019,12 @@ function scheduleCooldown(remaining: number): void { cooldownTimer = timer; cooldownDue = due; } } -client.on('state', ({ detail }) => { if (landingEntry) landingEntry.entered = true; renderRoom(detail); }); +client.on('state', ({ detail }) => { + // Only a new current snapshot resolves early loss; status/report sync still sees the old seat. + if (detail === client.state && detail !== stageQualitySuppressedState) stageQualitySuppressedState = undefined; + if (landingEntry) landingEntry.entered = true; + renderRoom(detail); +}); client.on('status', ({ detail }) => { if (detail === 'reconnecting' || detail === 'disconnected') { retireHandRejection(); audioRetryNotice.hidden = true; audioRetryNotice.textContent = ''; @@ -1007,8 +1042,16 @@ client.on('status', ({ detail }) => { }); client.on('hand', ({ detail }) => { if (detail.raised && detail.participantId === client.me?.participantId) retireHandRejection(); }); client.on('stagejoined', ({ detail }) => { if (detail.participantId === client.me?.participantId) retireHandRejection(); }); -client.on('role', ({ detail }) => { if (detail.role !== 'audience' && detail.participantId === client.me?.participantId) retireHandRejection(); }); -client.on('transferred', ({ detail }) => { if (detail.toId === client.me?.participantId) retireHandRejection(); }); +client.on('role', ({ detail }) => { + if (detail.participantId !== client.me?.participantId) return; + if (detail.role === 'audience') suppressQualityDetails(); + else retireHandRejection(); +}); +client.on('transferred', ({ detail }) => { + const me = client.me; + if (detail.fromId === me?.participantId && !me.onStage) suppressQualityDetails(); + if (detail.toId === client.me?.participantId) retireHandRejection(); +}); client.on('handrejected', ({ detail }) => { // Events carry no raise identifier; copy the current-seat ownership before observable work. const generation = roomActionGeneration; @@ -1031,6 +1074,7 @@ client.on('invite', ({ detail }) => { } }); client.on('stageleft', ({ detail }) => { + if (detail.participantId === client.me?.participantId && detail.role !== 'controller') suppressQualityDetails(); if (detail.participantId === client.me?.participantId) { stagePermissionOwner = undefined; invitation = detail.reason === 'removed' ? '主控已將你移出舞台,你仍可收聽。' : ''; renderNotices(); } }); client.on('micerror', ({ detail }) => { micRetryPending = false; micRetryAvailable = Boolean(client.me?.onStage); micNotice = `${detail.message}(${detail.name})`; renderNotices(); }); From c27bd93fee931b56aa593ca5653b7b37fb9907d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=A5=E8=AA=9E=E3=81=BB=E3=81=97=E3=81=9E=E3=82=89?= <92282870+YueyuHoshizora@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:22:06 +0800 Subject: [PATCH 16/38] fix(web): retire stale speaking indicators on reconnect (batch 210) --- PLAN.md | 3 + README.md | 2 + test/webSpeakingRetirement.test.ts | 356 +++++++++++++++++++++++++++++ web/src/main.ts | 81 ++++++- 4 files changed, 430 insertions(+), 12 deletions(-) create mode 100644 test/webSpeakingRetirement.test.ts diff --git a/PLAN.md b/PLAN.md index 6a7e8c0..a69c305 100644 --- a/PLAN.md +++ b/PLAN.md @@ -865,3 +865,6 @@ N 路混音管線(decode→mix→encode)、不含自己的台上混音、lim 230. **完整範例舞台品質讀者下台即退役(第209批,2026-10-16):** immutable208 完整sample+實際SDK→validator→MockHub證明被移下、主動下台與台下主控移交三條真實role/stageleft→state次序,在晚到權威快照前仍展開顯示舊8%/2%/37ms品質,甚至移下提示已出現。限定舞台品質details的pure viewer-loss fence,own role audience/own stageleft非controller/own offstage outgoing transferred先保留失效事實再清報告/rows/text並hide/collapse;以event.role保留controller下台例外,onstage移交與他人delta不退休讀者。綁舊snapshot/seat,普通status/quality/notice/render不能借舊state重開,新的目前權威state再核資格,須新report才有數字。不套用widget完整receipt集合,不宣稱任意無epoch重放可辨識;未發現真實舊socket/Hub async report重灌,亦不改server收件政策。既有reconnect清報告但保留展開、rowqualitybadges、本人stats、timer/SDK/media皆保持;新reset/paint在原roomactiongeneration護欄後,舊continuation不得清較新合法entry,不廣修控制生命周期。四檔main/新完整模組test+README/PLAN,原assertions保留;實際三反例、controller/transfer例外、same-seat/newstate/report恢復、socket/reconnect、callback/actionownership及無額外操作列回歸。原生browser/AT/硬體音訊未驗收。 + + +231. **範例重連說話標記退役(第210批,2026-10-16):** immutable209 完整 sample+實際 SDK/validator/Hub/RoomMixer/MockMediaTransport 證明讀者控制 socket 中斷後,伺服器已無 VAD,列上「說話中」仍持續 30 秒;恢復期間修改房名造成新列重建,也會在新 speaking 回呼前從 SDK 保留集合重新點亮。只修 sample 的重連顯示窗口:清除目前列標記、阻止舊集合在恢復快照重新播回;處理當前恢復 state 後的新 speaking 回呼才恢復,空集合亦可。普通初入房/一般快照 seeding、逐差異索引更新、重複 ID 列、dirty slider 與 node identity 契約保持,不新增 timer/request/media 操作,不改 SDK 保留 media/最後集合政策。純目標狀態與 revision 先於 class setter,舊 continuation 不得覆寫較新事件/entry;保護限可觀測回呼,非任意宿主原子性。真正下台/移除/mute 已先收到 speaking prune,不廣修其政策。14 個不可變基線案例有兩個精確反例與十二正向控制;既有 speaking/roomRender 71 個通過。四檔限定 main、新測試、README、PLAN;原 assertions 保留。fresh 指恢復 state 後新 callback delivery,無 object replay 追蹤或 wire epoch/新鮮度保證;原生 browser/AT/實體音訊未驗收。 diff --git a/README.md b/README.md index 51763ce..62edb0d 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,8 @@ JSON frame,型別定義在 [`shared/protocol.ts`](shared/protocol.ts)。每個 | `rtc:answer`、`rtc:ice` | 伺服器端信令 | | `status` | `waiting`/`live` | +完整範例在控制連線重連時清除參與者列的「說話中」標記,避免把上次收到的集合持續當成目前活動;處理恢復後的房間快照,再收到新的 `speaking` 回呼後才恢復標記。普通進房與一般快照的既有顯示不變,不停止仍保留的音訊。回報沒有時間戳或 epoch,這個顯示邊界不保證資料即時或目前可聽。 + 連線品質收集每房間同時最多一輪;完成後再等待 `qualityIntervalMs` 啟動下一輪。個別 publisher 統計失敗只略過該人的本輪資料,其他人的回報照常;仍有 publisher 而全部統計失敗或缺失時不送空報告,但下一輪仍會重試。失敗不會被當作 0% 掉包,恢復後上行差量從上次成功樣本計算。最後一位停止發布後,既有已排程或執行中的輪次在完成時若仍無 publisher,會向當時的主控與台上者送一次 `quality: {participants: []}`,清除先前報告,之後不再排程;不新增清理輪詢。若已有新 publisher,不會以舊輪次的空結果清除它;離台再發布的舊統計結果仍會被忽略。關房不送此清空事件。清空依賴既有輪次執行及統計請求完成,未增加 timeout,也不保證一個 interval 內完成;停用品質回報時不另發事件。 個別統計請求的 rejection value 直接交給安全結構化 logger,不先字串化,避免不可轉字串的診斷值阻斷其他 publisher 的本輪資料與差量回報;任意自訂 logger/sink 自身拋錯仍不在此保證範圍。 `limits.joinTimeoutMs`、`rooms.controllerGraceMs`、`rooms.participantGraceMs` 的範圍為 0~2147483647 ms;`rooms.heartbeatIntervalMs` 為 1000~2147483647 ms。上限包含 2147483647(約 24.9 天);更大的值以前會被 Node timer 正規化成約 1 ms,現在啟動與離線 `config:check` 會拒絕該設定。0 的既有語意不變:join deadline 停用、participant 立即移除、controller 立即排程到期。這不限制只做經過時間比較的 `handRaiseIntervalMs`。 diff --git a/test/webSpeakingRetirement.test.ts b/test/webSpeakingRetirement.test.ts new file mode 100644 index 0000000..e90e37f --- /dev/null +++ b/test/webSpeakingRetirement.test.ts @@ -0,0 +1,356 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { it, type TestContext } from 'node:test'; +import { setImmediate as nextTurn } from 'node:timers/promises'; +import { StageClient, decodeName } from '../packages/client/src/index.ts'; +import { FakeSession, harness, SENDRECV_OFFER, testConfig, sine } from './helpers.ts'; +import { sampleApp, flushSample, roomState } from './fixtures/sampleApp.ts'; +import type { SampleNode } from './fixtures/sampleApp.ts'; +import { parseClientMessage } from '../src/ws/validate.ts'; +import type { ClientMessage, ServerMessage, RoomStatePayload } from '../shared/protocol.ts'; + +// The complete production sample, SDK, validator, Hub, RoomMixer and MockMediaTransport +// execute unchanged. This controls only DOM, clocks, WebSocket delivery and native RTC/capture. +// It does not open an actual socket, room, browser or microphone. +const source = readFileSync(process.env.STAGE_SAMPLE_SOURCE ?? new URL('../web/src/main.ts', import.meta.url), 'utf8'); +class Socket { + static OPEN = 1; static instances: Socket[] = []; + readyState = 1; sent: ClientMessage[] = []; + onmessage: ((event: { data: string }) => void) | null = null; + onclose: (() => void) | null = null; + constructor() { Socket.instances.push(this); } + send(data: string) { assert.equal(this.readyState, 1); this.sent.push(JSON.parse(data)); } + deliver(message: ServerMessage) { if (this.readyState === 1) this.onmessage?.({ data: JSON.stringify(message) }); } + close() { if (this.readyState === 3) return; this.readyState = 3; this.onclose?.(); } +} +class Peer { + static instances: Peer[] = []; + connectionState = 'connected'; signalingState = 'stable'; localDescription = null; + closes = 0; rejectOffer?: (reason: Error) => void; + constructor() { Peer.instances.push(this); } + addTransceiver() { return { direction: 'recvonly', sender: { track: null, async replaceTrack() {} } }; } + createOffer(): Promise { return new Promise((_resolve, reject) => { this.rejectOffer = reject; }); } + getStats() { return Promise.resolve(new Map()); } + close() { this.closes++; this.connectionState = 'closed'; this.rejectOffer?.(new Error('controlled cleanup')); } +} +let sequence = 0; +async function setup(t: TestContext) { + const config = testConfig(c => { c.rooms.qualityIntervalMs = 0; c.rooms.presenceBroadcastMs = 0; c.rooms.participantGraceMs = 60000; c.limits.controlPerSecond = 2000; }); + const wire = harness(config), host = await wire.create('Publishing host', { codeRequired: false }); + const speaker = await wire.join(host.roomId, undefined, 'Publishing speaker'); + const command = async (session: FakeSession, data: Record) => { + const requestId = data.requestId ?? `speaking-discovery-${sequence++}`; + await wire.hub.handle(session, parseClientMessage(JSON.stringify({ ...data, requestId }), config.limits)); + const result = session.inbox.find(m => (m.type === 'ok' || m.type === 'error') && m.requestId === requestId); + assert.equal(result?.type, 'ok', JSON.stringify(result)); + }; + await command(speaker.s, { type: 'hand:raise' }); + await command(host.s, { type: 'stage:approve', targetId: speaker.id }); + await command(host.s, { type: 'rtc:offer', payload: SENDRECV_OFFER }); + await command(speaker.s, { type: 'rtc:offer', payload: SENDRECV_OFFER }); + const saved = new Map(); + const globals = { window: new EventTarget(), WebSocket: Socket, RTCPeerConnection: Peer, + navigator: { mediaDevices: { async enumerateDevices() { return []; }, async getUserMedia(): Promise { throw new DOMException('Controlled refusal', 'NotAllowedError'); } } } }; + for (const [key, value] of Object.entries(globals)) { saved.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); Object.defineProperty(globalThis, key, { configurable: true, value }); } + Socket.instances = []; Peer.instances = []; + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); + let now = 0; t.mock.method(performance, 'now', () => now); + const sdk = new StageClient({ url: 'wss://sample.test/ws', enableMicMeter: false, reconnect: { initialDelayMs: 25, maxDelayMs: 25 }, + audioElement: { srcObject: null, volume: 1, muted: false, pause() {}, async play() {} } as unknown as HTMLAudioElement }); + const h = sampleApp({ sdk, source, decodeName }); + const sessions = new Map(), cursors = new Map(); + const tick = async (ms = 1) => { now += ms; t.mock.timers.tick(ms); h.clock.advance(ms); await flushSample(); }; + const until = async (predicate: () => boolean) => { for (let n = 0; n < 100 && !predicate(); n++) await tick(); assert.ok(predicate(), 'SDK progress'); }; + function drain(socket: Socket, beforeType?: ServerMessage['type']) { + const session = sessions.get(socket)!; const delivered: ServerMessage[] = []; + let cursor = cursors.get(socket)!; + while (cursor < session.inbox.length) { + const message = session.inbox[cursor]!; if (message.type === beforeType) break; + cursors.set(socket, ++cursor); delivered.push(message); socket.deliver(message); + } + return delivered; + } + const attach = (socket: Socket) => { const session = new FakeSession(); sessions.set(socket, session); cursors.set(socket, 0); wire.hub.attach(session); drain(socket); return session; }; + async function acceptJoin(socket: Socket, beforeType?: ServerMessage['type']) { + await until(() => socket.sent.some(m => m.type === 'join')); + const request = socket.sent.find(m => m.type === 'join')!; + await command(sessions.get(socket)!, request as unknown as Record); + const frames = drain(socket, beforeType); await flushSample(); return frames; + } + t.after(async () => { + try { await h.cleanup(); await wire.hub.shutdown(); await wire.transport.close(); await flushSample(); } + finally { for (const [key, descriptor] of saved) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); } } + }); + const joining = sdk.join({ roomId: host.roomId, name: 'Audience reader' }); await flushSample(); + const socket = Socket.instances.at(-1)!, session = attach(socket); await acceptJoin(socket); await joining; + const rows = () => h.app.descendants().filter(node => node.classList.contains('person') && node.dataset.participantId !== undefined); + const row = (id: string) => { const result = rows().find(node => node.dataset.participantId === id); assert.ok(result, `row ${id}`); return result; }; + const active = () => rows().filter(node => node.classList.contains('speaking')).map(node => node.dataset.participantId!); + const mixer = wire.mixers.get(host.roomId)!; + const speak = (ids = [speaker.id]) => { + for (const id of ids) assert.equal(wire.transport.emitUplink(host.roomId, id, sine(960, 0.2)), true); + mixer.tick(); drain(socket); + }; + const silence = () => { for (let n = 0; n < 4; n++) mixer.tick(); drain(socket); }; + const drop = async () => { socket.close(); await wire.hub.detach(session); await flushSample(); assert.equal(sdk.status, 'reconnecting'); }; + const resume = async (beforeType?: ServerMessage['type']) => { + await tick(25); const next = Socket.instances.at(-1)!; assert.notEqual(next, socket); attach(next); + const frames = await acceptJoin(next, beforeType); return { next, frames }; + }; + return { ...h, wire, config, sdk, host, speaker, socket, session, sessions, row, rows, active, speak, silence, mixer, command, drain, tick, drop, resume, attach, acceptJoin }; +} + +it('live speech and natural silence update classes through actual mixer reports without rebuilding rows', async t => { + const h = await setup(t), before = h.rows(); h.speak([h.host.id, h.speaker.id]); + assert.deepEqual(new Set(h.active()), new Set([h.host.id, h.speaker.id])); + h.silence(); assert.deepEqual(h.active(), []); assert.deepEqual(h.rows(), before); +}); + +it('disconnected publisher with continuing media stays active within grace for a connected reader', async t => { + const h = await setup(t); h.speak(); await h.wire.hub.detach(h.speaker.s); h.drain(h.socket); + assert.equal(h.sdk.state!.speakers.find(p => p.participantId === h.speaker.id)!.connected, false); + assert.equal(h.row(h.speaker.id).classList.contains('speaking'), true); + h.silence(); assert.equal(h.row(h.speaker.id).classList.contains('speaking'), false); + h.speak(); assert.equal(h.row(h.speaker.id).classList.contains('speaking'), true); +}); + +it('reader reconnect retires the mounted indication while actual server speech ends and the media peer survives', async t => { + const h = await setup(t); h.speak(); const row = h.row(h.speaker.id), peer = Peer.instances[0]!; + await h.drop(); h.silence(); + assert.deepEqual(h.host.s.last('speaking')!.participantIds, [], 'server has reported silence'); + for (let n = 0; n < 30; n++) await h.tick(1000); + assert.equal(h.sdk.status, 'reconnecting'); assert.equal(peer.closes, 0, 'retained media is intentional'); + assert.deepEqual([...h.sdk.speaking], [h.speaker.id], 'SDK getter is last received report'); + assert.equal(h.row(h.speaker.id), row, 'visible row is still installed'); + assert.equal(row.classList.contains('speaking'), false, 'active speaking indication must retire when the reader loses report delivery'); +}); + +it('normal resumed snapshot stays retired until the speaking baseline', async t => { + const h = await setup(t); h.speak(); const id = h.sdk.me!.participantId; + await h.drop(); h.silence(); const { next, frames } = await h.resume('speaking'); + assert.equal(h.sdk.me!.participantId, id); + assert.ok(frames.some(m => m.type === 'room:state')); + assert.equal(h.row(h.speaker.id).classList.contains('speaking'), false, 'resume snapshot alone must not restore the retained report'); + const tail = h.drain(next); await flushSample(); + assert.equal(tail[0]!.type, 'speaking'); assert.deepEqual(h.active(), []); +}); + +for (const mode of ['remove', 'leave', 'kick', 'mute'] as const) { + it(`actual ${mode} retires active publisher before fresh state without needing sample-specific lifecycle logic`, async t => { + const h = await setup(t); h.speak([h.host.id, h.speaker.id]); + const data = mode === 'remove' ? { type: 'stage:remove', targetId: h.speaker.id } + : mode === 'leave' ? { type: 'stage:leave' } + : mode === 'kick' ? { type: 'participant:kick', targetId: h.speaker.id } + : { type: 'mic:force-mute', targetId: h.speaker.id }; + await h.command(mode === 'leave' ? h.speaker.s : h.host.s, data); + const prefix = h.drain(h.socket, 'room:state'); + assert.ok(prefix.some(m => m.type === 'speaking')); + assert.deepEqual(h.active(), [h.host.id], 'unrelated active publisher is preserved'); + assert.equal(h.sdk.state!.speakers.some(p => p.participantId === h.speaker.id), true, 'old roster remains until state'); + await h.tick(250); assert.deepEqual(h.active(), [h.host.id]); + const tail = h.drain(h.socket); assert.ok(tail.some(m => m.type === 'room:state')); + assert.deepEqual(h.active(), [h.host.id]); + }); +} + +for (const mode of ['disconnect', 'kick', 'close'] as const) { + it(`${mode} removes active rows and later entry cannot reuse them`, async t => { + const h = await setup(t); h.speak(); const oldRows = h.rows(); + if (mode === 'disconnect') { await h.sdk.disconnect(); await h.wire.hub.detach(h.session, true); } + else { await h.command(h.host.s, mode === 'kick' ? { type: 'participant:kick', targetId: h.sdk.me!.participantId } : { type: 'room:close' }); h.drain(h.socket); } + await flushSample(); assert.equal(h.sdk.state, null); assert.deepEqual([...h.sdk.speaking], []); + assert.deepEqual(h.rows(), []); assert.ok(oldRows.every(row => !row.isConnected)); + const oldClasses = oldRows.map(row => row.className); + const newRoom = await h.wire.create('New room', { codeRequired: false }); + const join = h.sdk.join({ roomId: newRoom.roomId, name: 'New entry' }); await flushSample(); + const next = Socket.instances.at(-1)!; h.attach(next); await h.acceptJoin(next); await join; + assert.deepEqual(h.active(), []); assert.deepEqual(oldRows.map(row => row.className), oldClasses); + assert.ok(h.rows().every(row => !oldRows.includes(row))); + }); +} + +it('ordinary room rename preserves an unchanged last report and keeps active indication', async t => { + const h = await setup(t); h.speak(); await h.command(h.host.s, { type: 'room:rename', name: 'Renamed' }); + h.drain(h.socket); assert.equal(h.row(h.speaker.id).classList.contains('speaking'), true); +}); + +it('fresh post-resume speaking report displays activity again while the same peer and seat survive', async t => { + const h = await setup(t); h.speak(); const ownId = h.sdk.me!.participantId, peer = Peer.instances[0]!; + await h.drop(); const { next, frames } = await h.resume('speaking'); + assert.equal(h.sdk.me!.participantId, ownId); assert.equal(Peer.instances[0], peer); assert.equal(peer.closes, 0); + assert.ok(frames.some(m => m.type === 'room:state')); + const tail = h.drain(next); const fresh = tail.find(m => m.type === 'speaking'); + assert.ok(fresh && fresh.type === 'speaking'); assert.deepEqual(fresh.participantIds, [h.speaker.id]); + assert.equal(h.row(h.speaker.id).classList.contains('speaking'), true); +}); + +it('resumed authoritative snapshot keeps replacement rows retired before any fresh report', async t => { + const h = await setup(t); h.speak(); const oldRow = h.row(h.speaker.id); + let receipts = 0; h.sdk.on('speaking', () => receipts++); + await h.drop(); h.silence(); + await h.command(h.host.s, { type: 'room:rename', name: 'Changed while offline' }); + const { next } = await h.resume('speaking'); + const replacement = h.row(h.speaker.id); + assert.equal(h.sdk.status, 'live'); assert.equal(h.sdk.state!.name, 'Changed while offline'); + assert.notEqual(replacement, oldRow, 'real changed snapshot forces ordinary full sample rendering'); + assert.equal(oldRow.isConnected, false); assert.equal(replacement.isConnected, true); + assert.equal(receipts, 0, 'no new SDK speaking receipt since reconnect'); + assert.deepEqual([...h.sdk.speaking], [h.speaker.id], 'snapshot did not refresh the public getter'); + const beforeFreshReport = replacement.classList.contains('speaking'); + h.drain(next); await flushSample(); assert.equal(receipts, 1); assert.equal(replacement.classList.contains('speaking'), false, 'new empty receipt repairs the indication'); + assert.equal(beforeFreshReport, false, 'resumed snapshot must not reactivate retired activity from a retained getter'); +}); + +// Controlled SDK callbacks isolate lifecycle and reentrant DOM boundaries; the cases above +// independently exercise the real SDK and server reports. Neither fixture models a native browser. +function syntheticState(): RoomStatePayload { + const state = roomState({ participantId: 'host', role: 'controller', onStage: true }); + const a = { ...state.me, participantId: 'a', role: 'speaker' as const }; + const b = { ...a, participantId: 'b' }; + const c = { ...a, participantId: 'c', role: 'audience' as const, onStage: false, handRaised: true }; + return { ...state, speakers: [state.me, a, b], hands: [c], audience: [c], audienceCount: 1 }; +} +async function synthetic(t: TestContext, active = ['a', 'b', 'c']) { + const h = sampleApp({ source }); t.after(() => h.cleanup()); await flushSample(); + const state = syntheticState(); h.sdk.speaking = new Set(active); h.sdk.snapshot(state); + const rows = () => h.app.descendants().filter(row => row.classList.contains('person') && row.dataset.participantId !== undefined); + const check = (ids: string[]) => { + for (const row of rows()) assert.equal(row.classList.contains('speaking'), ids.includes(row.dataset.participantId!), row.dataset.participantId); + }; + const emit = (ids: string[]) => { h.sdk.speaking = new Set(ids); h.sdk.emit('speaking', { participantIds: ids }); }; + return { ...h, state, rows, check, emit }; +} + +it('reconnect clears both duplicate rows in place, adds no SDK work and ignores reports until a current resumed state', async t => { + const h = await synthetic(t), rows = h.rows(), jobs = [...h.clock.jobs.values()].map(job => [job.kind, job.delay]); + const nodes = h.app.descendants(); + h.sdk.setStatus('reconnecting'); h.check([]); + assert.deepEqual(h.rows(), rows); assert.deepEqual(h.app.descendants(), nodes); + assert.deepEqual([...h.clock.jobs.values()].map(job => [job.kind, job.delay]), jobs); + assert.equal(h.sdk.audioRetries.length, 0); assert.equal(h.sdk.previews.length, 0); assert.equal(h.sdk.joins.length, 0); + h.emit(['a']); h.check([]); + h.sdk.setStatus('live'); h.emit(['b']); h.check([]); + h.sdk.emit('state', h.state); h.emit(['c']); h.check([]); + h.sdk.snapshot({ ...h.state }); h.check([]); h.emit(['c']); h.check(['c']); +}); + +it('a snapshot processed during reconnect cannot be admitted later by status alone', async t => { + const h = await synthetic(t); h.sdk.setStatus('reconnecting'); + const during = { ...h.state, name: 'During loss' }; + h.sdk.state = during; h.sdk.emit('state', during); h.check([]); + h.sdk.setStatus('live'); h.emit(['a']); h.check([]); + h.sdk.snapshot({ ...during }); h.emit(['b']); h.check(['b']); +}); + +it('resume status-before-state and fresh empty reports preserve ordinary snapshot seeding afterward', async t => { + const h = await synthetic(t); h.sdk.setStatus('reconnecting'); + const resumed = { ...h.state, name: 'Resumed' }; + h.sdk.state = resumed; h.sdk.setStatus('live'); h.emit(['a']); h.check([]); + h.sdk.emit('state', resumed); h.check([]); + h.emit([]); h.check([]); + h.sdk.speaking = new Set(['b', 'c']); h.sdk.snapshot({ ...resumed, name: 'Ordinary update' }); h.check(['b', 'c']); + h.sdk.snapshot(h.state); h.emit(['a']); h.check(['a']); + h.sdk.setStatus('reconnecting'); h.check([]); h.sdk.emit('status', 'reconnecting'); h.check([]); + h.sdk.snapshot({ ...resumed, name: 'Second resume' }); h.check([]); h.emit(['a']); h.check(['a']); +}); + +it('landing retires speaking ownership and same-identity reentry retains ordinary seeding', async t => { + const h = await synthetic(t); h.sdk.setStatus('reconnecting'); + await h.sdk.disconnect(); const retired = h.rows(); assert.deepEqual(retired, []); + h.emit(['a']); h.sdk.emit('status', 'live'); assert.deepEqual(h.rows(), []); + h.sdk.speaking = new Set(['b']); h.sdk.snapshot(h.state); h.check(['b']); + h.sdk.setStatus('reconnecting'); h.check([]); + h.sdk.speaking = new Set(['a']); h.sdk.snapshot({ ...h.state, roomId: 'new-room' }); h.check(['a']); +}); + +for (const timing of ['before', 'after'] as const) { + it(`nested ${timing}-write speaking receipt finishes interrupted duplicate-row and multi-ID changes`, async t => { + const h = await synthetic(t), rows = h.rows(); let fired = false; + const row = rows.find(row => row.dataset.participantId === 'a')!, original = row.classList.toggle; + row.classList.toggle = (name, force) => { + if (name === 'speaking' && !fired) { + fired = true; + if (timing === 'before') h.emit(['a', 'c']); + const result = original(name, force); + if (timing === 'after') h.emit(['a', 'c']); + return result; + } + return original(name, force); + }; + h.emit([]); assert.equal(fired, true); h.check(['a', 'c']); assert.deepEqual(h.rows(), rows); + h.emit(['a', 'c']); h.check(['a', 'c']); h.emit([]); h.check([]); + }); +} + +it('two finite nested before-write receipts leave the newest projection and preserve unrelated classes', async t => { + const h = await synthetic(t); let receipts = 0; + for (const row of h.rows()) { + row.className += ' preserved'; const original = row.classList.toggle; + row.classList.toggle = (name, force) => { + if (name === 'speaking' && receipts < 2) { + receipts++; h.emit(receipts === 1 ? ['a', 'c'] : ['b']); + } + return original(name, force); + }; + } + h.emit([]); assert.equal(receipts, 2); h.check(['b']); + assert.ok(h.rows().every(row => row.classList.contains('preserved'))); +}); + +for (const target of ['state', 'landing', 'new-entry'] as const) { + it(`a nested ${target} replaces speaking-row ownership before the old diff can continue`, async t => { + const h = await synthetic(t), oldRows = h.rows(); + const calls: SampleNode[] = []; let fired = false, retiredClasses: string[] = []; + for (const row of oldRows) { + const original = row.classList.toggle; + row.classList.toggle = (name, force) => { + calls.push(row); const result = original(name, force); + if (name === 'speaking' && !fired) { + fired = true; + if (target === 'state') { h.sdk.speaking = new Set(['c']); h.sdk.snapshot({ ...h.state, name: 'Replacement' }); } + else { + void h.sdk.disconnect(); + if (target === 'new-entry') { h.sdk.speaking = new Set(['b']); h.sdk.snapshot({ ...h.state, roomId: 'Replacement room' }); } + } + retiredClasses = oldRows.map(row => row.className); + } + return result; + }; + } + h.emit([]); assert.equal(fired, true); assert.equal(calls.length, 1); + assert.ok(oldRows.every(row => !row.isConnected)); assert.deepEqual(oldRows.map(row => row.className), retiredClasses); + if (target === 'state') h.check(['c']); + else if (target === 'new-entry') h.check(['b']); + else assert.deepEqual(h.rows(), []); + }); +} + +for (const status of ['reconnecting', 'disconnected'] as const) { + it(`a new identity snapshot while ${status} cannot restore retained speaking before live entry`, async t => { + const h = await synthetic(t); h.sdk.setStatus(status); h.check([]); + const next = { ...h.state, roomId: 'new identity' }; + h.sdk.state = next; h.sdk.emit('state', next); h.check([]); + h.sdk.setStatus('live'); h.emit(['c']); h.check([]); + h.sdk.speaking = new Set(['b']); h.sdk.emit('state', next); h.check([]); + h.emit(['a']); h.check(['a']); + }); +} + +it('a fresh receipt during ordinary row construction updates current unmounted rows before they mount', async t => { + let h: ReturnType | undefined, armed = false, fired = false; + const app = sampleApp({ source, decodeName(value) { + if (armed && value === 'Trigger second speaker' && !fired) { + fired = true; h!.sdk.speaking = new Set(['b']); h!.sdk.emit('speaking', { participantIds: ['b'] }); + } + return value; + } }); + h = app; t.after(() => app.cleanup()); await flushSample(); + const state = syntheticState(); state.speakers[2]!.name = 'Trigger second speaker'; + h.sdk.speaking = new Set(['a']); h.sdk.snapshot(state); armed = true; + h.sdk.snapshot({ ...state, name: 'Rebuild' }); assert.equal(fired, true); + const check = () => { + const rows = app.app.descendants().filter(row => row.classList.contains('person')); + for (const row of rows) assert.equal(row.classList.contains('speaking'), row.dataset.participantId === 'b'); + }; + check(); h.sdk.emit('speaking', { participantIds: ['b'] }); check(); +}); diff --git a/web/src/main.ts b/web/src/main.ts index fa7c104..3e4acdf 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -584,6 +584,8 @@ function field(form: HTMLFormElement, label: string, name: string, value = '', r return input; } function landing(): void { + // Drop ownership before any reset can synchronously deliver another speaking callback. + resetSpeaking(); handRejectionRoomActive = false; retireHandRejection(); stopRoomTimers(); stageQualitySuppressedState = undefined; resetQualityDetails(); @@ -593,7 +595,6 @@ function landing(): void { resetAudienceFilter(); renderedRoomSignature = renderedSpeakingSignature = renderedQualitySignature = undefined; renderedGainInputs.length = 0; renderedAudienceHeading = undefined; - personRows.clear(); speakingIds.clear(); resetMicSettings(); resetRoomActions(''); cooldownButton = null; renderedReturnStage = null; renderedRoomActions = []; scheduleCooldown(0); @@ -755,6 +756,61 @@ function syncAudienceFilter(state: RoomStatePayload): void { audienceFilter.hidden = false; audienceSearch.disabled = false; applyAudienceFilter(); } +let speakingDirtyIds = new Set(); +let speakingRowsRevision = 0; +let speakingPaintRevision: number | undefined; +let speakingRevision = 0; +let speakingIdentity = ''; +let speakingState: RoomStatePayload | undefined; +let speakingRetired = false; +let speakingReconnectState: RoomStatePayload | null = null; +function resetSpeaking(): void { + speakingRevision++; speakingIdentity = ''; speakingState = undefined; + speakingRetired = false; speakingReconnectState = null; + speakingRowsRevision++; personRows.clear(); speakingIds = new Set(); speakingDirtyIds = new Set(); +} +/** The SDK retains its last report across socket loss; that is not a current activity indication. */ +function retireSpeaking(): void { + speakingRevision++; + speakingRetired = true; speakingReconnectState = client.state; speakingState = undefined; + updateSpeaking(new Set()); +} +function acceptSpeakingState(state: RoomStatePayload): void { + if (state !== client.state) return; + const identity = JSON.stringify([state.roomId, state.me.participantId]); + if (client.status === 'reconnecting' || client.status === 'disconnected') { + speakingIdentity = identity; speakingRetired = true; speakingState = undefined; return; + } + if (identity !== speakingIdentity) { + speakingIdentity = identity; speakingRetired = false; speakingReconnectState = null; + } + // Status is emitted before state on resume. Neither status alone nor a pre-loss replay admits reports. + speakingState = state !== speakingReconnectState ? state : undefined; +} +function updateSpeaking(ids: Set): void { + const previous = speakingIds, revision = speakingRowsRevision, dirty = speakingDirtyIds; + speakingIds = ids; renderedSpeakingSignature = JSON.stringify([...ids]); + for (const id of previous) if (!ids.has(id)) dirty.add(id); + for (const id of ids) if (!previous.has(id)) dirty.add(id); + if (speakingPaintRevision === revision) return; + speakingPaintRevision = revision; + try { + // Nested receipts reserve their desired set first. Drain only dirty IDs until finite callbacks settle. + while (dirty.size && speakingRowsRevision === revision) { + const id = dirty.values().next().value!; dirty.delete(id); + for (const row of personRows.get(id) ?? []) { + if (speakingRowsRevision !== revision) return; + const active = speakingIds.has(id); + if (row.classList.contains('speaking') !== active) row.classList.toggle('speaking', active); + if (speakingRowsRevision !== revision) return; + // A before-write callback can return into an older toggle after reserving a newer report. + if (row.classList.contains('speaking') !== speakingIds.has(id)) dirty.add(id); + } + } + } finally { + if (speakingPaintRevision === revision) speakingPaintRevision = undefined; + } +} function renderRoom(state: RoomStatePayload): void { const generation = resetRoomActions(JSON.stringify([state.roomId, state.me.participantId])); if (generation !== roomActionGeneration) return; @@ -770,7 +826,8 @@ function renderRoom(state: RoomStatePayload): void { const { audienceCount, ...roomStructure } = state; const audienceTitle = `觀眾 · ${audienceCount}/${state.limits.maxAudience}`; const signature = JSON.stringify([roomStructure, roomCode, client.status]); - const speakingSignature = JSON.stringify([...client.speaking]); + const currentSpeaking = new Set(speakingRetired ? speakingIds : client.speaking); + const speakingSignature = JSON.stringify([...currentSpeaking]); const qualitySignature = JSON.stringify([...quality]); if (signature === renderedRoomSignature && speakingSignature === renderedSpeakingSignature && qualitySignature === renderedQualitySignature && !renderedGainInputs.some(({ input, value }) => input.value !== value)) { @@ -779,7 +836,7 @@ function renderRoom(state: RoomStatePayload): void { syncAudienceFilter(state); updateCooldown(); renderNotices(true); return; } renderedGainInputs.length = 0; audienceFilterRows.length = 0; - personRows.clear(); speakingIds = new Set(client.speaking); + speakingRevision++; speakingRowsRevision++; personRows.clear(); speakingDirtyIds = new Set(); speakingIds = currentSpeaking; brand.textContent = decodeName(state.name); status.textContent = statusLabels[client.status]; cooldownButton = null; @@ -921,7 +978,7 @@ function personRow(person: ParticipantView, controller: boolean, kind: 'speaker' if (kind === 'audience') audienceFilterRows.push({ row, name: decodeName(person.name).normalize('NFC').toLowerCase() }); const rows = personRows.get(person.participantId); if (rows) rows.push(row); else personRows.set(person.participantId, [row]); - row.classList.toggle('speaking', client.speaking.has(person.participantId)); + row.classList.toggle('speaking', speakingIds.has(person.participantId)); const identity = element('div', 'identity'); identity.append(element('strong', '', decodeName(person.name)), element('span', 'badge', roleLabels[person.role])); if (!person.connected) identity.append(element('span', 'badge', '連線中斷')); @@ -1020,12 +1077,15 @@ function scheduleCooldown(remaining: number): void { } } client.on('state', ({ detail }) => { + acceptSpeakingState(detail); // Only a new current snapshot resolves early loss; status/report sync still sees the old seat. if (detail === client.state && detail !== stageQualitySuppressedState) stageQualitySuppressedState = undefined; if (landingEntry) landingEntry.entered = true; renderRoom(detail); }); client.on('status', ({ detail }) => { + if (detail === 'disconnected' && !client.state) resetSpeaking(); + else if (detail === 'reconnecting' || detail === 'disconnected') retireSpeaking(); if (detail === 'reconnecting' || detail === 'disconnected') { retireHandRejection(); audioRetryNotice.hidden = true; audioRetryNotice.textContent = ''; } @@ -1082,15 +1142,12 @@ client.on('audioblocked', () => { audioBlocked = true; renderNotices(); }); client.on('error', ({ detail }) => showError(detail.message)); // Toggle in place: a full re-render every few hundred ms would reset focus and scroll. client.on('speaking', ({ detail }) => { + const revision = ++speakingRevision; const ids = new Set(detail.participantIds); - for (const id of speakingIds) { - if (!ids.has(id)) for (const row of personRows.get(id) ?? []) row.classList.toggle('speaking', false); - } - for (const id of ids) { - if (!speakingIds.has(id)) for (const row of personRows.get(id) ?? []) row.classList.toggle('speaking', true); - } - speakingIds = ids; - renderedSpeakingSignature = JSON.stringify([...ids]); + if (revision !== speakingRevision || !speakingIdentity || speakingState !== client.state + || client.status === 'reconnecting' || client.status === 'disconnected') return; + speakingRetired = false; speakingReconnectState = null; + updateSpeaking(ids); }); client.on('quality', ({ detail }) => { syncQualityDetails(detail.participants); From 1cc0036add4665cfa73f0390bd95a93b07ebfefe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=A5=E8=AA=9E=E3=81=BB=E3=81=97=E3=81=9E=E3=82=89?= <92282870+YueyuHoshizora@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:38:40 +0800 Subject: [PATCH 17/38] feat(embed): add explicit offstage microphone level test (batch 211) --- PLAN.md | 3 + README.md | 2 + packages/client/README.md | 5 + test/fixtures/embedApp.ts | 9 +- test/fixtures/embedSdkApp.ts | 9 +- test/webEmbedEntry.test.ts | 7 +- test/webEmbedInputDevice.test.ts | 2 +- test/webEmbedMicTest.test.ts | 358 +++++++++++++++++++++++++++++++ test/webEmbedMicrophone.test.ts | 7 +- test/webEmbedWsPath.test.ts | 7 +- web/embed.html | 1 + web/src/embed.ts | 223 +++++++++++++++++-- 12 files changed, 608 insertions(+), 25 deletions(-) create mode 100644 test/webEmbedMicTest.test.ts diff --git a/PLAN.md b/PLAN.md index a69c305..e56f7f2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -868,3 +868,6 @@ N 路混音管線(decode→mix→encode)、不含自己的台上混音、lim 231. **範例重連說話標記退役(第210批,2026-10-16):** immutable209 完整 sample+實際 SDK/validator/Hub/RoomMixer/MockMediaTransport 證明讀者控制 socket 中斷後,伺服器已無 VAD,列上「說話中」仍持續 30 秒;恢復期間修改房名造成新列重建,也會在新 speaking 回呼前從 SDK 保留集合重新點亮。只修 sample 的重連顯示窗口:清除目前列標記、阻止舊集合在恢復快照重新播回;處理當前恢復 state 後的新 speaking 回呼才恢復,空集合亦可。普通初入房/一般快照 seeding、逐差異索引更新、重複 ID 列、dirty slider 與 node identity 契約保持,不新增 timer/request/media 操作,不改 SDK 保留 media/最後集合政策。純目標狀態與 revision 先於 class setter,舊 continuation 不得覆寫較新事件/entry;保護限可觀測回呼,非任意宿主原子性。真正下台/移除/mute 已先收到 speaking prune,不廣修其政策。14 個不可變基線案例有兩個精確反例與十二正向控制;既有 speaking/roomRender 71 個通過。四檔限定 main、新測試、README、PLAN;原 assertions 保留。fresh 指恢復 state 後新 callback delivery,無 object replay 追蹤或 wire epoch/新鮮度保證;原生 browser/AT/實體音訊未驗收。 + + +232. **最小 embed 台下麥克風音量測試(第211批,2026-10-16):** immutable210 已有公開 startMicTest({signal})/MicTest level、stop、stopped 能力,完整 embed 缺 UI;十二個實際 SDK+fake media 探查與296既有 focused tests 通過。新增僅當前已進房且離台的明確測試/取消/停止按鈕與具名 meter,不回放、不發布舞台、不自動擷取或重啟。捕捉 entry/client/binding/room/participant 與 pending token,先純退役再 abort/stop/DOM,取消後保留 lane 至 promise settle,晚到 grant/handle 釋放;共享 micready 不是完成訊號。上台的早期本人通知先 fence 舊 snapshot,重連/離房/替換/terminal/pagehide/hidden 都退役,只停止自己擁有的測試,不影響正常台上麥克風。活躍且可見時約100ms讀一次 level,非有限值顯示0、有限值夾限0~1;自然停止清排程,getter/setter/cleanup重入不能借舊 owner 開新擷取或清新結果。input apply 先reserve再退役測試與重驗,不跟著選擇自動測試;未套用 draft 不代表實際輸入。缺舊SDK方法在明確點擊後固定local failure,不fallback擷取。僅embed源碼/HTML、新測試、必需fixtures與README/clientREADME/PLAN;既有無startMicTest靜態禁止因明確新功能窄調,其他assertions保留,無SDK/server/media/protocol/依賴變更。原生權限視窗/鍵盤/焦點/AT/實體音訊未驗收,取消不宣稱關閉native prompt。 diff --git a/README.md b/README.md index 62edb0d..01ecea4 100644 --- a/README.md +++ b/README.md @@ -331,6 +331,8 @@ SDK 的應用層 keepalive 會與控制請求共用送出間隔,避免合法 最小 embed 的舞台列也會比較目前 DOM 值,省去相同 gain 界限、停用/等待屬性及移交控制的重複寫入;原有兩次移交資格核對與每次同步保留,不快取快照或跳過事件。受控完整 embed 經公開 SDK/validator/Hub 的 24 筆改名、房名、同值增益與舉手/收回操作,1/3/8 講者的等值列寫入由 264/792/2,112 降為 0;草稿 value 與原節點/handler 身分均維持。權威增益、講者名稱、靜音或等待改變仍做必要更新,宿主改過的值也會修正;面板與列退役寫入不在零寫入範圍。這是受控 DOM 工作量,尚未量測原生瀏覽器延遲、CPU、layout、焦點或 AT 改善。 +最小 embed 另提供台下的「測試麥克風音量」:只有已進房且目前離台時,明確按鈕才呼叫既有 `startMicTest({ signal })`,可能要求瀏覽器麥克風權限。它只顯示本機音量,不回放、不送上舞台,也不保證他人能聽見。等待時可取消,取得音軌後可停止;取消不會關閉原生權限視窗,但稍後授權取得的測試音軌會由 SDK 釋放。測試中約每 100 ms 讀取一次音量,停止、自然結束、上台、重連、離房/換席、送出麥克風選擇、頁面隱藏或離開都清理測試與讀取排程,不停止正常舞台麥克風。返回前景或選擇裝置後不自動重啟,必須再按測試。裝置草稿並未套用時,測試仍沿用 SDK 目前輸入設定。失敗只在測試區顯示固定提示,可手動重試;舊 SDK 缺少方法時同樣安全失敗,不改用其他擷取方式。原生按鈕與標籤支援標準操作語意,但受控 DOM/SDK 測試不等同實際權限視窗、鍵盤/焦點、AT 或硬體音訊驗收。 + 最小 embed 已進房時可手動重新整理揚聲器/耳機清單,再明確送出輸出裝置選擇;選單本身只是草稿,空值表示送出系統預設,完成不代表已播放或能聽見聲音。清單名稱安全顯示,缺失草稿保留但不能送出,不自動枚舉、播放、擷取或儲存裝置。舊 SDK 缺少所需 API 或瀏覽器不支援時顯示系統音訊設定說明。頁面所有替代 client 共用同一 audio 元件,因此草稿/清單及單一刷新/送出等待鎖跨離房、新加入與 client 替換保留;離房不會取消已送交瀏覽器的 setSinkId,也不會重設實際輸出。新席位需等待舊操作結束才可再次操作,舊席位結果不回填新錯誤或清單;永不完成的操作仍等待,沒有 timeout 或取消保證。原生權限、輸出硬體、鍵盤與輔助科技尚未驗收,受控測試不代表可聽音訊。 主機使用自訂 `server.wsPath` 時,可填「WebSocket 路徑」或以 `?wsPath=%2Fcontrol` 預填,缺省/空白仍用 `/ws`;模組仍從同一主機的 `/lib/stage-client.js` 載入。路徑必須以單一 `/` 開頭,不接受跨主機 authority、scheme、反斜線、query/fragment、控制字元、內部空白或一般/`%2e` 編碼的 dot segment。安全的百分比編碼保留為 pathname,不會再解碼成另一個主機;query 本身先依 URLSearchParams 解碼一次,例如預填字面 `%2F` 路徑片段要寫 `%252F`。Unicode 路徑依 URL 規則序列化,因此伺服器 `wsPath` 須與序列化後 pathname 相符,不保證任意輸入 byte 原樣保留。每次送出在任何等待前,一起擷取當時的主機、路徑、房間 ID、代碼與名稱;等待舊連線清理、模組載入或 hello 期間改欄位,只影響下一次明確送出,不把新房間資料混入舊主機的加入請求。房間 ID/代碼/名稱沿用原 trim,空白代碼仍省略;驗證失敗不載入模組或建立連線。此頁會執行所選主機提供的 JavaScript,請只使用可信任主機;送出快照避免操作時序混用,不是對惡意模組的隔離。 diff --git a/packages/client/README.md b/packages/client/README.md index 8e013c6..7f4a8be 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -268,3 +268,8 @@ WebSocket 建構失敗、hello 逾時或尚未進房的連線關閉,也會釋 `npm test -w packages/client` 也會啟動獨立 Node 子程序的 loopback 伺服器,讓實際 StageClient 經真實 WebSocket 完成建立/加入房間、舉手核准、個人/主控靜音、下台、同席位與同媒體 peer 自動恢復、被踢與伺服器關閉。同一流程會分別使用預設 unlimited 與 maxPendingControlRequests=1,並檢查額外控制操作的本機拒絕、名額釋放與信令仍可完成;cap=1 的 child fixture 刻意延後初次 join ACK,確認名額仍占用時 SDP 已完成,再釋放 ACK。測試檢查個人化快照、ACK 錯誤、收聽偏好保留,以及終止後不重連;伺服器使用 MockMediaTransport,瀏覽器 PeerConnection/音軌/AudioContext 為 fake,不代表實際 ICE、音訊品質或瀏覽器權限驗證。需允許本機 loopback listener 與 Node 子程序;不使用外部服務。 `micNoopIntegration.test.ts` 另外以主控、發言者、觀眾三個實際 SDK 經 native WebSocket,檢查五種重複麥克風控制只刷新發起者的個人化 state,順序仍為 state → ACK → 公開 Promise 完成;底層 self/force mute 真正改變時仍廣播。觀察者的零額外快照以各自連線的 ping/pong 排序屏障確認,不以短暫 sleep 當作沒有訊息的證據。測試也涵蓋私人欄位、gain 正規化、既有 peer/mixer 身分、權限拒絕後恢復,以及延後 ACK 的逾時、名額釋放和舊 ACK 隔離;媒體仍為 fake/MockMediaTransport,不是實際音訊或效能測量。 + + +### 最小 embed 的台下麥克風音量測試 + +最小 embed 另提供台下的「測試麥克風音量」:只有已進房且目前離台時,明確按鈕才呼叫既有 `startMicTest({ signal })`,可能要求瀏覽器麥克風權限。它只顯示本機音量,不回放、不送上舞台,也不保證他人能聽見。等待時可取消,取得音軌後可停止;取消不會關閉原生權限視窗,但稍後授權取得的測試音軌會由 SDK 釋放。測試中約每 100 ms 讀取一次音量,停止、自然結束、上台、重連、離房/換席、送出麥克風選擇、頁面隱藏或離開都清理測試與讀取排程,不停止正常舞台麥克風。返回前景或選擇裝置後不自動重啟,必須再按測試。裝置草稿並未套用時,測試仍沿用 SDK 目前輸入設定。失敗只在測試區顯示固定提示,可手動重試;舊 SDK 缺少方法時同樣安全失敗,不改用其他擷取方式。原生按鈕與標籤支援標準操作語意,但受控 DOM/SDK 測試不等同實際權限視窗、鍵盤/焦點、AT 或硬體音訊驗收。 diff --git a/test/fixtures/embedApp.ts b/test/fixtures/embedApp.ts index 7d4e12e..7fd3603 100644 --- a/test/fixtures/embedApp.ts +++ b/test/fixtures/embedApp.ts @@ -161,9 +161,10 @@ export class EmbedSdk { export const flush = async (): Promise => { for (let i = 0; i < 16; i++) await Promise.resolve(); }; export function embedApp(options: { libraryLoad?: Promise | ((url: string, attempt: number) => Promise); source?: string; search?: string; maxGainDb?: unknown; confirm?: ((message: string) => boolean) | null } = {}) { - const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'speaking-summary', 'connection-stats-panel', 'connection-stats-update', 'connection-stats-text', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); + const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'speaking-summary', 'connection-stats-panel', 'connection-stats-update', 'connection-stats-text', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'mic-test-panel', 'mic-test-start', 'mic-test-cancel', 'mic-test-stop', 'mic-test-meter', 'mic-test-status', 'mic-test-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); const node = (id: string): EmbedNode => { const found = nodes.get(id); assert.ok(found, `unmodeled node: ${id}`); return found; }; node('connection-stats-panel').append(node('connection-stats-update'), node('connection-stats-text')); + node('mic-test-panel').append(...['mic-test-start', 'mic-test-cancel', 'mic-test-stop', 'mic-test-meter', 'mic-test-status', 'mic-test-notice'].map(node)); node('error-notice').hidden = true; node('hand-rejected').hidden = true; node('room-occupancy').hidden = true; node('own-connection-quality').hidden = true; node('speaking-summary').hidden = true; const cancel = node('cancel-join'); cancel.hidden = cancel.disabled = true; cancel.textContent = '取消連線'; const hand = node('hand'), mic = node('mic'), stage = node('stage'), micRetry = node('mic-retry'), leave = node('leave'), form = node('join'), status = node('status'); @@ -231,9 +232,11 @@ export function embedApp(options: { libraryLoad?: Promise | ((url: string, const executable = stripTypeScriptTypes(source.replace("import './styles.css';", '').replace(dynamicImport, 'loadLibrary(moduleUrl)')); assert.doesNotMatch(executable, /\bimport\s*(?:\(|['"])/, 'the fixture must never import a real external library'); const confirmations: string[] = []; + const pageDocument = Object.assign(new EventTarget(), { hidden: false, createElement(tag: string) { assert.ok(['option', 'li', 'span', 'div', 'button', 'label', 'input'].includes(tag), `unmodeled element: ${tag}`); return new EmbedNode(tag); }, querySelector(selector: string) { assert.match(selector, /^#[a-z-]+$/); return node(selector.slice(1)); } }); + const pageWindow = new EventTarget(); runInNewContext(executable, { ...(options.confirm === null ? {} : { confirm: (message: string) => { confirmations.push(message); return options.confirm?.(message) ?? false; } }), - document: { createElement(tag: string) { assert.ok(['option', 'li', 'span', 'div', 'button', 'label', 'input'].includes(tag), `unmodeled element: ${tag}`); return new EmbedNode(tag); }, querySelector(selector: string) { assert.match(selector, /^#[a-z-]+$/); return node(selector.slice(1)); } }, + document: pageDocument, window: pageWindow, AbortController, location: { search: options.search ?? '?host=https%3A%2F%2Fstage.example.test%2Fprefix&room=%20same-room%20&code=%20TEST42%20', origin: 'https://embed.example.test' }, URL, URLSearchParams, Error, Promise, performance: { now: () => now }, loadLibrary: async (url: string) => { @@ -248,7 +251,7 @@ export function embedApp(options: { libraryLoad?: Promise | ((url: string, clearTimeout: (id: number) => clear('timeout', id), }, { filename: 'web/src/embed.ts', timeout: 1000 }); return { - hand, mic, stage, micRetry, leave, form, submit, status, clients, moduleUrls, node, tick, clock, confirmations, + hand, mic, stage, micRetry, leave, form, submit, status, clients, moduleUrls, node, tick, clock, confirmations, pageDocument, pageWindow, async join(overrides: Partial = {}): Promise { const count = clients.length; form.submit(); assert.equal(submit.disabled, true); await flush(); assert.equal(clients.length, count + 1); const sdk = clients.at(-1)!; diff --git a/test/fixtures/embedSdkApp.ts b/test/fixtures/embedSdkApp.ts index a587d73..e2c9d0c 100644 --- a/test/fixtures/embedSdkApp.ts +++ b/test/fixtures/embedSdkApp.ts @@ -107,9 +107,10 @@ export async function actualApp(t: TestContext, options: { deferCapture?: boolea const originals = new Map(Object.keys(globals).map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value }); t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); let now = 1000; t.mock.method(performance, 'now', () => now); - const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'speaking-summary', 'connection-stats-panel', 'connection-stats-update', 'connection-stats-text', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); + const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'speaking-summary', 'connection-stats-panel', 'connection-stats-update', 'connection-stats-text', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'mic-test-panel', 'mic-test-start', 'mic-test-cancel', 'mic-test-stop', 'mic-test-meter', 'mic-test-status', 'mic-test-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); const node = (id: string): EmbedNode => { const found = nodes.get(id); assert.ok(found, id); return found; }; node('connection-stats-panel').append(node('connection-stats-update'), node('connection-stats-text')); + node('mic-test-panel').append(...['mic-test-start', 'mic-test-cancel', 'mic-test-stop', 'mic-test-meter', 'mic-test-status', 'mic-test-notice'].map(node)); node('error-notice').hidden = true; node('hand-rejected').hidden = true; node('room-occupancy').hidden = true; node('own-connection-quality').hidden = true; node('speaking-summary').hidden = true; const cancel = node('cancel-join'); cancel.hidden = cancel.disabled = true; cancel.textContent = '取消連線'; const form = node('join'), stage = node('stage'), status = node('status'), mic = node('mic'), micRetry = node('mic-retry'), leave = node('leave'); @@ -134,9 +135,11 @@ export async function actualApp(t: TestContext, options: { deferCapture?: boolea const executable = stripTypeScriptTypes(source.replace("import './styles.css';", '').replace(dynamicImport, 'loadLibrary(moduleUrl)')); assert.doesNotMatch(executable, /\bimport\s*(?:\(|['"])/, 'no real external host import'); const confirmations: string[] = []; + const pageDocument = Object.assign(new EventTarget(), { hidden: false, createElement(tag: string) { assert.ok(['option', 'li', 'span', 'div', 'button', 'label', 'input'].includes(tag), `unmodeled element: ${tag}`); return new EmbedNode(tag); }, querySelector(selector: string) { assert.match(selector, /^#[a-z-]+$/); return node(selector.slice(1)); } }); + const pageWindow = globals.window; runInNewContext(executable, { ...(options.confirm === null ? {} : { confirm: (message: string) => { confirmations.push(message); return options.confirm?.(message) ?? false; } }), - document: { createElement(tag: string) { assert.ok(['option', 'li', 'span', 'div', 'button', 'label', 'input'].includes(tag), `unmodeled element: ${tag}`); return new EmbedNode(tag); }, querySelector(selector: string) { assert.match(selector, /^#[a-z-]+$/); return node(selector.slice(1)); } }, + document: pageDocument, window: pageWindow, AbortController, location: { search: options.search ?? '?room=same-room', origin: 'https://stage.example.test' }, URL, URLSearchParams, Error, Promise, performance, setTimeout, clearTimeout, setInterval, clearInterval, loadLibrary: async (url: string) => { assert.equal(url, 'https://stage.example.test/lib/stage-client.js'); return library; }, @@ -158,7 +161,7 @@ export async function actualApp(t: TestContext, options: { deferCapture?: boolea assert.ok(StagePeer.instances.every(peer => peer.connectionState === 'closed')); assert.ok(StageContext.instances.every(context => context.closes === 1)); } finally { for (const [key, descriptor] of originals) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); } } }); - return { stage, status, mic, micRetry, leave, form, node, audio, clients, tracks, captures, enumerations, sinks, tick, until, confirmations, + return { stage, status, mic, micRetry, leave, form, node, audio, clients, tracks, captures, enumerations, sinks, tick, until, confirmations, pageDocument, pageWindow, calls: () => calls, ready: () => ready, async join(overrides: Partial = {}) { form.submit(); await flush(); const socket = StageSocket.instances.at(-1)!; diff --git a/test/webEmbedEntry.test.ts b/test/webEmbedEntry.test.ts index 8d21340..2e1f956 100644 --- a/test/webEmbedEntry.test.ts +++ b/test/webEmbedEntry.test.ts @@ -60,9 +60,10 @@ async function fixture(t: TestContext, options: { deferLibrary?: boolean } = {}) t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); let now = 1000; t.mock.method(performance, 'now', () => now); - const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'speaking-summary', 'connection-stats-panel', 'connection-stats-update', 'connection-stats-text', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); + const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'speaking-summary', 'connection-stats-panel', 'connection-stats-update', 'connection-stats-text', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'mic-test-panel', 'mic-test-start', 'mic-test-cancel', 'mic-test-stop', 'mic-test-meter', 'mic-test-status', 'mic-test-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); const node = (id: string): EmbedNode => { const found = nodes.get(id); assert.ok(found); return found; }; node('connection-stats-panel').append(node('connection-stats-update'), node('connection-stats-text')); + node('mic-test-panel').append(...['mic-test-start', 'mic-test-cancel', 'mic-test-stop', 'mic-test-meter', 'mic-test-status', 'mic-test-notice'].map(node)); node('error-notice').hidden = true; node('hand-rejected').hidden = true; node('room-occupancy').hidden = true; node('own-connection-quality').hidden = true; node('speaking-summary').hidden = true; const cancel = node('cancel-join'); cancel.hidden = cancel.disabled = true; cancel.textContent = '取消連線'; const form = node('join'), submit = new EmbedNode(), status = node('status'), leave = node('leave'), hand = node('hand'); @@ -79,8 +80,10 @@ async function fixture(t: TestContext, options: { deferLibrary?: boolean } = {}) assert.equal(source.split("import './styles.css';").length, 2); const executable = stripTypeScriptTypes(source.replace("import './styles.css';", '').replace(dynamicImport, 'loadLibrary(moduleUrl)')); assert.doesNotMatch(executable, /\bimport\s*(?:\(|['"])/); + const pageDocument = Object.assign(new EventTarget(), { hidden: false, createElement(tag: string) { assert.equal(tag, 'option'); return new EmbedNode(); }, querySelector(selector: string) { assert.match(selector, /^#[a-z-]+$/); return node(selector.slice(1)); } }); + const pageWindow = Reflect.get(globalThis, 'window') as EventTarget; runInNewContext(executable, { - document: { createElement(tag: string) { assert.equal(tag, 'option'); return new EmbedNode(); }, querySelector(selector: string) { assert.match(selector, /^#[a-z-]+$/); return node(selector.slice(1)); } }, + document: pageDocument, window: pageWindow, AbortController, location: { search: '?room=same-room&code=TEST42', origin: 'https://stage.example.test' }, URL, URLSearchParams, Error, Promise, performance, setTimeout, clearTimeout, setInterval, clearInterval, loadLibrary: async (url: string) => { diff --git a/test/webEmbedInputDevice.test.ts b/test/webEmbedInputDevice.test.ts index 6e82879..034f54a 100644 --- a/test/webEmbedInputDevice.test.ts +++ b/test/webEmbedInputDevice.test.ts @@ -58,7 +58,7 @@ it('embed HTML declares a hidden labeled manual microphone draft with separate a } assert.ok(html.indexOf(panel) > html.indexOf('')); const source = readFileSync(new URL('../web/src/embed.ts', import.meta.url), 'utf8'); - assert.doesNotMatch(source, /(?:local|session)Storage|\bgetUserMedia\b|\benumerateDevices\b|\bdevicechange\b|\bstartMicTest\b|\btestMicrophone\b/); + assert.doesNotMatch(source, /(?:local|session)Storage|\bgetUserMedia\b|\benumerateDevices\b|\bdevicechange\b|\btestMicrophone\b/); }); it('whole embed never enumerates/applies before explicit actions, including entry, snapshots, unlock and elapsed time', async t => { diff --git a/test/webEmbedMicTest.test.ts b/test/webEmbedMicTest.test.ts new file mode 100644 index 0000000..e846ba3 --- /dev/null +++ b/test/webEmbedMicTest.test.ts @@ -0,0 +1,358 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { it, type TestContext } from 'node:test'; +import type { RoomStatePayload, ServerMessage } from '../shared/protocol.ts'; +import type { Session } from '../src/ws/hub.ts'; +import { parseClientMessage } from '../src/ws/validate.ts'; +import { harness, testConfig } from './helpers.ts'; +import { embedApp, flush, type EmbedSdk, type EmbedNode } from './fixtures/embedApp.ts'; +import { actualApp, actualState, StageContext, StagePeer, StageSocket } from './fixtures/embedSdkApp.ts'; + +// Complete production embed and, where stated, real SDK/Hub. DOM, sockets and media are fake. +// Native keyboard, accessibility-tree, permission prompts and hardware audio are not exercised. +interface TestHandle { readonly stopped?: boolean; level(): number; stop(): void } +interface PreviewClient extends EventTarget { + state: RoomStatePayload | null; + startMicTest(options?: { signal?: AbortSignal }): Promise; + on(type: string, listener: (event: { detail: unknown }) => void): () => void; +} +type App = ReturnType; +const node = (h: Pick, suffix: string): EmbedNode => h.node(`mic-test-${suffix}`); +function idle(h: Pick, hidden = false, busy = false): void { + assert.equal(node(h, 'panel').hidden, hidden); assert.equal(node(h, 'start').disabled, hidden || busy); + assert.equal(node(h, 'cancel').hidden, true); assert.equal(node(h, 'stop').hidden, true); + assert.equal(Number(node(h, 'meter').value), 0); +} +function pending(h: Pick): void { + assert.equal(node(h, 'panel').hidden, false); assert.equal(node(h, 'start').disabled, true); + assert.equal(node(h, 'start').getAttribute('aria-busy'), 'true'); + assert.equal(node(h, 'cancel').hidden, false); assert.equal(node(h, 'cancel').disabled, false); + assert.equal(node(h, 'stop').hidden, true); assert.equal(Number(node(h, 'meter').value), 0); +} +function active(h: Pick, level = 0): void { + assert.equal(node(h, 'panel').hidden, false); assert.equal(node(h, 'start').disabled, true); + assert.equal(node(h, 'cancel').hidden, true); assert.equal(node(h, 'stop').hidden, false); + assert.equal(node(h, 'stop').disabled, false); assert.equal(Number(node(h, 'meter').value), level); +} +function force(h: Pick, suffix = 'start'): void { + node(h, 'panel').hidden = false; node(h, suffix).hidden = node(h, suffix).disabled = false; node(h, suffix).invoke(); +} +function captures(sdk: EmbedSdk) { + const requests: { signal: AbortSignal; work: ReturnType> }[] = []; + Reflect.set(sdk, 'startMicTest', ({ signal }: { signal: AbortSignal }) => { + const work = Promise.withResolvers(); requests.push({ signal, work }); return work.promise; + }); + return requests; +} +function handle(level = 0.25) { + return { stops: 0, reads: 0, stopped: false, level() { this.reads++; return level; }, stop() { this.stops++; this.stopped = true; } }; +} +async function fake(t: TestContext) { + const h = embedApp(); t.after(() => h.cleanup()); const sdk = await h.join(); + const requests = captures(sdk); return { h, sdk, requests }; +} +async function actual(t: TestContext) { + const h = await actualApp(t, { deferCapture: true }); const room = await h.join({ role: 'audience', onStage: false }); + const client = h.clients[0] as unknown as PreviewClient; return { ...h, ...room, client }; +} +function graph(t: TestContext) { + const values = { amplitude: 0.25, reads: 0 }; + t.mock.method(StageContext.prototype, 'createAnalyser', () => ({ fftSize: 2048, + getFloatTimeDomainData(samples: Float32Array) { values.reads++; samples.fill(values.amplitude); }, disconnect() {} })); + return values; +} +function onceSetter(target: EmbedNode, property: 'textContent' | 'disabled' | 'value', callback: () => void): void { + let value: unknown = Reflect.get(target, property), armed = true; + Object.defineProperty(target, property, { configurable: true, get() { return value; }, set(next: unknown) { + value = next; if (armed) { armed = false; callback(); } + } }); +} + +it('embed microphone test uses native labelled actions and meter with honest level-only copy', () => { + const html = readFileSync(new URL('../web/embed.html', import.meta.url), 'utf8'); + for (const suffix of ['panel', 'start', 'cancel', 'stop', 'meter', 'status', 'notice']) + assert.equal(html.split(`id="mic-test-${suffix}"`).length, 2); + for (const suffix of ['start', 'cancel', 'stop']) { + const button = html.match(new RegExp(`]*id="mic-test-${suffix}"[^>]*>[^<]+`))?.[0]; assert.ok(button); + assert.match(button, /type="button"/); assert.match(button, /\bdisabled\b/); + assert.doesNotMatch(button, /tabindex=|role=|aria-pressed=|aria-label=/); + if (suffix !== 'start') assert.match(button, /\bhidden\b/); + } + assert.match(html, /