mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-09-01 05:29:22 +00:00
Merge pull request #35 from Shik3i/feat/canonical-media-state-v1
feat(sync): add canonical media state and resilient offline recovery
This commit is contained in:
@@ -32,6 +32,70 @@ Ensures all peers are buffered and synchronized before resuming:
|
||||
> **Network Transit Buffer Rule**: The orchestrator (`background.js`) must always use a timeout at least 500ms longer than the worker (`content.js`) to account for IPC and network transit time. Never align them exactly 1:1, as this will introduce a race condition on slow connections.
|
||||
4. **Resume**: All peers call `play()` simultaneously.
|
||||
|
||||
## 3.1 Canonical Media State v1
|
||||
|
||||
The relay keeps one optional, in-memory canonical playback state per active room.
|
||||
Accepted `PLAY`, `PAUSE`, and `SEEK` commands advance a server-owned revision.
|
||||
Playing positions advance lazily from the server update time; paused positions do
|
||||
not. Heartbeats remain observational and do not mutate canonical state.
|
||||
|
||||
`ROOM_DATA` materializes the playing position at snapshot creation and advertises
|
||||
the optional `media-state-v1` capability. A joining/reconnecting client validates
|
||||
the room/revision and optional privacy-sanitized media title, respects Host Control
|
||||
solo mode and Episode Lobby, then queues an internal
|
||||
`APPLY_CANONICAL_MEDIA_STATE` message on the same ordered content path as newer
|
||||
live commands.
|
||||
That path reuses frame election, Netflix/Disney page-API seeks, native play/pause,
|
||||
the 2-second drift tolerance, and programmatic-event suppression. Recovery only
|
||||
completes after playback state and position verification. Transient failures
|
||||
retry after 250, 750, 1500, and 3000 ms, while target, heartbeat, and content-boot
|
||||
signals can retrigger a pending attempt within that bound. A pending playing
|
||||
snapshot advances from its local receipt time while waiting for a target, and
|
||||
the apply creates no action history, notification, command ACK, or relay media
|
||||
event.
|
||||
|
||||
Current clients announce `media-state-v1` as an optional client capability and
|
||||
continue sending accepted media controls while alone on a capable relay. If a
|
||||
room instead falls back to one legacy client that suppresses solo controls, the
|
||||
relay clears canonical state so a future joiner receives no snapshot rather than
|
||||
known-unreliable playback truth.
|
||||
|
||||
Force Sync remains a two-phase ACK protocol. A valid `PREPARE` is temporary
|
||||
room-wide choreography; the next authorized `EXECUTE` commits the latest target
|
||||
visible to peers to canonical state. Delayed execution is logged but remains
|
||||
valid until newer accepted playback or lobby state explicitly supersedes it; an
|
||||
untracked post-restart execute retains legacy relay liveness without inventing a
|
||||
canonical target. The
|
||||
offline queue replays an adjacent `PREPARE`/`EXECUTE` pair in one paced batch and
|
||||
retains both if delivery fails. Per-sender
|
||||
`seq`, peer heartbeats, and the reconnect queue remain separate mechanisms. The
|
||||
relay rejects duplicate/regressing current-client media sequences before they
|
||||
can diverge canonical truth from live receivers.
|
||||
|
||||
## 3.2 Offline Media Intent
|
||||
|
||||
Canonical Media State and Offline Media Intent have different ownership:
|
||||
|
||||
- Canonical Media State is the relay's last accepted shared playback truth.
|
||||
- Offline Media Intent is one room-scoped client representation of local
|
||||
`PLAY`/`PAUSE`/`SEEK` commands that have not reached the relay yet.
|
||||
|
||||
Contiguous offline controls merge into a bounded logical queue entry. Every
|
||||
retained coordination event, including Force Sync and Episode Lobby events, is
|
||||
an ordering barrier. Stale offline `PING`, `PONG`, heartbeat `PEER_STATUS`, and
|
||||
`EVENT_ACK` frames are not persisted because they are no longer meaningful after
|
||||
reconnect. Force Sync ACK remains transactional and is not dropped or merged.
|
||||
|
||||
Media intent waits for the reconnecting room's `ROOM_DATA`. An authorized intent
|
||||
takes precedence over the older canonical snapshot, materializes into the
|
||||
minimum ordered legacy `SEEK` plus `PLAY`/`PAUSE` frames needed by old peers, and
|
||||
thereby advances relay canonical state normally. With no intent, canonical
|
||||
recovery is unchanged. Role loss discards room-driving intent before recovery;
|
||||
intentional Host Control solo mode and an active Episode Lobby remain
|
||||
authoritative. MV3 session restoration migrates the previous raw queue format,
|
||||
preserves barriers, repairs `localSeq` monotonically, and rejects another room's
|
||||
intent.
|
||||
|
||||
## 4. Episode Auto-Sync
|
||||
Maintains continuous synchronized viewing when watching series:
|
||||
1. **Detection**: `content.js` monitors the Media Session API for title changes.
|
||||
|
||||
+107
-5
@@ -54,7 +54,7 @@ Payload:
|
||||
"password": "string, max 128, optional",
|
||||
"tabTitle": "string, max 100, optional",
|
||||
"mediaTitle": "string, max 100, optional",
|
||||
"clientCapabilities": ["chat-v1"],
|
||||
"clientCapabilities": ["chat-v1", "media-state-v1"],
|
||||
"protocolVersion": "string, max 16"
|
||||
}
|
||||
```
|
||||
@@ -83,13 +83,110 @@ Payload:
|
||||
"hostPeerId": "string or null",
|
||||
"controlMode": "everyone | host-only",
|
||||
"controllers": ["peerId"],
|
||||
"capabilities": ["host-control", "co-host", "chat", "chat-v1"]
|
||||
"mediaState": "canonical media state object or null",
|
||||
"capabilities": ["host-control", "co-host", "chat", "chat-v1", "media-state-v1"]
|
||||
}
|
||||
```
|
||||
|
||||
`room_data` is sent to the joining socket. It is not the general broadcast used
|
||||
for every later room update.
|
||||
|
||||
## Canonical Media State v1
|
||||
|
||||
Relays advertise this optional recovery primitive with `"media-state-v1"` in
|
||||
`room_data.capabilities`. Each active room stores at most one state:
|
||||
|
||||
```json
|
||||
{
|
||||
"revision": 42,
|
||||
"playbackState": "playing",
|
||||
"currentTime": 1234.5,
|
||||
"updatedAt": 1787234425123,
|
||||
"updatedBy": "peer-id"
|
||||
}
|
||||
```
|
||||
|
||||
The internal `currentTime` is the media position at server-owned `updatedAt`.
|
||||
Playing state advances lazily when a snapshot is requested; paused state stays
|
||||
fixed. `revision`, `updatedAt`, and `updatedBy` are server-owned. Clients cannot
|
||||
spoof them. The wire snapshot contains the already-projected `currentTime`,
|
||||
`revision`, `playbackState`, `updatedBy`, and an optional privacy-sanitized
|
||||
`mediaTitle`, so clients never compare client and server wall clocks and retain
|
||||
the existing cross-episode guard during recovery.
|
||||
|
||||
Only accepted, sanitized room controls update canonical state:
|
||||
|
||||
- `play` uses its valid `currentTime`, or an existing effective canonical position.
|
||||
- `pause` uses its valid `currentTime`, or freezes an existing effective position.
|
||||
- `seek` prefers `targetTime` (with `currentTime` compatibility) and preserves the
|
||||
established playback state.
|
||||
- a valid `force_sync_prepare` records only temporary coordination state. The
|
||||
next authorized `force_sync_execute` commits the latest room-wide prepared
|
||||
target as playing. A target older than `FORCE_SYNC_TARGET_DELAY_WARNING` is
|
||||
logged but remains executable until newer room playback supersedes it, because
|
||||
receivers are already paused. An execute without retained target still uses
|
||||
the legacy wire fallback after relay restart but cannot invent canonical state.
|
||||
The latest valid prepare is also the only post-demotion or reconnect execute
|
||||
exemption in Host Control mode.
|
||||
|
||||
`peer_status` heartbeats are observations and never rewrite canonical intent.
|
||||
For current clients, the relay drops invalid, duplicate, or regressing `seq`
|
||||
values on room-moving media commands before relay/canonical mutation. Legacy
|
||||
clients without `seq` retain their existing behavior. Canonical `revision`
|
||||
orders server-accepted room transitions; it does not replace per-sender order.
|
||||
|
||||
On join/reconnect, a capable extension attempts to apply a valid snapshot
|
||||
through an extension-internal recovery message. Recovery is only marked handled
|
||||
after playback state and position verification. Transient failures use bounded
|
||||
retries after 250, 750, 1500, and 3000 ms and can also be retriggered by target,
|
||||
heartbeat, or content-boot signals. Existing seek/page-API and native-event
|
||||
suppression prevent `play`, `pause`, or `seek` echoes. Pending recovery is scoped
|
||||
to the room/revision in `chrome.storage.session`, waits for the selected media
|
||||
target lifecycle, and projects a still-playing snapshot from its local receipt
|
||||
time before a delayed apply. It is cleared on leave/switch. Intentional host-only
|
||||
guest desync and an active Episode Lobby take precedence over snapshot recovery.
|
||||
|
||||
Compatibility is additive: new clients use old behavior with a relay that omits
|
||||
the capability; old clients ignore the extra `room_data` field from a new relay.
|
||||
A new relay canonicalizes every accepted legacy `play`, `pause`, `seek`, and
|
||||
matching Force Sync command regardless of `join_room.clientCapabilities`, while
|
||||
relaying the established event names, payloads, and order unchanged. This makes
|
||||
server-first rollout safe: old clients populate recovery state without needing to
|
||||
understand or acknowledge it, and new clients consume it only when the relay
|
||||
advertises the capability. No protocol-version or minimum-version bump is
|
||||
required. New clients also announce `"media-state-v1"` in optional
|
||||
`join_room.clientCapabilities`; this only tells a capable relay that the client
|
||||
keeps canonical state current while alone. When a room falls back to one legacy
|
||||
client, the relay clears potentially stale canonical state instead of recovering
|
||||
future joiners to unverified solo playback. Offline `play`/`pause`/`seek`
|
||||
compaction remains the separate
|
||||
client-owned layer described below rather than part of the relay capability.
|
||||
|
||||
### Offline media intent
|
||||
|
||||
Offline media intent is client-side queue state, not a relay protocol feature.
|
||||
An updated extension coalesces contiguous unsent `play`, `pause`, and `seek`
|
||||
commands for one room. Retained non-media events are ordering barriers. On a
|
||||
successful rejoin, the extension first reads `room_data` so current Host Control
|
||||
and Episode Lobby authority can be applied, then replays an authorized intent as
|
||||
the minimum existing legacy media-event sequence. Actual wire frames, rather
|
||||
than logical queue entries, consume the paced reconnect budget.
|
||||
|
||||
Pending authorized local intent takes precedence over an older canonical
|
||||
snapshot because it has not yet been accepted by the relay. Its legacy replay
|
||||
then updates canonical state like any other accepted control. Without pending
|
||||
intent, canonical recovery proceeds normally. Intent made stale by a room switch,
|
||||
role loss, intentional solo mode, or an active Episode Lobby is discarded and
|
||||
cannot suppress server recovery.
|
||||
|
||||
This requires no event, capability, ACK, protocol-version, or minimum-version
|
||||
change. Old relays receive ordinary `play`/`pause`/`seek`; old peers see only the
|
||||
same existing relayed events.
|
||||
|
||||
Queued adjacent `force_sync_prepare` and `force_sync_execute` entries replay in
|
||||
one paced batch. If either send fails, the full pair remains queued so a later
|
||||
retry refreshes the prepared target before executing it.
|
||||
|
||||
## Ephemeral encrypted chat
|
||||
|
||||
Relays advertise chat support with `"chat-v1"` in `room_data.capabilities` and keep
|
||||
@@ -253,9 +350,14 @@ them with the same sanitized relay envelope as other room events, including
|
||||
|
||||
### `force_sync_execute`
|
||||
|
||||
Payload includes `targetTime`. In `host-only` mode, only controllers may send it.
|
||||
The relay also allows a matching initiator's execute event after that initiator
|
||||
started the prepare step, even if their controller state changed before execute.
|
||||
The current extension sends sequence/action metadata but no target; the relay uses
|
||||
the latest validated room target retained from `force_sync_prepare`. In
|
||||
`host-only` mode, only controllers may send it.
|
||||
The relay also allows that latest valid initiator's execute event after their
|
||||
controller state changed or their socket reconnected before execute. Invalid
|
||||
prepares are dropped and grant no exemption. Newer accepted playback/lobby state
|
||||
explicitly supersedes the prepared target, so its delayed execute is dropped.
|
||||
Otherwise, even a delayed execute is relayed to release paused receivers.
|
||||
|
||||
## Episode Lobby
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
|
||||
|
||||
function sourceBetween(startNeedle, endNeedle) {
|
||||
const start = backgroundSource.indexOf(startNeedle);
|
||||
const end = backgroundSource.indexOf(endNeedle, start + startNeedle.length);
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
return backgroundSource.slice(start, end);
|
||||
}
|
||||
|
||||
describe('async room-session guards', () => {
|
||||
it('normalizes persisted room, peer, lobby and Force Sync state before restoration', () => {
|
||||
const restore = sourceBetween('function ensureState()', '// Start restoration immediately');
|
||||
expect(restore).toContain("typeof data.currentRoom.roomId === 'string'");
|
||||
expect(restore).toContain('.map(createPeerData)');
|
||||
expect(restore).toContain('data.currentRoom.activeLobby,');
|
||||
expect(restore).toContain('Array.isArray(data.forceSyncAcks)');
|
||||
expect(restore).toContain('currentRoom && Number.isFinite(data.forceSyncDeadline)');
|
||||
expect(restore).toContain('const restoredEpisodeLobby = currentRoom');
|
||||
expect(restore).toContain('data.episodeLobby,');
|
||||
});
|
||||
|
||||
it('revalidates ROOM_DATA after every asynchronous join boundary', () => {
|
||||
const roomData = sourceBetween('case EVENTS.ROOM_DATA:', 'case EVENTS.CONTROL_MODE:');
|
||||
expect(roomData.match(/currentRoom\?\.roomId !== data\.roomId/g)?.length).toBeGreaterThanOrEqual(2);
|
||||
expect(roomData).toContain('const authoritativeLobby = normalizeEpisodeLobby(');
|
||||
});
|
||||
|
||||
it('does not return chat context after its room or target changed', () => {
|
||||
const handler = sourceBetween("message.type === 'GET_CHAT_CONTEXT'", "message.type === 'CHAT_SEND'");
|
||||
expect(handler).toContain('const roomId = currentRoom.roomId');
|
||||
expect(handler).toContain('const isCurrentSession = () =>');
|
||||
expect(handler.indexOf('if (!isCurrentSession() || settings.roomId !== roomId)'))
|
||||
.toBeGreaterThan(handler.indexOf('await loadLocale'));
|
||||
expect(handler).not.toContain('roomId: currentRoom.roomId');
|
||||
});
|
||||
|
||||
it('drops heartbeats and episode transitions that cross a room or target switch', () => {
|
||||
const heartbeat = sourceBetween("message.type === 'HEARTBEAT'", "message.type === 'INJECT_CONTENT_SCRIPT'");
|
||||
expect(heartbeat).toContain('const heartbeatRoomId = currentRoom?.roomId || null');
|
||||
expect(heartbeat).toContain("status: 'ignored_stale_session'");
|
||||
expect(heartbeat.indexOf('currentRoom?.roomId !== heartbeatRoomId'))
|
||||
.toBeLessThan(heartbeat.indexOf('emit(EVENTS.PEER_STATUS'));
|
||||
|
||||
const episode = sourceBetween("message.type === 'EPISODE_CHANGED'", "message.type === 'EPISODE_READY_LOCAL'");
|
||||
expect(episode).toContain('const isCurrentEpisodeContext = () =>');
|
||||
expect(episode.match(/if \(!isCurrentEpisodeContext\(\)/g)?.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const ready = sourceBetween("message.type === 'EPISODE_READY_LOCAL'", "message.type === 'TITLE_PRIVACY_CHANGED'");
|
||||
expect(ready).toContain('settings.roomId !== lobbyRoomId');
|
||||
|
||||
const privacy = sourceBetween("message.type === 'TITLE_PRIVACY_CHANGED'", "message.type === 'MEDIA_FRAME_CANDIDATE_CHANGED'");
|
||||
expect(privacy).toContain('currentRoom?.roomId !== privacyRoomId');
|
||||
});
|
||||
|
||||
it('resolves content-event awaits before mutating canonical or room state', () => {
|
||||
const handler = sourceBetween("message.type === 'CONTENT_EVENT'", "message.type === 'FORCE_SYNC_ACK'");
|
||||
const processEventIndex = handler.indexOf('const processEvent = async () =>');
|
||||
const videoStateIndex = handler.indexOf('await getReadyTabVideoState(tabId)', processEventIndex);
|
||||
const settingsIndex = handler.indexOf('const settings = await getSettings()', videoStateIndex);
|
||||
const contextGuardIndex = handler.indexOf("sendResponse({ status: 'ignored_stale_session' })", settingsIndex);
|
||||
const supersedeIndex = handler.indexOf('supersedeCanonicalMediaRecovery(`local ${message.action}`)', contextGuardIndex);
|
||||
expect(videoStateIndex).toBeGreaterThan(-1);
|
||||
expect(settingsIndex).toBeGreaterThan(videoStateIndex);
|
||||
expect(contextGuardIndex).toBeGreaterThan(settingsIndex);
|
||||
expect(handler).toContain('(eventRoomId && settings.roomId !== eventRoomId)');
|
||||
expect(supersedeIndex).toBeGreaterThan(contextGuardIndex);
|
||||
});
|
||||
|
||||
it('serializes new connection attempts behind terminal room teardown', () => {
|
||||
const teardown = sourceBetween('async function endRoomSession', 'async function leaveRoomAfterIdleGrace');
|
||||
expect(teardown).toContain('if (roomTeardownPromise) return roomTeardownPromise');
|
||||
expect(teardown).toContain('performRoomSessionTeardown(options)');
|
||||
|
||||
for (const [start, end] of [
|
||||
["message.type === 'CONNECT'", "message.type === 'RETRY_CONNECT'"],
|
||||
["message.type === 'RETRY_CONNECT'", "message.type === 'GET_STATUS'"],
|
||||
["message.type === 'WEB_JOIN_REQUEST'", "message.type === 'REGENERATE_ID'"]
|
||||
]) {
|
||||
expect(sourceBetween(start, end)).toContain('await waitForRoomTeardown()');
|
||||
}
|
||||
});
|
||||
});
|
||||
+981
-208
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,131 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
|
||||
const contentSource = fs.readFileSync(path.join(extensionDir, 'content.js'), 'utf8');
|
||||
|
||||
function functionBody(source, name, nextName) {
|
||||
const start = source.indexOf(`function ${name}(`);
|
||||
const end = source.indexOf(`function ${nextName}(`, start + 1);
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
return source.slice(start, end === -1 ? source.length : end);
|
||||
}
|
||||
|
||||
describe('canonical ROOM_DATA recovery contract', () => {
|
||||
it('is capability-gated and treats absent/null media state as the old-relay path', () => {
|
||||
const handler = functionBody(backgroundSource, 'handleCanonicalRoomData', 'handleServerEvent');
|
||||
expect(handler).toContain('canonicalMediaStateFromRoomData(data)');
|
||||
expect(handler).toContain("canonicalSnapshot.status === 'unsupported'");
|
||||
expect(handler).toContain("canonicalSnapshot.status === 'empty'");
|
||||
expect(handler.indexOf('canonicalMediaStateFromRoomData(data)'))
|
||||
.toBeLessThan(handler.indexOf('canonicalMediaStateTracker.receive'));
|
||||
expect(backgroundSource).toMatch(/CLIENT_CAPABILITIES[\s\S]{0,160}CAPABILITIES\.MEDIA_STATE_V1/);
|
||||
expect(backgroundSource).toContain('!serverSupports(CAPABILITIES.MEDIA_STATE_V1)');
|
||||
});
|
||||
|
||||
it('keeps legacy PLAY/PAUSE/SEEK, Force Sync, Episode Lobby, and Host Control handlers independent of canonical recovery', () => {
|
||||
const serverHandler = functionBody(backgroundSource, 'handleServerEvent', 'executeForceSync');
|
||||
expect(serverHandler).toContain('case EVENTS.PLAY:');
|
||||
expect(serverHandler).toContain('case EVENTS.PAUSE:');
|
||||
expect(serverHandler).toContain('case EVENTS.SEEK:');
|
||||
expect(serverHandler).toContain('case EVENTS.FORCE_SYNC_PREPARE:');
|
||||
expect(serverHandler).toContain('case EVENTS.FORCE_SYNC_EXECUTE:');
|
||||
expect(serverHandler).toContain('case EVENTS.EPISODE_LOBBY:');
|
||||
expect(serverHandler).toContain('case EVENTS.CONTROL_MODE:');
|
||||
expect(serverHandler).not.toMatch(/case EVENTS\.(?:PLAY|PAUSE|SEEK):[\s\S]{0,500}MEDIA_STATE_V1/);
|
||||
});
|
||||
|
||||
it('uses a dedicated internal apply message without action/history/ACK machinery', () => {
|
||||
const apply = functionBody(backgroundSource, 'performPendingCanonicalMediaStateApply', 'tryApplyPendingCanonicalMediaState');
|
||||
expect(apply).toContain("type: 'APPLY_CANONICAL_MEDIA_STATE'");
|
||||
expect(apply).toContain('enqueueContentCommand(async () =>');
|
||||
expect(apply).toContain('canonicalMediaStateTracker.getPending(roomId)');
|
||||
expect(apply).not.toContain('routeToContent(');
|
||||
expect(apply).not.toContain('emit(');
|
||||
expect(apply).not.toContain('addToHistory(');
|
||||
expect(apply).not.toContain('EVENT_ACK');
|
||||
|
||||
const contentHandlerStart = contentSource.indexOf("message.type === 'APPLY_CANONICAL_MEDIA_STATE'");
|
||||
const serverCommandStart = contentSource.indexOf("message.type === 'SERVER_COMMAND'", contentHandlerStart);
|
||||
const internalHandler = contentSource.slice(contentHandlerStart, serverCommandStart);
|
||||
expect(internalHandler).toContain('const applyGeneration = beginCanonicalMediaApply()');
|
||||
expect(internalHandler).toContain('applyCanonicalMediaState(message.mediaState, applyGeneration)');
|
||||
expect(internalHandler).not.toContain('CMD_ACK');
|
||||
expect(internalHandler).not.toContain('CONTENT_EVENT');
|
||||
});
|
||||
|
||||
it('keeps pending recovery room-scoped and retries on target lifecycle signals', () => {
|
||||
expect(backgroundSource).toContain("'canonicalMediaRecovery'");
|
||||
expect(backgroundSource).toContain('canonicalMediaStateTracker.restore(');
|
||||
expect(backgroundSource).toContain('CANONICAL_RECOVERY_RETRY_DELAYS');
|
||||
expect(backgroundSource).toContain('requestCanonicalMediaRecoveryAttempt()');
|
||||
expect(backgroundSource).toMatch(/message\.type === 'HEARTBEAT'[\s\S]*requestCanonicalMediaRecoveryAttempt\(\)/);
|
||||
expect(backgroundSource).toMatch(/message\.type === 'CONTENT_BOOT'[\s\S]*requestCanonicalMediaRecoveryAttempt\(\)/);
|
||||
expect(backgroundSource).toMatch(/currentTargetHasVideo\) \{\s*await tryApplyPendingCanonicalMediaState\(\)/);
|
||||
expect(backgroundSource.match(/clearCanonicalMediaRecovery\(\)/g)?.length).toBeGreaterThanOrEqual(4);
|
||||
const apply = functionBody(backgroundSource, 'performPendingCanonicalMediaStateApply', 'tryApplyPendingCanonicalMediaState');
|
||||
expect(apply).toContain('getPendingProjected(roomId)');
|
||||
expect(apply).toContain('targetActivationGeneration');
|
||||
expect(apply).toContain("return { status: 'stale_target' }");
|
||||
const retry = functionBody(backgroundSource, 'scheduleCanonicalMediaRecoveryRetry', 'requestCanonicalMediaRecoveryAttempt');
|
||||
expect(retry).toContain('canonicalRecoveryRetryAttempt >= CANONICAL_RECOVERY_RETRY_DELAYS.length');
|
||||
expect(retry).toContain('latest?.mediaState.revision !== expectedRevision');
|
||||
const clear = functionBody(backgroundSource, 'clearCanonicalMediaRecovery', 'invalidateChatSession');
|
||||
expect(clear).toContain('canonicalRecoveryApplyInProgress = null');
|
||||
});
|
||||
|
||||
it('protects intentional desync, active Episode Lobby and queued reconnect intent', () => {
|
||||
const apply = functionBody(backgroundSource, 'performPendingCanonicalMediaStateApply', 'tryApplyPendingCanonicalMediaState');
|
||||
const roomData = functionBody(backgroundSource, 'handleCanonicalRoomData', 'handleServerEvent');
|
||||
expect(apply).toContain('if (hcmDesynced)');
|
||||
expect(apply).toContain('if (episodeLobby)');
|
||||
expect(roomData).toContain('if (hasPendingLocalIntent)');
|
||||
expect(backgroundSource).toContain('awaitingRoomData = true');
|
||||
expect(backgroundSource).toContain('await handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent)');
|
||||
expect(backgroundSource).toContain('await flushEventQueue(replaySettings)');
|
||||
});
|
||||
|
||||
it('supersedes pending recovery only after newer local or accepted remote room control', () => {
|
||||
const supersede = functionBody(backgroundSource, 'supersedeCanonicalMediaRecovery', 'performPendingCanonicalMediaStateApply');
|
||||
expect(supersede).toContain('canonicalMediaStateTracker.getPending(roomId)');
|
||||
expect(supersede).toContain('markCanonicalMediaStateHandled(roomId, pending.mediaState.revision)');
|
||||
expect(supersede).toContain("type: 'CANCEL_CANONICAL_MEDIA_STATE'");
|
||||
expect(supersede).toContain('action,');
|
||||
expect(supersede).toContain('payload');
|
||||
expect(backgroundSource).toContain('function isCanonicalSupersedingControl(event, data)');
|
||||
expect(backgroundSource).toContain('supersedeCanonicalMediaRecovery(`newer ${event}`)');
|
||||
expect(backgroundSource).toContain('supersedeCanonicalMediaRecovery(`local ${message.action}`)');
|
||||
expect(backgroundSource.indexOf("sendResponse({ status: 'blocked_host_only' })"))
|
||||
.toBeLessThan(backgroundSource.indexOf('supersedeCanonicalMediaRecovery(`local ${message.action}`)'));
|
||||
});
|
||||
|
||||
it('awaits media actions and verifies playback plus drift before acknowledging recovery', () => {
|
||||
const apply = functionBody(contentSource, 'applyCanonicalMediaState', 'pollSeekReady');
|
||||
expect(apply).toContain('Math.abs(drift) >= MIN_SEEK_DELTA');
|
||||
expect(apply).toContain("_setSuppress('seek')");
|
||||
expect(apply).toContain('await tryMediaAction(EVENTS.SEEK');
|
||||
expect(apply).toContain('await tryMediaAction(EVENTS.PAUSE)');
|
||||
expect(apply).toContain('await tryMediaAction(EVENTS.PLAY)');
|
||||
expect(apply).toContain('await pollCanonicalMediaState(mediaState, startedAt, applyGeneration)');
|
||||
expect(apply.indexOf("status: 'applied'"))
|
||||
.toBeGreaterThan(apply.indexOf('await pollCanonicalMediaState(mediaState, startedAt, applyGeneration)'));
|
||||
expect(apply).toContain('isCanonicalMediaApplyCurrent(applyGeneration)');
|
||||
expect(apply).toContain('restoreSupersedingLocalState(video)');
|
||||
expect(apply).toContain('if (hcmDesynced)');
|
||||
expect(apply).toContain('isDifferentEpisode(mediaState.mediaTitle, localMediaTitle)');
|
||||
expect(apply).toContain("status: 'ignored_episode_mismatch'");
|
||||
const contentHandlerStart = contentSource.indexOf("message.type === 'APPLY_CANONICAL_MEDIA_STATE'");
|
||||
const serverCommandStart = contentSource.indexOf("message.type === 'SERVER_COMMAND'", contentHandlerStart);
|
||||
expect(contentSource.slice(contentHandlerStart, serverCommandStart))
|
||||
.toContain('applyCanonicalMediaState(message.mediaState, applyGeneration).then(sendResponse)');
|
||||
expect(contentSource).toContain("message.type === 'CANCEL_CANONICAL_MEDIA_STATE'");
|
||||
expect(contentSource).toContain('cancelCanonicalMediaApply(action, findVideo(), false, payload)');
|
||||
expect(contentSource).toContain('restorationGeneration !== canonicalMediaApplyGeneration');
|
||||
expect(contentSource).toContain('holdCanonicalRestorePlaySuppression()');
|
||||
expect(contentSource).toContain('consumeCanonicalRestorePlaySuppression()');
|
||||
expect(contentSource).toContain('cancelCanonicalMediaApply(EVENTS.SEEK, video)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { CAPABILITIES, MAX_MEDIA_TIME } from '../shared/constants.js';
|
||||
|
||||
export function validateCanonicalMediaState(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
if (!Number.isSafeInteger(value.revision) || value.revision < 1) return null;
|
||||
if (value.playbackState !== 'playing' && value.playbackState !== 'paused') return null;
|
||||
if (typeof value.currentTime !== 'number'
|
||||
|| !Number.isFinite(value.currentTime)
|
||||
|| value.currentTime < 0
|
||||
|| value.currentTime > MAX_MEDIA_TIME) {
|
||||
return null;
|
||||
}
|
||||
if (value.mediaTitle !== undefined
|
||||
&& value.mediaTitle !== null
|
||||
&& typeof value.mediaTitle !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const normalized = {
|
||||
revision: value.revision,
|
||||
playbackState: value.playbackState,
|
||||
currentTime: value.currentTime
|
||||
};
|
||||
if (typeof value.updatedBy === 'string' && value.updatedBy) {
|
||||
normalized.updatedBy = value.updatedBy.substring(0, 16);
|
||||
}
|
||||
if (typeof value.mediaTitle === 'string' && value.mediaTitle) {
|
||||
normalized.mediaTitle = value.mediaTitle.substring(0, 100);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function canonicalMediaStateFromRoomData(roomData) {
|
||||
const capabilities = Array.isArray(roomData?.capabilities) ? roomData.capabilities : [];
|
||||
if (!capabilities.includes(CAPABILITIES.MEDIA_STATE_V1)) {
|
||||
return { status: 'unsupported', mediaState: null };
|
||||
}
|
||||
if (roomData.mediaState === null || roomData.mediaState === undefined) {
|
||||
return { status: 'empty', mediaState: null };
|
||||
}
|
||||
const mediaState = validateCanonicalMediaState(roomData.mediaState);
|
||||
return mediaState
|
||||
? { status: 'available', mediaState }
|
||||
: { status: 'invalid', mediaState: null };
|
||||
}
|
||||
|
||||
export function projectCanonicalMediaState(mediaState, receivedAt, now = Date.now()) {
|
||||
const validated = validateCanonicalMediaState(mediaState);
|
||||
if (!validated) return null;
|
||||
if (validated.playbackState !== 'playing') return validated;
|
||||
const received = typeof receivedAt === 'number' && Number.isFinite(receivedAt)
|
||||
? receivedAt
|
||||
: now;
|
||||
return {
|
||||
...validated,
|
||||
currentTime: Math.min(
|
||||
MAX_MEDIA_TIME,
|
||||
validated.currentTime + Math.max(0, now - received) / 1000
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRoomId(roomId) {
|
||||
return typeof roomId === 'string' && roomId ? roomId : null;
|
||||
}
|
||||
|
||||
export function createCanonicalMediaStateTracker() {
|
||||
let roomId = null;
|
||||
let knownRevision = 0;
|
||||
let appliedRevision = 0;
|
||||
let pending = null;
|
||||
|
||||
function adoptRoom(nextRoomId) {
|
||||
const normalizedRoomId = normalizeRoomId(nextRoomId);
|
||||
if (normalizedRoomId === roomId) return false;
|
||||
roomId = normalizedRoomId;
|
||||
knownRevision = 0;
|
||||
appliedRevision = 0;
|
||||
pending = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
adoptRoom,
|
||||
|
||||
beginRecovery(nextRoomId) {
|
||||
adoptRoom(nextRoomId);
|
||||
// A relay restart or an empty-room recreation starts a new in-memory
|
||||
// revision epoch. ROOM_DATA belongs to this fresh connection, so a
|
||||
// lower revision is current truth rather than a stale packet from
|
||||
// the previous epoch.
|
||||
knownRevision = 0;
|
||||
appliedRevision = 0;
|
||||
pending = null;
|
||||
},
|
||||
|
||||
receive(nextRoomId, value, receivedAt = Date.now()) {
|
||||
adoptRoom(nextRoomId);
|
||||
const mediaState = validateCanonicalMediaState(value);
|
||||
if (!roomId || !mediaState) return { status: 'invalid' };
|
||||
if (mediaState.revision < knownRevision) return { status: 'stale' };
|
||||
if (mediaState.revision === knownRevision
|
||||
&& (appliedRevision === mediaState.revision || pending?.mediaState.revision === mediaState.revision)) {
|
||||
return { status: 'duplicate' };
|
||||
}
|
||||
knownRevision = Math.max(knownRevision, mediaState.revision);
|
||||
pending = {
|
||||
roomId,
|
||||
mediaState,
|
||||
receivedAt: typeof receivedAt === 'number' && Number.isFinite(receivedAt)
|
||||
? receivedAt
|
||||
: Date.now()
|
||||
};
|
||||
return { status: 'pending', mediaState };
|
||||
},
|
||||
|
||||
getPending(nextRoomId = roomId) {
|
||||
return pending && pending.roomId === normalizeRoomId(nextRoomId)
|
||||
? { roomId: pending.roomId, mediaState: { ...pending.mediaState } }
|
||||
: null;
|
||||
},
|
||||
|
||||
getPendingProjected(nextRoomId = roomId, now = Date.now()) {
|
||||
if (!pending || pending.roomId !== normalizeRoomId(nextRoomId)) return null;
|
||||
const mediaState = projectCanonicalMediaState(
|
||||
pending.mediaState,
|
||||
pending.receivedAt,
|
||||
now
|
||||
);
|
||||
return mediaState ? { roomId: pending.roomId, mediaState } : null;
|
||||
},
|
||||
|
||||
markHandled(nextRoomId, revision) {
|
||||
if (normalizeRoomId(nextRoomId) !== roomId
|
||||
|| !Number.isSafeInteger(revision)
|
||||
|| revision < 1) {
|
||||
return false;
|
||||
}
|
||||
knownRevision = Math.max(knownRevision, revision);
|
||||
appliedRevision = Math.max(appliedRevision, revision);
|
||||
if (pending?.mediaState.revision <= revision) pending = null;
|
||||
return true;
|
||||
},
|
||||
|
||||
clear() {
|
||||
adoptRoom(null);
|
||||
},
|
||||
|
||||
restore(value, currentRoomId) {
|
||||
adoptRoom(currentRoomId);
|
||||
if (!value || typeof value !== 'object' || value.roomId !== roomId || !roomId) return false;
|
||||
knownRevision = Number.isSafeInteger(value.knownRevision) && value.knownRevision >= 0
|
||||
? value.knownRevision
|
||||
: 0;
|
||||
appliedRevision = Number.isSafeInteger(value.appliedRevision) && value.appliedRevision >= 0
|
||||
? Math.min(value.appliedRevision, knownRevision)
|
||||
: 0;
|
||||
const restoredPending = validateCanonicalMediaState(value.pending?.mediaState);
|
||||
if (value.pending?.roomId === roomId
|
||||
&& restoredPending
|
||||
&& restoredPending.revision > appliedRevision
|
||||
&& restoredPending.revision >= knownRevision) {
|
||||
pending = {
|
||||
roomId,
|
||||
mediaState: restoredPending,
|
||||
receivedAt: typeof value.pending.receivedAt === 'number'
|
||||
&& Number.isFinite(value.pending.receivedAt)
|
||||
? value.pending.receivedAt
|
||||
: Date.now()
|
||||
};
|
||||
knownRevision = restoredPending.revision;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
snapshot() {
|
||||
return {
|
||||
roomId,
|
||||
knownRevision,
|
||||
appliedRevision,
|
||||
pending: pending ? {
|
||||
roomId: pending.roomId,
|
||||
mediaState: { ...pending.mediaState },
|
||||
receivedAt: pending.receivedAt
|
||||
} : null
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
canonicalMediaStateFromRoomData,
|
||||
createCanonicalMediaStateTracker,
|
||||
projectCanonicalMediaState,
|
||||
validateCanonicalMediaState
|
||||
} from './canonical-media-state.js';
|
||||
|
||||
const state = (revision, currentTime = revision * 10, playbackState = 'playing') => ({
|
||||
revision,
|
||||
currentTime,
|
||||
playbackState,
|
||||
updatedBy: 'peer-a'
|
||||
});
|
||||
|
||||
describe('canonical media state validation', () => {
|
||||
it('accepts a bounded canonical snapshot', () => {
|
||||
expect(validateCanonicalMediaState(state(2))).toEqual(state(2));
|
||||
});
|
||||
|
||||
it('preserves an optional bounded media title and rejects invalid title types', () => {
|
||||
expect(validateCanonicalMediaState({ ...state(2), mediaTitle: 'Series S01E02' }))
|
||||
.toMatchObject({ mediaTitle: 'Series S01E02' });
|
||||
expect(validateCanonicalMediaState({ ...state(2), mediaTitle: 'x'.repeat(120) }).mediaTitle)
|
||||
.toHaveLength(100);
|
||||
expect(validateCanonicalMediaState({ ...state(2), mediaTitle: 42 })).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
[],
|
||||
{},
|
||||
{ revision: 0, playbackState: 'playing', currentTime: 1 },
|
||||
{ revision: 1.5, playbackState: 'playing', currentTime: 1 },
|
||||
{ revision: 1, playbackState: 'buffering', currentTime: 1 },
|
||||
{ revision: 1, playbackState: 'playing', currentTime: NaN },
|
||||
{ revision: 1, playbackState: 'playing', currentTime: Infinity },
|
||||
{ revision: 1, playbackState: 'playing', currentTime: -1 },
|
||||
{ revision: 1, playbackState: 'playing', currentTime: 86401 },
|
||||
{ revision: 1, playbackState: 'playing', currentTime: '1' }
|
||||
])('rejects malformed snapshot %#', value => {
|
||||
expect(validateCanonicalMediaState(value)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ROOM_DATA capability compatibility', () => {
|
||||
const legacyRoomData = {
|
||||
roomId: 'room-a',
|
||||
peers: [],
|
||||
activeLobby: null,
|
||||
hostPeerId: 'peer-a',
|
||||
controlMode: 'everyone',
|
||||
controllers: ['peer-a'],
|
||||
capabilities: ['host-control', 'co-host', 'chat-v1']
|
||||
};
|
||||
|
||||
it('treats an old relay without capability or mediaState as the unchanged fallback', () => {
|
||||
expect(canonicalMediaStateFromRoomData(legacyRoomData)).toEqual({
|
||||
status: 'unsupported',
|
||||
mediaState: null
|
||||
});
|
||||
expect(canonicalMediaStateFromRoomData({ ...legacyRoomData, capabilities: undefined })).toEqual({
|
||||
status: 'unsupported',
|
||||
mediaState: null
|
||||
});
|
||||
});
|
||||
|
||||
it('does not consume a stray mediaState unless the relay advertises support', () => {
|
||||
expect(canonicalMediaStateFromRoomData({ ...legacyRoomData, mediaState: state(4) })).toEqual({
|
||||
status: 'unsupported',
|
||||
mediaState: null
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts null as a valid capable-relay state without creating pending recovery', () => {
|
||||
expect(canonicalMediaStateFromRoomData({
|
||||
...legacyRoomData,
|
||||
capabilities: [...legacyRoomData.capabilities, 'media-state-v1'],
|
||||
mediaState: null
|
||||
})).toEqual({ status: 'empty', mediaState: null });
|
||||
});
|
||||
|
||||
it('returns a validated snapshot only for a capable relay', () => {
|
||||
expect(canonicalMediaStateFromRoomData({
|
||||
...legacyRoomData,
|
||||
capabilities: [...legacyRoomData.capabilities, 'media-state-v1'],
|
||||
mediaState: state(4)
|
||||
})).toEqual({ status: 'available', mediaState: state(4) });
|
||||
});
|
||||
});
|
||||
|
||||
describe('canonical media state tracker', () => {
|
||||
it('projects a deferred playing snapshot from local receipt time and clamps it', () => {
|
||||
expect(projectCanonicalMediaState(state(1, 100, 'playing'), 1_000, 31_000))
|
||||
.toMatchObject({ currentTime: 130, playbackState: 'playing' });
|
||||
expect(projectCanonicalMediaState(state(1, 100, 'paused'), 1_000, 31_000))
|
||||
.toMatchObject({ currentTime: 100, playbackState: 'paused' });
|
||||
expect(projectCanonicalMediaState(state(1, 86_390, 'playing'), 1_000, 31_000).currentTime)
|
||||
.toBe(86_400);
|
||||
});
|
||||
|
||||
it('accepts a valid snapshot and applies each revision once', () => {
|
||||
const tracker = createCanonicalMediaStateTracker();
|
||||
expect(tracker.receive('room-a', state(1)).status).toBe('pending');
|
||||
expect(tracker.markHandled('room-a', 1)).toBe(true);
|
||||
expect(tracker.receive('room-a', state(1)).status).toBe('duplicate');
|
||||
});
|
||||
|
||||
it('ignores stale revisions and lets a newer snapshot replace pending state', () => {
|
||||
const tracker = createCanonicalMediaStateTracker();
|
||||
tracker.receive('room-a', state(3));
|
||||
expect(tracker.receive('room-a', state(2)).status).toBe('stale');
|
||||
expect(tracker.receive('room-a', state(4)).status).toBe('pending');
|
||||
expect(tracker.getPending('room-a').mediaState).toEqual(state(4));
|
||||
});
|
||||
|
||||
it('never exposes room A state after switching to room B or leaving', () => {
|
||||
const tracker = createCanonicalMediaStateTracker();
|
||||
tracker.receive('room-a', state(5));
|
||||
tracker.adoptRoom('room-b');
|
||||
expect(tracker.getPending('room-a')).toBeNull();
|
||||
expect(tracker.getPending('room-b')).toBeNull();
|
||||
tracker.receive('room-b', state(1));
|
||||
tracker.clear();
|
||||
expect(tracker.snapshot()).toEqual({ roomId: null, knownRevision: 0, appliedRevision: 0, pending: null });
|
||||
});
|
||||
|
||||
it('allows the same revision once in a new reconnect recovery cycle', () => {
|
||||
const tracker = createCanonicalMediaStateTracker();
|
||||
tracker.receive('room-a', state(8, 80));
|
||||
tracker.markHandled('room-a', 8);
|
||||
tracker.beginRecovery('room-a');
|
||||
expect(tracker.receive('room-a', state(8, 100)).status).toBe('pending');
|
||||
expect(tracker.getPending().mediaState.currentTime).toBe(100);
|
||||
});
|
||||
|
||||
it('accepts a lower revision from a new relay or room epoch after reconnect', () => {
|
||||
const tracker = createCanonicalMediaStateTracker();
|
||||
tracker.receive('room-a', state(12), 1_000);
|
||||
tracker.markHandled('room-a', 12);
|
||||
tracker.beginRecovery('room-a');
|
||||
expect(tracker.receive('room-a', state(1), 2_000).status).toBe('pending');
|
||||
expect(tracker.getPending('room-a').mediaState.revision).toBe(1);
|
||||
});
|
||||
|
||||
it('persists receipt time so MV3 recovery projects only playing snapshots', () => {
|
||||
const first = createCanonicalMediaStateTracker();
|
||||
first.receive('room-a', state(4, 50, 'playing'), 10_000);
|
||||
const restored = createCanonicalMediaStateTracker();
|
||||
expect(restored.restore(first.snapshot(), 'room-a')).toBe(true);
|
||||
expect(restored.getPendingProjected('room-a', 15_000).mediaState.currentTime).toBe(55);
|
||||
});
|
||||
|
||||
it('restores only room-scoped session state', () => {
|
||||
const first = createCanonicalMediaStateTracker();
|
||||
first.receive('room-a', state(9));
|
||||
const stored = first.snapshot();
|
||||
|
||||
const sameRoom = createCanonicalMediaStateTracker();
|
||||
expect(sameRoom.restore(stored, 'room-a')).toBe(true);
|
||||
expect(sameRoom.getPending('room-a').mediaState.revision).toBe(9);
|
||||
|
||||
const otherRoom = createCanonicalMediaStateTracker();
|
||||
expect(otherRoom.restore(stored, 'room-b')).toBe(false);
|
||||
expect(otherRoom.getPending('room-b')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not resurrect a pending snapshot that was already applied', () => {
|
||||
const tracker = createCanonicalMediaStateTracker();
|
||||
expect(tracker.restore({
|
||||
roomId: 'room-a',
|
||||
knownRevision: 7,
|
||||
appliedRevision: 7,
|
||||
pending: {
|
||||
roomId: 'room-a',
|
||||
mediaState: state(7),
|
||||
receivedAt: 1_000
|
||||
}
|
||||
}, 'room-a')).toBe(true);
|
||||
expect(tracker.getPending('room-a')).toBeNull();
|
||||
});
|
||||
});
|
||||
+288
-9
@@ -83,6 +83,7 @@
|
||||
EPISODE_LOBBY: "episode_lobby",
|
||||
EPISODE_READY: "episode_ready"
|
||||
};
|
||||
const MAX_MEDIA_TIME = 86400;
|
||||
// --- SHARED_EVENTS_INJECT_END ---
|
||||
|
||||
// Suppresses native event reporting after a programmatic action.
|
||||
@@ -90,6 +91,9 @@
|
||||
// While a timer exists, matching native events are consumed and not relayed.
|
||||
// Timers self-clean after 300ms if the native event never fires.
|
||||
let _suppressTimers = {};
|
||||
const canonicalRestorePlaySuppressions = new Set();
|
||||
let canonicalMediaApplyGeneration = 0;
|
||||
let canonicalSupersedingLocalState = null;
|
||||
|
||||
function _setSuppress(state) {
|
||||
if (_suppressTimers[state]) clearTimeout(_suppressTimers[state]);
|
||||
@@ -105,6 +109,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
function holdCanonicalRestorePlaySuppression() {
|
||||
const hold = { timeout: null };
|
||||
hold.timeout = setTimeout(() => canonicalRestorePlaySuppressions.delete(hold), 5000);
|
||||
canonicalRestorePlaySuppressions.add(hold);
|
||||
return hold;
|
||||
}
|
||||
|
||||
function releaseCanonicalRestorePlaySuppression(hold) {
|
||||
if (!canonicalRestorePlaySuppressions.delete(hold)) return;
|
||||
clearTimeout(hold.timeout);
|
||||
}
|
||||
|
||||
function consumeCanonicalRestorePlaySuppression() {
|
||||
const hold = canonicalRestorePlaySuppressions.values().next().value;
|
||||
if (!hold) return false;
|
||||
releaseCanonicalRestorePlaySuppression(hold);
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Seek Relay Filtering ---
|
||||
// Minimum seek delta (seconds) to report. Prevents HLS/DASH buffering micro-seeks
|
||||
// from being relayed to peers as user-initiated seeks.
|
||||
@@ -1184,13 +1207,13 @@
|
||||
// --- Helper: site-specific player actions, then native HTML5 fallback ---
|
||||
function tryMediaAction(action, data) {
|
||||
const video = findVideo();
|
||||
if (!video) return;
|
||||
if (!video) return false;
|
||||
|
||||
if (action === EVENTS.SEEK) {
|
||||
const target = data ? (data.targetTime !== undefined ? data.targetTime : data.currentTime) : undefined;
|
||||
if (!Number.isFinite(target)) {
|
||||
reportLog(`Media Action Error: Invalid seek payload - ${JSON.stringify(data)}`, 'error');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
data = { ...data, targetTime: target };
|
||||
}
|
||||
@@ -1198,24 +1221,218 @@
|
||||
try {
|
||||
const actionFix = getActivePlayerActionFix();
|
||||
if (tryPlayerActionFix(actionFix, action, video, data)) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback for native HTML5
|
||||
if (action === EVENTS.PLAY) {
|
||||
_setSuppress('playing');
|
||||
video.play().catch((e) => {
|
||||
reportLog(`Playback prevented: ${e.message}`, 'warn');
|
||||
_clearSuppress('playing');
|
||||
});
|
||||
const playResult = video.play();
|
||||
if (playResult && typeof playResult.then === 'function') {
|
||||
return playResult.then(() => true).catch((e) => {
|
||||
reportLog(`Playback prevented: ${e.message}`, 'warn');
|
||||
_clearSuppress('playing');
|
||||
return false;
|
||||
});
|
||||
}
|
||||
return true;
|
||||
} else if (action === EVENTS.PAUSE) {
|
||||
_setSuppress('paused');
|
||||
video.pause();
|
||||
return true;
|
||||
} else if (action === EVENTS.SEEK) {
|
||||
seekVideo(video, data.targetTime, data.delta);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
return false;
|
||||
} catch (e) {
|
||||
reportLog(`Media Action Error: ${e.message}`, 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function beginCanonicalMediaApply() {
|
||||
canonicalMediaApplyGeneration++;
|
||||
canonicalSupersedingLocalState = null;
|
||||
return canonicalMediaApplyGeneration;
|
||||
}
|
||||
|
||||
function cancelCanonicalMediaApply(action = null, video = null, preserveLocalState = false, data = null) {
|
||||
canonicalMediaApplyGeneration++;
|
||||
const storesPlaybackIntent = action === EVENTS.PLAY
|
||||
|| action === EVENTS.PAUSE
|
||||
|| action === EVENTS.SEEK
|
||||
|| action === EVENTS.FORCE_SYNC_PREPARE
|
||||
|| action === EVENTS.FORCE_SYNC_EXECUTE;
|
||||
if (storesPlaybackIntent) {
|
||||
const payloadTime = Number.isFinite(data?.targetTime)
|
||||
? data.targetTime
|
||||
: (Number.isFinite(data?.currentTime) ? data.currentTime : null);
|
||||
const videoTime = video ? getSyncCurrentTime(video) : null;
|
||||
canonicalSupersedingLocalState = {
|
||||
playbackState: action === EVENTS.PLAY || action === EVENTS.FORCE_SYNC_EXECUTE
|
||||
? 'playing'
|
||||
: (action === EVENTS.PAUSE || action === EVENTS.FORCE_SYNC_PREPARE
|
||||
? 'paused'
|
||||
: (video ? (video.paused ? 'paused' : 'playing') : canonicalSupersedingLocalState?.playbackState)),
|
||||
currentTime: payloadTime ?? videoTime
|
||||
};
|
||||
} else if (!preserveLocalState) {
|
||||
canonicalSupersedingLocalState = null;
|
||||
}
|
||||
return canonicalMediaApplyGeneration;
|
||||
}
|
||||
|
||||
function isCanonicalMediaApplyCurrent(generation) {
|
||||
return !destroyed && generation === canonicalMediaApplyGeneration;
|
||||
}
|
||||
|
||||
async function restoreSupersedingLocalState(video) {
|
||||
const restorationGeneration = canonicalMediaApplyGeneration;
|
||||
const state = canonicalSupersedingLocalState;
|
||||
if (!state || !video || destroyed || video.isConnected === false) return;
|
||||
|
||||
if (state.playbackState === 'paused' && !video.paused) {
|
||||
_setSuppress('paused');
|
||||
video.pause();
|
||||
}
|
||||
if (Number.isFinite(state.currentTime)) {
|
||||
const currentTime = getSyncCurrentTime(video);
|
||||
if (currentTime === null || Math.abs(currentTime - state.currentTime) >= MIN_SEEK_DELTA) {
|
||||
_setSuppress('seek');
|
||||
seekVideo(video, state.currentTime);
|
||||
}
|
||||
}
|
||||
if (state.playbackState === 'playing' && video.paused) {
|
||||
_setSuppress('playing');
|
||||
const playSuppression = holdCanonicalRestorePlaySuppression();
|
||||
try {
|
||||
await video.play();
|
||||
} catch (error) {
|
||||
_clearSuppress('playing');
|
||||
reportLog(`Could not restore locally superseding playback: ${error.message}`, 'warn');
|
||||
} finally {
|
||||
releaseCanonicalRestorePlaySuppression(playSuppression);
|
||||
}
|
||||
}
|
||||
// A delayed play() can settle after an even newer command already ran.
|
||||
// Re-assert that newest intent so the old promise cannot win last.
|
||||
if (!destroyed && restorationGeneration !== canonicalMediaApplyGeneration) {
|
||||
await restoreSupersedingLocalState(video);
|
||||
}
|
||||
}
|
||||
|
||||
function pollCanonicalMediaState(mediaState, startedAt, applyGeneration, timeoutMs = 2500) {
|
||||
return new Promise((resolve) => {
|
||||
const interval = 100;
|
||||
const finishAt = Date.now() + timeoutMs;
|
||||
const timer = setInterval(() => {
|
||||
if (!isCanonicalMediaApplyCurrent(applyGeneration)) {
|
||||
clearInterval(timer);
|
||||
seekPollTimers.delete(timer);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const video = findVideo();
|
||||
const currentTime = video ? getSyncCurrentTime(video) : null;
|
||||
const projectedTime = mediaState.currentTime
|
||||
+ (mediaState.playbackState === 'playing'
|
||||
? Math.max(0, Date.now() - startedAt) / 1000
|
||||
: 0);
|
||||
const playbackMatches = video
|
||||
&& (mediaState.playbackState === 'playing' ? !video.paused : video.paused);
|
||||
const drift = currentTime === null ? null : projectedTime - currentTime;
|
||||
if (playbackMatches && drift !== null && Math.abs(drift) < MIN_SEEK_DELTA) {
|
||||
clearInterval(timer);
|
||||
seekPollTimers.delete(timer);
|
||||
resolve({ currentTime, drift });
|
||||
} else if (Date.now() >= finishAt) {
|
||||
clearInterval(timer);
|
||||
seekPollTimers.delete(timer);
|
||||
resolve(null);
|
||||
}
|
||||
}, interval);
|
||||
seekPollTimers.add(timer);
|
||||
});
|
||||
}
|
||||
|
||||
async function applyCanonicalMediaState(mediaState, applyGeneration) {
|
||||
if (!mediaState || typeof mediaState !== 'object'
|
||||
|| !Number.isSafeInteger(mediaState.revision) || mediaState.revision < 1
|
||||
|| (mediaState.playbackState !== 'playing' && mediaState.playbackState !== 'paused')
|
||||
|| typeof mediaState.currentTime !== 'number'
|
||||
|| !Number.isFinite(mediaState.currentTime)
|
||||
|| mediaState.currentTime < 0
|
||||
|| mediaState.currentTime > MAX_MEDIA_TIME
|
||||
|| (mediaState.mediaTitle !== undefined
|
||||
&& mediaState.mediaTitle !== null
|
||||
&& typeof mediaState.mediaTitle !== 'string')) {
|
||||
return { status: 'invalid' };
|
||||
}
|
||||
if (!isCanonicalMediaApplyCurrent(applyGeneration)) return { status: 'superseded' };
|
||||
if (hcmDesynced) return { status: 'ignored_desynced' };
|
||||
const localMediaTitle = getMediaTitle();
|
||||
if (_autoSyncEnabled && isDifferentEpisode(mediaState.mediaTitle, localMediaTitle)) {
|
||||
reportLog(`Canonical media state ignored: sender="${mediaState.mediaTitle || '?'}" vs mine="${localMediaTitle || '?'}"`, 'warn');
|
||||
return { status: 'ignored_episode_mismatch' };
|
||||
}
|
||||
|
||||
const video = findVideo();
|
||||
if (!video) return { status: 'no_video' };
|
||||
|
||||
const currentTime = getSyncCurrentTime(video);
|
||||
const drift = currentTime === null ? null : mediaState.currentTime - currentTime;
|
||||
const shouldSeek = drift === null || Math.abs(drift) >= MIN_SEEK_DELTA;
|
||||
const startedAt = Date.now();
|
||||
const superseded = async () => {
|
||||
await restoreSupersedingLocalState(video);
|
||||
return { status: 'superseded', revision: mediaState.revision };
|
||||
};
|
||||
|
||||
try {
|
||||
// Paused recovery pauses before seeking; playing recovery seeks before
|
||||
// starting. Both paths reuse the same site/page-API abstractions and
|
||||
// native-event suppression as ordinary remote commands.
|
||||
if (mediaState.playbackState === 'paused' && !video.paused) {
|
||||
const pauseApplied = await tryMediaAction(EVENTS.PAUSE);
|
||||
if (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded();
|
||||
if (!pauseApplied) {
|
||||
return { status: 'apply_failed', reason: 'pause_action_failed' };
|
||||
}
|
||||
}
|
||||
if (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded();
|
||||
if (shouldSeek) {
|
||||
_setSuppress('seek');
|
||||
const seekApplied = await tryMediaAction(EVENTS.SEEK, { targetTime: mediaState.currentTime });
|
||||
if (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded();
|
||||
if (!seekApplied) {
|
||||
return { status: 'apply_failed', reason: 'seek_action_failed' };
|
||||
}
|
||||
}
|
||||
if (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded();
|
||||
if (mediaState.playbackState === 'playing' && video.paused) {
|
||||
const playApplied = await tryMediaAction(EVENTS.PLAY);
|
||||
if (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded();
|
||||
if (!playApplied) {
|
||||
return { status: 'apply_failed', reason: 'play_action_failed' };
|
||||
}
|
||||
}
|
||||
const verified = await pollCanonicalMediaState(mediaState, startedAt, applyGeneration);
|
||||
if (!isCanonicalMediaApplyCurrent(applyGeneration)) return superseded();
|
||||
if (!verified) {
|
||||
reportLog(`Canonical media state r${mediaState.revision} could not be verified`, 'warn');
|
||||
return { status: 'apply_failed', reason: 'verification_timeout' };
|
||||
}
|
||||
scheduleProactiveHeartbeat();
|
||||
return {
|
||||
status: 'applied',
|
||||
revision: mediaState.revision,
|
||||
drift: verified.drift,
|
||||
sought: shouldSeek
|
||||
};
|
||||
} catch (error) {
|
||||
reportLog(`Canonical media state apply failed: ${error.message}`, 'warn');
|
||||
return { status: 'apply_failed' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1319,6 +1536,29 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'CANCEL_CANONICAL_MEDIA_STATE') {
|
||||
const preserveLocalState = message.reason === `local ${EVENTS.PLAY}`
|
||||
|| message.reason === `local ${EVENTS.PAUSE}`
|
||||
|| message.reason === `local ${EVENTS.SEEK}`;
|
||||
cancelCanonicalMediaApply(
|
||||
message.action,
|
||||
findVideo(),
|
||||
preserveLocalState,
|
||||
message.payload
|
||||
);
|
||||
sendResponse({ status: 'cancelled' });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'APPLY_CANONICAL_MEDIA_STATE') {
|
||||
const applyGeneration = beginCanonicalMediaApply();
|
||||
applyCanonicalMediaState(message.mediaState, applyGeneration).then(sendResponse).catch(error => {
|
||||
reportLog(`Canonical media state apply failed: ${error.message}`, 'warn');
|
||||
sendResponse({ status: 'apply_failed', reason: 'unexpected_error' });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'SERVER_COMMAND') {
|
||||
const { action, payload } = message;
|
||||
let actionCompleted = false;
|
||||
@@ -1355,6 +1595,10 @@
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (syncActions.includes(action)) {
|
||||
cancelCanonicalMediaApply(action, findVideo(), false, payload);
|
||||
}
|
||||
|
||||
if (action === EVENTS.PLAY) {
|
||||
tryMediaAction(EVENTS.PLAY);
|
||||
@@ -1664,6 +1908,11 @@
|
||||
_clearSuppress(eventState);
|
||||
return;
|
||||
}
|
||||
if (action === EVENTS.PLAY && consumeCanonicalRestorePlaySuppression()) return;
|
||||
|
||||
if (action === EVENTS.PLAY || action === EVENTS.PAUSE) {
|
||||
cancelCanonicalMediaApply(action, video);
|
||||
}
|
||||
|
||||
// Suppress only SEEK during visibility grace period (tab re-focus ghost jump).
|
||||
// Play/Pause pass through — user may want to immediately pause after tabbing back.
|
||||
@@ -1818,6 +2067,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// `seeking` normally captured the local state synchronously. Keep this
|
||||
// as a fallback for players that emit only `seeked`, without replacing
|
||||
// the earlier paused/playing state after an async stale action resolves.
|
||||
if (!canonicalSupersedingLocalState) {
|
||||
cancelCanonicalMediaApply(EVENTS.SEEK, video);
|
||||
}
|
||||
|
||||
// Step 4: Debounce rapid consecutive seeks (e.g. scrubbing)
|
||||
// — wait 300ms for the user to settle before relaying
|
||||
if (seekDebounceTimer) clearTimeout(seekDebounceTimer);
|
||||
@@ -1835,6 +2091,18 @@
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleSeeking = event => {
|
||||
if (!isCurrentVideoEvent(event)) return;
|
||||
const video = event.currentTarget;
|
||||
const current = getSyncCurrentTime(video);
|
||||
if (current === null) return;
|
||||
if (expectedSeekTime !== null && Math.abs(current - expectedSeekTime) < 1.0) return;
|
||||
if (Date.now() < visibilityGraceUntil) return;
|
||||
const delta = lastReportedSeekTime !== null ? Math.abs(current - lastReportedSeekTime) : null;
|
||||
if (delta !== null && delta < MIN_SEEK_DELTA) return;
|
||||
cancelCanonicalMediaApply(EVENTS.SEEK, video);
|
||||
};
|
||||
|
||||
|
||||
let lastVideoSrc = undefined;
|
||||
|
||||
@@ -1849,6 +2117,7 @@
|
||||
if (handlers) {
|
||||
video.removeEventListener('play', handlers.play);
|
||||
video.removeEventListener('pause', handlers.pause);
|
||||
if (handlers.seeking) video.removeEventListener('seeking', handlers.seeking);
|
||||
video.removeEventListener('seeked', handlers.seeked);
|
||||
video.removeEventListener('loadeddata', handlers.loadeddata);
|
||||
if (handlers.waiting) video.removeEventListener('waiting', handlers.waiting);
|
||||
@@ -1881,9 +2150,17 @@
|
||||
const existing = video._koalaHandlers;
|
||||
if (existing) detachVideoListeners(video);
|
||||
activeVideo = video;
|
||||
video._koalaHandlers = { play: handlePlay, pause: handlePause, seeked: handleSeeked, loadeddata: handleLoadedData, waiting: handleWaiting };
|
||||
video._koalaHandlers = {
|
||||
play: handlePlay,
|
||||
pause: handlePause,
|
||||
seeking: handleSeeking,
|
||||
seeked: handleSeeked,
|
||||
loadeddata: handleLoadedData,
|
||||
waiting: handleWaiting
|
||||
};
|
||||
video.addEventListener('play', handlePlay);
|
||||
video.addEventListener('pause', handlePause);
|
||||
video.addEventListener('seeking', handleSeeking);
|
||||
video.addEventListener('seeked', handleSeeked);
|
||||
video.addEventListener('loadeddata', handleLoadedData);
|
||||
video.addEventListener('waiting', handleWaiting);
|
||||
@@ -2096,6 +2373,8 @@
|
||||
|
||||
for (const timer of Object.values(_suppressTimers)) clearTimeout(timer);
|
||||
_suppressTimers = {};
|
||||
for (const hold of canonicalRestorePlaySuppressions) clearTimeout(hold.timeout);
|
||||
canonicalRestorePlaySuppressions.clear();
|
||||
for (const timer of lifecycleTimeouts) clearTimeout(timer);
|
||||
lifecycleTimeouts.clear();
|
||||
for (const timer of seekPollTimers) clearInterval(timer);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
|
||||
|
||||
function sourceBetween(startNeedle, endNeedle) {
|
||||
const start = backgroundSource.indexOf(startNeedle);
|
||||
const end = backgroundSource.indexOf(endNeedle, start + startNeedle.length);
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
return backgroundSource.slice(start, end);
|
||||
}
|
||||
|
||||
describe('episode lobby completion races', () => {
|
||||
it('does not read the cleared lobby after a remote ready completes it', () => {
|
||||
const handler = sourceBetween('case EVENTS.EPISODE_READY:', 'case EVENTS.EPISODE_LOBBY_CANCEL:');
|
||||
const snapshotIndex = handler.indexOf('const readyPeers = [...lobby.readyPeers]');
|
||||
const roomUpdateIndex = handler.indexOf('currentRoom.activeLobby.readyPeers = readyPeers');
|
||||
const completionIndex = handler.indexOf('checkEpisodeLobbyCompletion()');
|
||||
|
||||
expect(snapshotIndex).toBeGreaterThan(-1);
|
||||
expect(roomUpdateIndex).toBeGreaterThan(snapshotIndex);
|
||||
expect(completionIndex).toBeGreaterThan(roomUpdateIndex);
|
||||
expect(handler.slice(completionIndex)).not.toContain('episodeLobby.readyPeers');
|
||||
});
|
||||
|
||||
it('revalidates the lobby after awaiting settings for a local ready', () => {
|
||||
const handler = sourceBetween("message.type === 'EPISODE_READY_LOCAL'", "message.type === 'TITLE_PRIVACY_CHANGED'");
|
||||
const awaitIndex = handler.indexOf('const settings = await getSettings()');
|
||||
const guardIndex = handler.indexOf('if (!isCurrentLobbyContext() || settings.roomId !== lobbyRoomId)');
|
||||
const mutationIndex = handler.indexOf('lobby.readyPeers.push(peerId)');
|
||||
|
||||
expect(handler).toContain('const lobby = episodeLobby');
|
||||
expect(handler).toContain('const isCurrentLobbyContext = () => episodeLobby === lobby');
|
||||
expect(awaitIndex).toBeGreaterThan(-1);
|
||||
expect(guardIndex).toBeGreaterThan(awaitIndex);
|
||||
expect(mutationIndex).toBeGreaterThan(guardIndex);
|
||||
});
|
||||
|
||||
it('clears the persisted room lobby when completion starts Force Sync', () => {
|
||||
const execute = sourceBetween('function executeEpisodeLobby()', 'function checkEpisodeLobbyCompletion()');
|
||||
const roomClearIndex = execute.indexOf('currentRoom.activeLobby = null');
|
||||
const lobbyClearIndex = execute.indexOf('clearEpisodeLobbyState()');
|
||||
|
||||
expect(roomClearIndex).toBeGreaterThan(-1);
|
||||
expect(lobbyClearIndex).toBeGreaterThan(roomClearIndex);
|
||||
expect(execute).toContain('chrome.storage.session.set({ currentRoom })');
|
||||
});
|
||||
|
||||
it('validates restored and authoritative lobby state before using readyPeers', () => {
|
||||
expect(backgroundSource).toContain('function normalizeEpisodeLobby(value, fallbackCreatedAt = Date.now(), allowedPeerIds = null)');
|
||||
expect(backgroundSource).toContain('data.currentRoom.activeLobby,');
|
||||
expect(backgroundSource).toContain('const authoritativeLobby = normalizeEpisodeLobby(');
|
||||
expect(backgroundSource).toContain('new Set(currentRoom.peers.map(candidate => candidate.peerId))');
|
||||
});
|
||||
|
||||
it('rejects stale or unknown ready senders and correlates new ready frames to the lobby', () => {
|
||||
const remoteReady = sourceBetween('case EVENTS.EPISODE_READY:', 'case EVENTS.EPISODE_LOBBY_CANCEL:');
|
||||
expect(remoteReady).toContain('const senderPresent = currentRoom?.peers?.some');
|
||||
expect(remoteReady).toContain('!sameEpisode(data.expectedTitle, lobby.expectedTitle)');
|
||||
|
||||
const localReady = sourceBetween("message.type === 'EPISODE_READY_LOCAL'", "message.type === 'TITLE_PRIVACY_CHANGED'");
|
||||
expect(localReady).toContain('expectedTitle: lobby.expectedTitle');
|
||||
});
|
||||
|
||||
it('adopts authoritative correction data after the relay rejects a competing lobby', () => {
|
||||
const remoteLobby = sourceBetween('case EVENTS.EPISODE_LOBBY:', 'case EVENTS.EPISODE_READY:');
|
||||
expect(remoteLobby).toContain('data.authoritative === true && Array.isArray(data.readyPeers)');
|
||||
expect(remoteLobby).toContain('currentRoom.activeLobby = incomingLobby');
|
||||
});
|
||||
|
||||
it('counts only ready peers who still participate in lobby completion', () => {
|
||||
const completion = sourceBetween('function checkEpisodeLobbyCompletion()', 'function checkEpisodeLobbyPeerDeparture()');
|
||||
expect(completion).toContain('const participatingPeerIds = new Set(peers');
|
||||
expect(completion).toContain('participatingPeerIds.has(candidate)');
|
||||
expect(completion).toContain('readyParticipatingCount >= participatingPeerIds.size');
|
||||
});
|
||||
|
||||
it('re-evaluates or cancels a lobby when the local peer enters solo mode', () => {
|
||||
const desync = sourceBetween("message.type === 'HCM_DESYNC_STATE'", "message.type === 'LEAVE_ROOM'");
|
||||
expect(desync).toContain("cancelEpisodeLobby('Initiator entered solo mode')");
|
||||
expect(desync).toContain('checkEpisodeLobbyCompletion()');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
|
||||
|
||||
function functionBody(name, nextName) {
|
||||
const start = backgroundSource.indexOf(`function ${name}(`);
|
||||
const end = backgroundSource.indexOf(`function ${nextName}(`, start + 1);
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
return backgroundSource.slice(start, end === -1 ? backgroundSource.length : end);
|
||||
}
|
||||
|
||||
describe('offline media intent background integration', () => {
|
||||
it('keeps online sends immediate and defers reconnect work only while ROOM_DATA is pending', () => {
|
||||
const emit = functionBody('emit', 'emitLive');
|
||||
expect(emit).toContain('mustWaitForRoomData');
|
||||
expect(emit).toContain('&& !flushInProgress');
|
||||
expect(emit).toContain('socket.send(msg)');
|
||||
expect(emit).toContain('queueEvent(event, data)');
|
||||
expect(emit).not.toContain('setTimeout');
|
||||
expect(backgroundSource).toMatch(/awaitingRoomData = true;[\s\S]*emit\(EVENTS\.JOIN_ROOM/);
|
||||
});
|
||||
|
||||
it('restores and migrates the persisted MV3 queue with a monotonic local sequence', () => {
|
||||
expect(backgroundSource).toContain('normalizePersistedEventQueue(');
|
||||
expect(backgroundSource).toContain('localSeq = Math.max(localSeq, maxQueuedSequence(eventQueue))');
|
||||
expect(backgroundSource).toContain('chrome.storage.session.set({ eventQueue, localSeq })');
|
||||
expect(backgroundSource).not.toContain('storage.sync.set({ eventQueue');
|
||||
expect(backgroundSource).toContain('if (restorationTimedOut) return');
|
||||
});
|
||||
|
||||
it('reconciles Host Control, Episode Lobby and solo mode before canonical recovery and replay', () => {
|
||||
const roomDataStart = backgroundSource.indexOf('case EVENTS.ROOM_DATA:');
|
||||
const roomDataEnd = backgroundSource.indexOf('case EVENTS.CONTROL_MODE:', roomDataStart);
|
||||
const roomData = backgroundSource.slice(roomDataStart, roomDataEnd);
|
||||
const policyIndex = roomData.indexOf('applyQueuedRoomPolicy(data.roomId');
|
||||
const canonicalIndex = roomData.indexOf('handleCanonicalRoomData(data, queuePolicy.hasPendingLocalIntent)');
|
||||
const flushIndex = roomData.indexOf('flushEventQueue(replaySettings)');
|
||||
expect(policyIndex).toBeGreaterThan(-1);
|
||||
expect(canonicalIndex).toBeGreaterThan(-1);
|
||||
expect(flushIndex).toBeGreaterThan(-1);
|
||||
expect(policyIndex).toBeLessThan(canonicalIndex);
|
||||
expect(canonicalIndex).toBeLessThan(flushIndex);
|
||||
expect(roomData).toContain('activeLobby: !!episodeLobby');
|
||||
expect(roomData).toContain('desynced: hcmDesynced');
|
||||
expect(roomData).toContain('if (!authoritativeLobby && episodeLobby && !hasQueuedLocalLobby)');
|
||||
expect(roomData).toContain('authoritativeLobby: !!authoritativeLobby');
|
||||
});
|
||||
|
||||
it('clears queued room intent on failed join, leave and room switch paths', () => {
|
||||
expect(functionBody('clearFailedJoinCredentials', 'invalidateChatSession')).toContain('clearEventQueue()');
|
||||
expect(functionBody('forceDisconnect', 'persistRoomIdleState')).toContain('eventQueue = []');
|
||||
expect(functionBody('leaveOldRoomIfSwitching', 'resetAudioProcessingInTab')).toContain('forceDisconnect()');
|
||||
const leaveHandler = backgroundSource.slice(
|
||||
backgroundSource.indexOf("message.type === 'LEAVE_ROOM'"),
|
||||
backgroundSource.indexOf("message.type === 'CLEAR_LOGS'")
|
||||
);
|
||||
expect(leaveHandler).toContain("endRoomSession({ notifyServer: true, reason: 'Left Room' })");
|
||||
expect(functionBody('performRoomSessionTeardown', 'endRoomSession')).toContain('forceDisconnect()');
|
||||
const retryHandler = backgroundSource.slice(
|
||||
backgroundSource.indexOf("message.type === 'RETRY_CONNECT'"),
|
||||
backgroundSource.indexOf("message.type === 'GET_STATUS'")
|
||||
);
|
||||
expect(retryHandler).toContain('forceDisconnect({ preserveEventQueue: true })');
|
||||
});
|
||||
|
||||
it('paces actual frames through a failure-retaining logical drain', () => {
|
||||
const flush = functionBody('flushEventQueue', 'addToHistory');
|
||||
expect(flush).toContain('drainQueuedBatch(drainSource');
|
||||
expect(flush).toContain('maxWireEvents: FLUSH_BATCH_SIZE');
|
||||
expect(flush).toContain('return emitLive(frame.event, payload)');
|
||||
expect(flush).toContain('if (eventQueueVersion === drainVersion)');
|
||||
expect(flush).toContain('const consumedEntries = new Set(drainSource.slice(0, consumedCount))');
|
||||
expect(flush).toContain('eventQueue = eventQueue.filter(entry => !consumedEntries.has(entry))');
|
||||
expect(flush).toContain('flushConnectionGeneration !== connectionGeneration');
|
||||
expect(flush).not.toMatch(/eventQueue\.shift\(\)[\s\S]*emit\(/);
|
||||
});
|
||||
|
||||
it('generation-scopes socket callbacks and stale ROOM_DATA work', () => {
|
||||
expect(backgroundSource).toContain('let connectionGeneration = 0');
|
||||
expect(backgroundSource).toContain('socket !== connectionSocket');
|
||||
expect(backgroundSource).toContain('handleServerEvent(payload[0], payload[1], generation)');
|
||||
expect(backgroundSource).toContain('expectedConnectionGeneration !== connectionGeneration');
|
||||
expect(backgroundSource).toContain('data.roomId !== pendingRoomDataRoomId');
|
||||
});
|
||||
|
||||
it('exposes bounded queue diagnostics without media-title content', () => {
|
||||
expect(backgroundSource).toContain('queuedLogicalEvents: eventQueue.length');
|
||||
expect(backgroundSource).toContain('queuedMediaIntents: queuedMediaIntentCount');
|
||||
expect(backgroundSource).toContain('queuedWireEvents: queuedWireCount(eventQueue)');
|
||||
expect(backgroundSource).not.toMatch(/Offline media intent[^`'\n]*mediaTitle/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,508 @@
|
||||
import { EVENTS, MAX_MEDIA_TIME } from '../shared/constants.js';
|
||||
|
||||
export const MEDIA_INTENT_KIND = 'media-intent';
|
||||
export const MAX_LOGICAL_QUEUE_SIZE = 50;
|
||||
|
||||
const MEDIA_EVENTS = new Set([EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK]);
|
||||
const KNOWN_EVENTS = new Set(Object.values(EVENTS));
|
||||
const STALE_OFFLINE_EVENTS = new Set([EVENTS.PING, EVENTS.PONG, EVENTS.PEER_STATUS, EVENTS.EVENT_ACK]);
|
||||
const FORCE_SYNC_EVENTS = new Set([EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE]);
|
||||
const HOST_GATED_EVENTS = new Set([
|
||||
...MEDIA_EVENTS,
|
||||
...FORCE_SYNC_EVENTS,
|
||||
EVENTS.EPISODE_LOBBY,
|
||||
EVENTS.EPISODE_LOBBY_CANCEL
|
||||
]);
|
||||
|
||||
function validSequence(value) {
|
||||
return Number.isSafeInteger(value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
function validTimestamp(value) {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
function mediaTime(value) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
||||
return Math.max(0, Math.min(MAX_MEDIA_TIME, value));
|
||||
}
|
||||
|
||||
function playbackStateFor(event, data) {
|
||||
if (event === EVENTS.PLAY) return 'playing';
|
||||
if (event === EVENTS.PAUSE) return 'paused';
|
||||
return data?.playbackState === 'playing' || data?.playbackState === 'paused'
|
||||
? data.playbackState
|
||||
: null;
|
||||
}
|
||||
|
||||
function mediaPositionFor(event, data) {
|
||||
if (event === EVENTS.SEEK) {
|
||||
return mediaTime(data?.targetTime) ?? mediaTime(data?.currentTime);
|
||||
}
|
||||
return mediaTime(data?.currentTime);
|
||||
}
|
||||
|
||||
function sanitizedMediaTitle(value) {
|
||||
return typeof value === 'string' && value ? value.substring(0, 100) : null;
|
||||
}
|
||||
|
||||
function trimQueue(queue, maxEntries) {
|
||||
const trimmed = queue.slice();
|
||||
let dropped = 0;
|
||||
while (trimmed.length > maxEntries) {
|
||||
if (trimmed[0]?.event === EVENTS.FORCE_SYNC_PREPARE) {
|
||||
const executeIndex = trimmed.findIndex(entry => entry?.event === EVENTS.FORCE_SYNC_EXECUTE);
|
||||
if (executeIndex >= 0) {
|
||||
trimmed.splice(0, executeIndex + 1);
|
||||
dropped += executeIndex + 1;
|
||||
continue;
|
||||
}
|
||||
// Preserve an incomplete oldest Force Sync transaction. Evict the
|
||||
// next-oldest work until EXECUTE arrives, at which point the whole
|
||||
// transaction can be evicted atomically if pressure continues.
|
||||
if (trimmed.length > 1) {
|
||||
trimmed.splice(1, 1);
|
||||
dropped++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
trimmed.shift();
|
||||
dropped++;
|
||||
}
|
||||
return { queue: trimmed, dropped };
|
||||
}
|
||||
|
||||
function createIntentEntry(event, data, roomId) {
|
||||
const currentTime = mediaPositionFor(event, data);
|
||||
const playbackState = playbackStateFor(event, data);
|
||||
if (event === EVENTS.SEEK && currentTime === null) return null;
|
||||
if (playbackState === null && currentTime === null) return null;
|
||||
return {
|
||||
kind: MEDIA_INTENT_KIND,
|
||||
roomId,
|
||||
intent: {
|
||||
playbackState,
|
||||
currentTime,
|
||||
latestEvent: event,
|
||||
previousSeq: null,
|
||||
latestSeq: validSequence(data?.seq),
|
||||
actionTimestamp: validTimestamp(data?.actionTimestamp),
|
||||
mediaTitle: sanitizedMediaTitle(data?.mediaTitle),
|
||||
sourceEventCount: 1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function mergeIntentEntry(entry, event, data) {
|
||||
const incomingSeq = validSequence(data?.seq);
|
||||
const previousLatestSeq = validSequence(entry.intent.latestSeq);
|
||||
if (incomingSeq !== null && previousLatestSeq !== null && incomingSeq <= previousLatestSeq) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const incomingPosition = mediaPositionFor(event, data);
|
||||
if (event === EVENTS.SEEK && incomingPosition === null) return entry;
|
||||
const incomingState = playbackStateFor(event, data);
|
||||
const merged = {
|
||||
...entry,
|
||||
intent: {
|
||||
...entry.intent,
|
||||
playbackState: incomingState ?? entry.intent.playbackState,
|
||||
currentTime: incomingPosition ?? entry.intent.currentTime,
|
||||
latestEvent: event,
|
||||
actionTimestamp: validTimestamp(data?.actionTimestamp) ?? entry.intent.actionTimestamp,
|
||||
mediaTitle: sanitizedMediaTitle(data?.mediaTitle) ?? entry.intent.mediaTitle,
|
||||
sourceEventCount: entry.intent.sourceEventCount + 1
|
||||
}
|
||||
};
|
||||
if (incomingSeq !== null) {
|
||||
merged.intent.previousSeq = previousLatestSeq ?? validSequence(entry.intent.previousSeq);
|
||||
merged.intent.latestSeq = incomingSeq;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function isMediaQueueEvent(event) {
|
||||
return MEDIA_EVENTS.has(event);
|
||||
}
|
||||
|
||||
export function isQueuedMediaIntent(entry) {
|
||||
return entry?.kind === MEDIA_INTENT_KIND
|
||||
&& typeof entry.roomId === 'string'
|
||||
&& entry.roomId
|
||||
&& entry.intent
|
||||
&& typeof entry.intent === 'object';
|
||||
}
|
||||
|
||||
export function enqueueQueuedEvent(queue, event, data, {
|
||||
roomId,
|
||||
maxEntries = MAX_LOGICAL_QUEUE_SIZE
|
||||
} = {}) {
|
||||
const next = Array.isArray(queue) ? queue.slice() : [];
|
||||
let collapsed = 0;
|
||||
|
||||
if (STALE_OFFLINE_EVENTS.has(event)) {
|
||||
return { queue: next, collapsed: 0, dropped: 0, droppedStale: 1 };
|
||||
}
|
||||
if (!KNOWN_EVENTS.has(event)) {
|
||||
return { queue: next, collapsed: 0, dropped: 0, droppedStale: 1 };
|
||||
}
|
||||
if (!isMediaQueueEvent(event)) {
|
||||
next.push({
|
||||
kind: 'event',
|
||||
roomId: typeof roomId === 'string' && roomId ? roomId : null,
|
||||
event,
|
||||
data
|
||||
});
|
||||
} else if (typeof roomId === 'string' && roomId) {
|
||||
const last = next.at(-1);
|
||||
const hasMergeTarget = isQueuedMediaIntent(last) && last.roomId === roomId;
|
||||
const merged = hasMergeTarget
|
||||
? mergeIntentEntry(last, event, data)
|
||||
: null;
|
||||
if (merged) {
|
||||
next[next.length - 1] = merged;
|
||||
collapsed = 1;
|
||||
} else if (!hasMergeTarget) {
|
||||
const entry = createIntentEntry(event, data, roomId);
|
||||
if (entry) next.push(entry);
|
||||
} else {
|
||||
return { queue: next, collapsed: 0, dropped: 0, droppedStale: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
const trimmed = trimQueue(next, maxEntries);
|
||||
return { queue: trimmed.queue, collapsed, dropped: trimmed.dropped, droppedStale: 0 };
|
||||
}
|
||||
|
||||
function normalizeIntentEntry(entry, roomId) {
|
||||
if (!isQueuedMediaIntent(entry) || entry.roomId !== roomId) return null;
|
||||
const intent = entry.intent;
|
||||
const playbackState = intent.playbackState === 'playing' || intent.playbackState === 'paused'
|
||||
? intent.playbackState
|
||||
: null;
|
||||
const currentTime = mediaTime(intent.currentTime);
|
||||
const latestEvent = isMediaQueueEvent(intent.latestEvent) ? intent.latestEvent : null;
|
||||
if (!latestEvent || (playbackState === null && currentTime === null)) return null;
|
||||
return {
|
||||
kind: MEDIA_INTENT_KIND,
|
||||
roomId,
|
||||
intent: {
|
||||
playbackState,
|
||||
currentTime,
|
||||
latestEvent,
|
||||
previousSeq: validSequence(intent.previousSeq),
|
||||
latestSeq: validSequence(intent.latestSeq),
|
||||
actionTimestamp: validTimestamp(intent.actionTimestamp),
|
||||
mediaTitle: sanitizedMediaTitle(intent.mediaTitle),
|
||||
sourceEventCount: Number.isSafeInteger(intent.sourceEventCount) && intent.sourceEventCount > 0
|
||||
? intent.sourceEventCount
|
||||
: 1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function repairIntentSequences(entry, minimumSequence) {
|
||||
const repaired = {
|
||||
...entry,
|
||||
intent: { ...entry.intent }
|
||||
};
|
||||
const hasState = repaired.intent.playbackState !== null;
|
||||
const hasPosition = repaired.intent.currentTime !== null;
|
||||
const previousSeq = validSequence(repaired.intent.previousSeq);
|
||||
const latestSeq = validSequence(repaired.intent.latestSeq);
|
||||
if (hasState && hasPosition) {
|
||||
if (latestSeq === null) {
|
||||
repaired.intent.previousSeq = null;
|
||||
repaired.intent.latestSeq = minimumSequence + 1;
|
||||
} else if (latestSeq <= minimumSequence) {
|
||||
if (previousSeq === null) {
|
||||
repaired.intent.previousSeq = null;
|
||||
repaired.intent.latestSeq = minimumSequence + 1;
|
||||
} else {
|
||||
repaired.intent.previousSeq = minimumSequence + 1;
|
||||
repaired.intent.latestSeq = minimumSequence + 2;
|
||||
}
|
||||
} else if (previousSeq !== null
|
||||
&& (previousSeq >= latestSeq || previousSeq <= minimumSequence)) {
|
||||
repaired.intent.previousSeq = minimumSequence + 1;
|
||||
repaired.intent.latestSeq = Math.max(latestSeq, minimumSequence + 2);
|
||||
}
|
||||
} else if (latestSeq === null || latestSeq <= minimumSequence) {
|
||||
repaired.intent.previousSeq = null;
|
||||
repaired.intent.latestSeq = minimumSequence + 1;
|
||||
}
|
||||
return repaired;
|
||||
}
|
||||
|
||||
export function normalizePersistedEventQueue(value, roomId, maxEntries = MAX_LOGICAL_QUEUE_SIZE) {
|
||||
if (!Array.isArray(value) || typeof roomId !== 'string' || !roomId) return [];
|
||||
let normalized = [];
|
||||
let maximumSequence = 0;
|
||||
for (const entry of value) {
|
||||
if (isQueuedMediaIntent(entry)) {
|
||||
let intentEntry = normalizeIntentEntry(entry, roomId);
|
||||
if (intentEntry) {
|
||||
intentEntry = repairIntentSequences(intentEntry, maximumSequence);
|
||||
normalized.push(intentEntry);
|
||||
normalized = trimQueue(normalized, maxEntries).queue;
|
||||
maximumSequence = Math.max(maximumSequence, maxQueuedSequence([intentEntry]));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!entry || typeof entry !== 'object' || typeof entry.event !== 'string') continue;
|
||||
if (!KNOWN_EVENTS.has(entry.event)) continue;
|
||||
if (typeof entry.roomId === 'string' && entry.roomId && entry.roomId !== roomId) continue;
|
||||
if (STALE_OFFLINE_EVENTS.has(entry.event)) continue;
|
||||
const data = entry.data && typeof entry.data === 'object' ? { ...entry.data } : entry.data;
|
||||
const queuedSequence = validSequence(data?.seq);
|
||||
if (data && typeof data === 'object'
|
||||
&& ((isMediaQueueEvent(entry.event) && queuedSequence === null)
|
||||
|| (queuedSequence !== null && queuedSequence <= maximumSequence))) {
|
||||
data.seq = maximumSequence + 1;
|
||||
}
|
||||
if (isMediaQueueEvent(entry.event)) {
|
||||
normalized = enqueueQueuedEvent(normalized, entry.event, data, { roomId, maxEntries }).queue;
|
||||
} else {
|
||||
normalized.push({ kind: 'event', roomId, event: entry.event, data });
|
||||
}
|
||||
normalized = trimQueue(normalized, maxEntries).queue;
|
||||
maximumSequence = Math.max(maximumSequence, maxQueuedSequence(normalized));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function mediaIntentNeedsSequenceReservation(entry) {
|
||||
if (!isQueuedMediaIntent(entry)) return false;
|
||||
const { playbackState, currentTime, previousSeq, latestSeq } = entry.intent;
|
||||
return playbackState !== null
|
||||
&& currentTime !== null
|
||||
&& validSequence(latestSeq) !== null
|
||||
&& validSequence(previousSeq) === null;
|
||||
}
|
||||
|
||||
export function reserveLatestMediaIntentSequence(queue, roomId, nextSequence) {
|
||||
const next = Array.isArray(queue) ? queue.slice() : [];
|
||||
const index = next.length - 1;
|
||||
const entry = next[index];
|
||||
if (!isQueuedMediaIntent(entry)
|
||||
|| entry.roomId !== roomId
|
||||
|| !mediaIntentNeedsSequenceReservation(entry)
|
||||
|| validSequence(nextSequence) === null
|
||||
|| nextSequence <= entry.intent.latestSeq) {
|
||||
return { queue: next, reserved: false };
|
||||
}
|
||||
next[index] = {
|
||||
...entry,
|
||||
intent: {
|
||||
...entry.intent,
|
||||
previousSeq: entry.intent.latestSeq,
|
||||
latestSeq: nextSequence
|
||||
}
|
||||
};
|
||||
return { queue: next, reserved: true };
|
||||
}
|
||||
|
||||
function frameData(intent, seq, includeActionTimestamp = true) {
|
||||
const data = {};
|
||||
if (seq !== null) data.seq = seq;
|
||||
if (includeActionTimestamp && intent.actionTimestamp !== null) data.actionTimestamp = intent.actionTimestamp;
|
||||
if (intent.mediaTitle !== null) data.mediaTitle = intent.mediaTitle;
|
||||
return data;
|
||||
}
|
||||
|
||||
export function materializeMediaIntent(entry) {
|
||||
if (!isQueuedMediaIntent(entry)) return [];
|
||||
const intent = entry.intent;
|
||||
const currentTime = mediaTime(intent.currentTime);
|
||||
const playbackState = intent.playbackState === 'playing' || intent.playbackState === 'paused'
|
||||
? intent.playbackState
|
||||
: null;
|
||||
const latestSeq = validSequence(intent.latestSeq);
|
||||
const previousSeq = validSequence(intent.previousSeq);
|
||||
const stateEvent = playbackState === 'playing' ? EVENTS.PLAY : EVENTS.PAUSE;
|
||||
|
||||
if (playbackState === null && currentTime !== null) {
|
||||
return [{
|
||||
event: EVENTS.SEEK,
|
||||
data: { ...frameData(intent, latestSeq), currentTime, targetTime: currentTime }
|
||||
}];
|
||||
}
|
||||
if (playbackState !== null && currentTime === null) {
|
||||
return [{ event: stateEvent, data: frameData(intent, latestSeq) }];
|
||||
}
|
||||
if (playbackState === null || currentTime === null) return [];
|
||||
|
||||
// A previous-format single PLAY/PAUSE has only one reserved sequence. Keep
|
||||
// its original one-frame behavior during migration rather than inventing a
|
||||
// sequence that could overtake a later transactional barrier. In a current
|
||||
// two-frame intent, only the logical final frame carries actionTimestamp so
|
||||
// its helper frame cannot falsely acknowledge the final user action.
|
||||
if (previousSeq === null || latestSeq === null || previousSeq >= latestSeq) {
|
||||
if (intent.latestEvent === EVENTS.SEEK) {
|
||||
return [{
|
||||
event: EVENTS.SEEK,
|
||||
data: { ...frameData(intent, latestSeq), currentTime, targetTime: currentTime }
|
||||
}];
|
||||
}
|
||||
return [{
|
||||
event: stateEvent,
|
||||
data: { ...frameData(intent, latestSeq), currentTime }
|
||||
}];
|
||||
}
|
||||
|
||||
const seekFrame = {
|
||||
event: EVENTS.SEEK,
|
||||
data: {
|
||||
...frameData(
|
||||
intent,
|
||||
intent.latestEvent === EVENTS.SEEK ? latestSeq : previousSeq,
|
||||
intent.latestEvent === EVENTS.SEEK
|
||||
),
|
||||
currentTime,
|
||||
targetTime: currentTime
|
||||
}
|
||||
};
|
||||
const stateFrame = {
|
||||
event: stateEvent,
|
||||
data: {
|
||||
...frameData(
|
||||
intent,
|
||||
intent.latestEvent === EVENTS.SEEK ? previousSeq : latestSeq,
|
||||
intent.latestEvent !== EVENTS.SEEK
|
||||
),
|
||||
currentTime
|
||||
}
|
||||
};
|
||||
return intent.latestEvent === EVENTS.SEEK
|
||||
? [stateFrame, seekFrame]
|
||||
: [seekFrame, stateFrame];
|
||||
}
|
||||
|
||||
export function queuedEntryWireCount(entry) {
|
||||
return isQueuedMediaIntent(entry) ? materializeMediaIntent(entry).length : 1;
|
||||
}
|
||||
|
||||
export function queuedWireCount(queue) {
|
||||
return Array.isArray(queue)
|
||||
? queue.reduce((total, entry) => total + queuedEntryWireCount(entry), 0)
|
||||
: 0;
|
||||
}
|
||||
|
||||
export function queuedMediaIntentCount(queue, roomId = null) {
|
||||
return Array.isArray(queue)
|
||||
? queue.filter(entry => isQueuedMediaIntent(entry) && (!roomId || entry.roomId === roomId)).length
|
||||
: 0;
|
||||
}
|
||||
|
||||
export function hasQueuedMediaIntent(queue, roomId) {
|
||||
return queuedMediaIntentCount(queue, roomId) > 0;
|
||||
}
|
||||
|
||||
export function discardQueuedMediaIntents(queue, roomId) {
|
||||
return Array.isArray(queue)
|
||||
? queue.filter(entry => !isQueuedMediaIntent(entry) || entry.roomId !== roomId)
|
||||
: [];
|
||||
}
|
||||
|
||||
export function reconcileQueuedRoomIntent(queue, {
|
||||
roomId,
|
||||
canControl = true,
|
||||
activeLobby = false,
|
||||
desynced = false,
|
||||
authoritativeLobby = false
|
||||
} = {}) {
|
||||
const source = Array.isArray(queue) ? queue : [];
|
||||
const blockedEvents = !canControl
|
||||
? HOST_GATED_EVENTS
|
||||
: new Set([
|
||||
...(activeLobby || desynced ? FORCE_SYNC_EVENTS : []),
|
||||
...(authoritativeLobby ? [EVENTS.EPISODE_LOBBY, EVENTS.EPISODE_READY, EVENTS.EPISODE_LOBBY_CANCEL] : [])
|
||||
]);
|
||||
const reconciled = source.filter(entry => {
|
||||
if (entry?.roomId && entry.roomId !== roomId) return false;
|
||||
if (isQueuedMediaIntent(entry) && entry.roomId === roomId) {
|
||||
return canControl && !activeLobby && !desynced;
|
||||
}
|
||||
return !blockedEvents?.has(entry?.event);
|
||||
});
|
||||
const hasPendingLocalIntent = reconciled.some(entry =>
|
||||
(isQueuedMediaIntent(entry) && entry.roomId === roomId)
|
||||
|| (!isQueuedMediaIntent(entry) && FORCE_SYNC_EVENTS.has(entry?.event))
|
||||
);
|
||||
return {
|
||||
queue: reconciled,
|
||||
discarded: source.length - reconciled.length,
|
||||
hasPendingLocalIntent
|
||||
};
|
||||
}
|
||||
|
||||
export function maxQueuedSequence(queue) {
|
||||
let max = 0;
|
||||
for (const entry of Array.isArray(queue) ? queue : []) {
|
||||
if (isQueuedMediaIntent(entry)) {
|
||||
max = Math.max(max, validSequence(entry.intent.previousSeq) ?? 0, validSequence(entry.intent.latestSeq) ?? 0);
|
||||
} else {
|
||||
max = Math.max(max, validSequence(entry?.data?.seq) ?? 0);
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
export async function drainQueuedBatch(queue, {
|
||||
roomId,
|
||||
maxWireEvents,
|
||||
sendFrame
|
||||
}) {
|
||||
const remaining = Array.isArray(queue) ? queue.slice() : [];
|
||||
let sentWireEvents = 0;
|
||||
let droppedStaleIntents = 0;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
const entry = remaining[0];
|
||||
if (entry?.roomId && entry.roomId !== roomId) {
|
||||
remaining.shift();
|
||||
droppedStaleIntents++;
|
||||
continue;
|
||||
}
|
||||
const deliveryEntries = entry?.event === EVENTS.FORCE_SYNC_PREPARE
|
||||
&& remaining[1]?.event === EVENTS.FORCE_SYNC_EXECUTE
|
||||
? remaining.slice(0, 2)
|
||||
: [entry];
|
||||
const frames = deliveryEntries.flatMap(deliveryEntry => {
|
||||
const deliveryFrames = isQueuedMediaIntent(deliveryEntry)
|
||||
? materializeMediaIntent(deliveryEntry)
|
||||
: [{ event: deliveryEntry.event, data: deliveryEntry.data }];
|
||||
return deliveryFrames.map(frame => ({ frame, entry: deliveryEntry }));
|
||||
});
|
||||
if (frames.length === 0) {
|
||||
remaining.splice(0, deliveryEntries.length);
|
||||
continue;
|
||||
}
|
||||
if (sentWireEvents > 0 && sentWireEvents + frames.length > maxWireEvents) {
|
||||
return { queue: remaining, sentWireEvents, droppedStaleIntents, status: 'batch_full' };
|
||||
}
|
||||
if (frames.length > maxWireEvents) {
|
||||
return { queue: remaining, sentWireEvents, droppedStaleIntents, status: 'entry_exceeds_batch' };
|
||||
}
|
||||
|
||||
let sentEntryFrames = 0;
|
||||
for (const { frame, entry: deliveryEntry } of frames) {
|
||||
if (!await sendFrame(frame, deliveryEntry)) {
|
||||
return {
|
||||
queue: remaining,
|
||||
sentWireEvents: sentWireEvents + sentEntryFrames,
|
||||
droppedStaleIntents,
|
||||
status: 'send_failed'
|
||||
};
|
||||
}
|
||||
sentEntryFrames++;
|
||||
}
|
||||
sentWireEvents += sentEntryFrames;
|
||||
remaining.splice(0, deliveryEntries.length);
|
||||
if (sentWireEvents >= maxWireEvents) {
|
||||
return { queue: remaining, sentWireEvents, droppedStaleIntents, status: 'batch_full' };
|
||||
}
|
||||
}
|
||||
return { queue: remaining, sentWireEvents, droppedStaleIntents, status: 'drained' };
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { EVENTS, MAX_MEDIA_TIME } from '../shared/constants.js';
|
||||
import { canonicalMediaStateFromRoomData } from './canonical-media-state.js';
|
||||
import {
|
||||
discardQueuedMediaIntents,
|
||||
drainQueuedBatch,
|
||||
enqueueQueuedEvent,
|
||||
hasQueuedMediaIntent,
|
||||
materializeMediaIntent,
|
||||
maxQueuedSequence,
|
||||
normalizePersistedEventQueue,
|
||||
queuedEntryWireCount,
|
||||
queuedMediaIntentCount,
|
||||
queuedWireCount,
|
||||
reconcileQueuedRoomIntent,
|
||||
reserveLatestMediaIntentSequence
|
||||
} from './offline-media-intent.js';
|
||||
|
||||
const roomId = 'room-a';
|
||||
const media = (event, data, queue = []) => enqueueQueuedEvent(queue, event, data, { roomId }).queue;
|
||||
|
||||
function reserve(queue, sequence) {
|
||||
return reserveLatestMediaIntentSequence(queue, roomId, sequence).queue;
|
||||
}
|
||||
|
||||
describe('offline media intent coalescing', () => {
|
||||
it('normalizes an empty or roomless persisted queue to empty', () => {
|
||||
expect(normalizePersistedEventQueue([], roomId)).toEqual([]);
|
||||
expect(normalizePersistedEventQueue([{ event: EVENTS.PLAY, data: { seq: 1 } }], null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('creates a PLAY intent and reserves ordered legacy SEEK + PLAY frames', () => {
|
||||
let queue = media(EVENTS.PLAY, { currentTime: 10, seq: 5, actionTimestamp: 100 });
|
||||
queue = reserve(queue, 6);
|
||||
expect(queue).toHaveLength(1);
|
||||
expect(materializeMediaIntent(queue[0])).toEqual([
|
||||
{ event: EVENTS.SEEK, data: { seq: 5, currentTime: 10, targetTime: 10 } },
|
||||
{ event: EVENTS.PLAY, data: { seq: 6, actionTimestamp: 100, currentTime: 10 } }
|
||||
]);
|
||||
});
|
||||
|
||||
it('collapses repeated PLAY and SEEK while preserving final state and monotonic sequences', () => {
|
||||
let queue = reserve(media(EVENTS.PLAY, { currentTime: 10, seq: 1 }), 2);
|
||||
queue = media(EVENTS.PLAY, { currentTime: 20, seq: 3 }, queue);
|
||||
queue = media(EVENTS.SEEK, { targetTime: 30, seq: 4 }, queue);
|
||||
expect(queue).toHaveLength(1);
|
||||
expect(queue[0].intent).toMatchObject({
|
||||
playbackState: 'playing',
|
||||
currentTime: 30,
|
||||
latestEvent: EVENTS.SEEK,
|
||||
previousSeq: 3,
|
||||
latestSeq: 4,
|
||||
sourceEventCount: 3
|
||||
});
|
||||
expect(materializeMediaIntent(queue[0]).map(frame => frame.data.seq)).toEqual([3, 4]);
|
||||
});
|
||||
|
||||
it('drops a regressing queued sequence instead of emitting stale ordering', () => {
|
||||
let queue = reserve(media(EVENTS.PLAY, { currentTime: 10, seq: 10 }), 11);
|
||||
const result = enqueueQueuedEvent(queue, EVENTS.PAUSE, { currentTime: 20, seq: 9 }, { roomId });
|
||||
expect(result.droppedStale).toBe(1);
|
||||
expect(result.queue).toEqual(queue);
|
||||
expect(maxQueuedSequence(result.queue)).toBe(11);
|
||||
});
|
||||
|
||||
it('merges PLAY -> SEEK -> PAUSE into paused at the final position', () => {
|
||||
let queue = reserve(media(EVENTS.PLAY, { currentTime: 500, seq: 1 }), 2);
|
||||
queue = media(EVENTS.SEEK, { targetTime: 600, seq: 3 }, queue);
|
||||
queue = media(EVENTS.PAUSE, { currentTime: 605, seq: 4 }, queue);
|
||||
expect(materializeMediaIntent(queue[0])).toEqual([
|
||||
{ event: EVENTS.SEEK, data: { seq: 3, currentTime: 605, targetTime: 605 } },
|
||||
{ event: EVENTS.PAUSE, data: { seq: 4, currentTime: 605 } }
|
||||
]);
|
||||
});
|
||||
|
||||
it('merges PAUSE -> SEEK -> PLAY into playing at the final position', () => {
|
||||
let queue = reserve(media(EVENTS.PAUSE, { currentTime: 100, seq: 10 }), 11);
|
||||
queue = media(EVENTS.SEEK, { targetTime: 200, seq: 12 }, queue);
|
||||
queue = media(EVENTS.PLAY, { currentTime: 205, seq: 13 }, queue);
|
||||
expect(materializeMediaIntent(queue[0]).map(frame => frame.event)).toEqual([EVENTS.SEEK, EVENTS.PLAY]);
|
||||
expect(queue[0].intent).toMatchObject({ playbackState: 'playing', currentTime: 205 });
|
||||
});
|
||||
|
||||
it('clamps finite positions and rejects a SEEK with no trustworthy position', () => {
|
||||
expect(media(EVENTS.SEEK, { targetTime: NaN, seq: 1 })).toEqual([]);
|
||||
expect(media(EVENTS.SEEK, { targetTime: -50, seq: 1 })[0].intent.currentTime).toBe(0);
|
||||
expect(media(EVENTS.SEEK, { targetTime: MAX_MEDIA_TIME + 50, seq: 1 })[0].intent.currentTime).toBe(MAX_MEDIA_TIME);
|
||||
});
|
||||
|
||||
it('keeps title metadata bounded and never retains arbitrary payload fields', () => {
|
||||
const queue = media(EVENTS.PAUSE, {
|
||||
currentTime: 10,
|
||||
seq: 1,
|
||||
mediaTitle: 'x'.repeat(200),
|
||||
password: 'secret',
|
||||
chatKey: 'secret'
|
||||
});
|
||||
expect(queue[0].intent.mediaTitle).toHaveLength(100);
|
||||
expect(JSON.stringify(queue)).not.toContain('password');
|
||||
expect(JSON.stringify(queue)).not.toContain('chatKey');
|
||||
});
|
||||
|
||||
it('keeps a thousand-event media burst at one logical entry', () => {
|
||||
let queue = reserve(media(EVENTS.PLAY, { currentTime: 0, seq: 1 }), 2);
|
||||
for (let index = 1; index <= 1000; index++) {
|
||||
queue = media(EVENTS.SEEK, { targetTime: index, seq: index + 2 }, queue);
|
||||
}
|
||||
queue = media(EVENTS.PAUSE, { currentTime: 1000, seq: 1003 }, queue);
|
||||
expect(queue).toHaveLength(1);
|
||||
expect(queue[0].intent).toMatchObject({ playbackState: 'paused', currentTime: 1000, sourceEventCount: 1002 });
|
||||
});
|
||||
|
||||
it('treats retained coordination events as barriers', () => {
|
||||
let queue = reserve(media(EVENTS.PLAY, { currentTime: 10, seq: 1 }), 2);
|
||||
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_PREPARE, { targetTime: 50, seq: 3 }, { roomId }).queue;
|
||||
queue = media(EVENTS.SEEK, { targetTime: 100, seq: 4 }, queue);
|
||||
queue = media(EVENTS.PAUSE, { currentTime: 120, seq: 5 }, queue);
|
||||
expect(queue).toHaveLength(3);
|
||||
expect(queue[0].kind).toBe('media-intent');
|
||||
expect(queue[1].event).toBe(EVENTS.FORCE_SYNC_PREPARE);
|
||||
expect(queue[2].kind).toBe('media-intent');
|
||||
expect(queue[2].intent).toMatchObject({ playbackState: 'paused', currentTime: 120 });
|
||||
});
|
||||
|
||||
it('does not persist stale liveness and command ACK frames as ordering barriers', () => {
|
||||
let queue = reserve(media(EVENTS.PLAY, { currentTime: 10, seq: 1 }), 2);
|
||||
for (const event of [EVENTS.PING, EVENTS.PONG, EVENTS.PEER_STATUS, EVENTS.EVENT_ACK]) {
|
||||
const result = enqueueQueuedEvent(queue, event, { seq: 99 }, { roomId });
|
||||
expect(result.droppedStale).toBe(1);
|
||||
queue = result.queue;
|
||||
}
|
||||
queue = media(EVENTS.PAUSE, { currentTime: 20, seq: 3 }, queue);
|
||||
expect(queue).toHaveLength(1);
|
||||
expect(queue[0].intent).toMatchObject({ playbackState: 'paused', currentTime: 20 });
|
||||
});
|
||||
|
||||
it('preserves the bounded logical queue cap', () => {
|
||||
let queue = [];
|
||||
for (let index = 0; index < 60; index++) {
|
||||
queue = enqueueQueuedEvent(queue, EVENTS.EPISODE_READY, { index }, { roomId }).queue;
|
||||
}
|
||||
expect(queue).toHaveLength(50);
|
||||
expect(queue[0].data.index).toBe(10);
|
||||
expect(queue.at(-1).data.index).toBe(59);
|
||||
});
|
||||
|
||||
it('migrates old raw media entries without crossing transactional barriers', () => {
|
||||
const restored = normalizePersistedEventQueue([
|
||||
{ event: EVENTS.PLAY, data: { currentTime: 10, seq: 1 } },
|
||||
{ event: EVENTS.SEEK, data: { targetTime: 20, seq: 2 } },
|
||||
{ event: EVENTS.PAUSE, data: { currentTime: 25, seq: 3 } },
|
||||
{ event: EVENTS.EPISODE_LOBBY, data: { expectedTitle: 'S01E02' } },
|
||||
{ event: EVENTS.PLAY, data: { currentTime: 30, seq: 4 } }
|
||||
], roomId);
|
||||
expect(restored).toHaveLength(3);
|
||||
expect(restored[0].intent).toMatchObject({ playbackState: 'paused', currentTime: 25 });
|
||||
expect(restored[1].event).toBe(EVENTS.EPISODE_LOBBY);
|
||||
expect(restored[2].intent).toMatchObject({ playbackState: 'playing', currentTime: 30 });
|
||||
expect(maxQueuedSequence(restored)).toBe(4);
|
||||
});
|
||||
|
||||
it('enforces the cap while restoring consecutive persisted media intents', () => {
|
||||
const persisted = [10, 20, 30].map((currentTime, index) => ({
|
||||
kind: 'media-intent',
|
||||
roomId,
|
||||
intent: {
|
||||
playbackState: 'paused',
|
||||
currentTime,
|
||||
latestEvent: EVENTS.PAUSE,
|
||||
previousSeq: null,
|
||||
latestSeq: index + 1,
|
||||
actionTimestamp: index + 1,
|
||||
mediaTitle: null,
|
||||
sourceEventCount: 1
|
||||
}
|
||||
}));
|
||||
const restored = normalizePersistedEventQueue(persisted, roomId, 2);
|
||||
expect(restored).toHaveLength(2);
|
||||
expect(restored.map(entry => entry.intent.currentTime)).toEqual([20, 30]);
|
||||
});
|
||||
|
||||
it('drops room-scoped barriers from another room and unknown persisted events', () => {
|
||||
const restored = normalizePersistedEventQueue([
|
||||
{ kind: 'event', roomId: 'room-b', event: EVENTS.FORCE_SYNC_EXECUTE, data: { seq: 1 } },
|
||||
{ kind: 'event', roomId, event: 'unexpected_event', data: { secret: 'nope' } },
|
||||
{ kind: 'event', roomId, event: EVENTS.EPISODE_READY, data: { seq: 2 } }
|
||||
], roomId);
|
||||
expect(restored).toEqual([{
|
||||
kind: 'event', roomId, event: EVENTS.EPISODE_READY, data: { seq: 2 }
|
||||
}]);
|
||||
});
|
||||
|
||||
it('discards stale-room intent without affecting the new room', () => {
|
||||
const queue = reserve(media(EVENTS.PAUSE, { currentTime: 500, seq: 1 }), 2);
|
||||
expect(hasQueuedMediaIntent(queue, roomId)).toBe(true);
|
||||
expect(discardQueuedMediaIntents(queue, roomId)).toEqual([]);
|
||||
expect(hasQueuedMediaIntent(queue, 'room-b')).toBe(false);
|
||||
});
|
||||
|
||||
it('drops queued shared intent after controller role loss so canonical recovery can proceed', () => {
|
||||
let queue = reserve(media(EVENTS.PAUSE, { currentTime: 1200, seq: 1 }), 2);
|
||||
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_PREPARE, { targetTime: 1300, seq: 3 }, { roomId }).queue;
|
||||
const result = reconcileQueuedRoomIntent(queue, { roomId, canControl: false });
|
||||
expect(result.queue).toEqual([]);
|
||||
expect(result.discarded).toBe(2);
|
||||
expect(result.hasPendingLocalIntent).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps an active Episode Lobby authoritative over queued media and Force Sync', () => {
|
||||
let queue = reserve(media(EVENTS.PLAY, { currentTime: 100, seq: 1 }), 2);
|
||||
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_PREPARE, { targetTime: 500, seq: 3 }, { roomId }).queue;
|
||||
queue = enqueueQueuedEvent(queue, EVENTS.EPISODE_READY, { title: 'S01E02' }, { roomId }).queue;
|
||||
const result = reconcileQueuedRoomIntent(queue, { roomId, activeLobby: true });
|
||||
expect(result.queue).toEqual([{
|
||||
kind: 'event',
|
||||
roomId,
|
||||
event: EVENTS.EPISODE_READY,
|
||||
data: { title: 'S01E02' }
|
||||
}]);
|
||||
expect(result.hasPendingLocalIntent).toBe(false);
|
||||
});
|
||||
|
||||
it('drops stale queued Episode Lobby coordination when ROOM_DATA has an authoritative lobby', () => {
|
||||
let queue = enqueueQueuedEvent([], EVENTS.EPISODE_LOBBY, { expectedTitle: 'S02E01' }, { roomId }).queue;
|
||||
queue = enqueueQueuedEvent(queue, EVENTS.EPISODE_READY, { title: 'S02E01' }, { roomId }).queue;
|
||||
queue = enqueueQueuedEvent(queue, EVENTS.EPISODE_LOBBY_CANCEL, {}, { roomId }).queue;
|
||||
const result = reconcileQueuedRoomIntent(queue, {
|
||||
roomId,
|
||||
activeLobby: true,
|
||||
authoritativeLobby: true
|
||||
});
|
||||
expect(result.queue).toEqual([]);
|
||||
expect(result.discarded).toBe(3);
|
||||
});
|
||||
|
||||
it('does not let intentional solo mode retain future room-driving intent', () => {
|
||||
const queue = reserve(media(EVENTS.SEEK, { targetTime: 600, playbackState: 'paused', seq: 1 }), 2);
|
||||
const result = reconcileQueuedRoomIntent(queue, { roomId, desynced: true });
|
||||
expect(result.queue).toEqual([]);
|
||||
expect(result.hasPendingLocalIntent).toBe(false);
|
||||
});
|
||||
|
||||
it('materializes legacy events normally when an old relay has no media-state capability', () => {
|
||||
expect(canonicalMediaStateFromRoomData({
|
||||
roomId,
|
||||
capabilities: ['host-control', 'chat-v1']
|
||||
})).toEqual({ status: 'unsupported', mediaState: null });
|
||||
const queue = reserve(media(EVENTS.PAUSE, { currentTime: 75, seq: 20 }), 21);
|
||||
expect(materializeMediaIntent(queue[0]).map(frame => frame.event)).toEqual([EVENTS.SEEK, EVENTS.PAUSE]);
|
||||
expect(materializeMediaIntent(queue[0]).every(frame =>
|
||||
frame.data.mediaState === undefined && frame.data.revision === undefined
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('offline media intent drain', () => {
|
||||
it('counts actual wire frames and never splits an intent at a batch boundary', async () => {
|
||||
let queue = enqueueQueuedEvent([], EVENTS.EPISODE_READY, { seq: 1 }, { roomId }).queue;
|
||||
queue = reserve(media(EVENTS.PAUSE, { currentTime: 50, seq: 2 }, queue), 3);
|
||||
const sent = [];
|
||||
const first = await drainQueuedBatch(queue, {
|
||||
roomId,
|
||||
maxWireEvents: 2,
|
||||
sendFrame: async frame => { sent.push(frame); return true; }
|
||||
});
|
||||
expect(first.sentWireEvents).toBe(1);
|
||||
expect(first.queue).toHaveLength(1);
|
||||
expect(sent.map(frame => frame.event)).toEqual([EVENTS.EPISODE_READY]);
|
||||
|
||||
const second = await drainQueuedBatch(first.queue, {
|
||||
roomId,
|
||||
maxWireEvents: 2,
|
||||
sendFrame: async frame => { sent.push(frame); return true; }
|
||||
});
|
||||
expect(second.sentWireEvents).toBe(2);
|
||||
expect(second.queue).toEqual([]);
|
||||
expect(sent.slice(1).map(frame => frame.event)).toEqual([EVENTS.SEEK, EVENTS.PAUSE]);
|
||||
});
|
||||
|
||||
it('retains the whole logical intent after a partial send failure', async () => {
|
||||
const queue = reserve(media(EVENTS.PLAY, { currentTime: 90, seq: 5, actionTimestamp: 500 }), 6);
|
||||
let calls = 0;
|
||||
const result = await drainQueuedBatch(queue, {
|
||||
roomId,
|
||||
maxWireEvents: 10,
|
||||
sendFrame: async () => ++calls === 1
|
||||
});
|
||||
expect(result.status).toBe('send_failed');
|
||||
expect(result.sentWireEvents).toBe(1);
|
||||
expect(result.queue).toEqual(queue);
|
||||
expect(materializeMediaIntent(result.queue[0]).map(frame => frame.data.seq)).toEqual([5, 6]);
|
||||
expect(materializeMediaIntent(result.queue[0]).map(frame => frame.data.actionTimestamp))
|
||||
.toEqual([undefined, 500]);
|
||||
});
|
||||
|
||||
it('drops stale-room intent during drain and preserves unrelated events', async () => {
|
||||
let queue = reserve(media(EVENTS.PAUSE, { currentTime: 40, seq: 1 }), 2);
|
||||
queue = enqueueQueuedEvent(queue, EVENTS.EPISODE_READY, { seq: 3 }, { roomId }).queue;
|
||||
const sent = [];
|
||||
const result = await drainQueuedBatch(queue, {
|
||||
roomId: 'room-b',
|
||||
maxWireEvents: 10,
|
||||
sendFrame: async frame => { sent.push(frame); return true; }
|
||||
});
|
||||
expect(result.droppedStaleIntents).toBe(2);
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it('repairs regressing sequences across malformed persisted intent entries', () => {
|
||||
const restored = normalizePersistedEventQueue([
|
||||
{
|
||||
kind: 'media-intent',
|
||||
roomId,
|
||||
intent: {
|
||||
playbackState: 'playing', currentTime: 10, latestEvent: EVENTS.PLAY,
|
||||
previousSeq: 99, latestSeq: 100, actionTimestamp: 1, mediaTitle: null, sourceEventCount: 1
|
||||
}
|
||||
},
|
||||
{
|
||||
kind: 'media-intent',
|
||||
roomId,
|
||||
intent: {
|
||||
playbackState: 'paused', currentTime: 20, latestEvent: EVENTS.PAUSE,
|
||||
previousSeq: 49, latestSeq: 50, actionTimestamp: 2, mediaTitle: null, sourceEventCount: 1
|
||||
}
|
||||
}
|
||||
], roomId);
|
||||
expect(materializeMediaIntent(restored[0]).map(frame => frame.data.seq)).toEqual([99, 100]);
|
||||
expect(materializeMediaIntent(restored[1]).map(frame => frame.data.seq)).toEqual([101, 102]);
|
||||
expect(maxQueuedSequence(restored)).toBe(102);
|
||||
});
|
||||
|
||||
it('preserves a valid legacy single-frame intent during sequence repair', () => {
|
||||
const restored = normalizePersistedEventQueue([{
|
||||
kind: 'media-intent',
|
||||
roomId,
|
||||
intent: {
|
||||
playbackState: 'paused', currentTime: 20, latestEvent: EVENTS.PAUSE,
|
||||
previousSeq: null, latestSeq: 50, actionTimestamp: 2, mediaTitle: null, sourceEventCount: 1
|
||||
}
|
||||
}], roomId);
|
||||
expect(materializeMediaIntent(restored[0])).toEqual([{
|
||||
event: EVENTS.PAUSE,
|
||||
data: { seq: 50, actionTimestamp: 2, currentTime: 20 }
|
||||
}]);
|
||||
});
|
||||
|
||||
it('evicts a complete Force Sync transaction instead of orphaning EXECUTE at the cap', () => {
|
||||
let queue = enqueueQueuedEvent([], EVENTS.FORCE_SYNC_PREPARE, { targetTime: 100, seq: 1 }, { roomId }).queue;
|
||||
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_EXECUTE, { seq: 2 }, { roomId }).queue;
|
||||
for (let index = 0; index < 49; index++) {
|
||||
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_ACK, { seq: index + 3 }, { roomId }).queue;
|
||||
}
|
||||
expect(queue).toHaveLength(49);
|
||||
expect(queue.some(entry => entry.event === EVENTS.FORCE_SYNC_PREPARE)).toBe(false);
|
||||
expect(queue.some(entry => entry.event === EVENTS.FORCE_SYNC_EXECUTE)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps adjacent Force Sync PREPARE and EXECUTE in the same replay batch', async () => {
|
||||
let queue = [];
|
||||
for (let index = 0; index < 9; index++) {
|
||||
queue = enqueueQueuedEvent(queue, EVENTS.EPISODE_READY, { seq: index + 1 }, { roomId }).queue;
|
||||
}
|
||||
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_PREPARE, { targetTime: 100, seq: 10 }, { roomId }).queue;
|
||||
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_EXECUTE, { seq: 11 }, { roomId }).queue;
|
||||
const sent = [];
|
||||
const result = await drainQueuedBatch(queue, {
|
||||
roomId,
|
||||
maxWireEvents: 10,
|
||||
sendFrame: async frame => { sent.push(frame.event); return true; }
|
||||
});
|
||||
expect(sent).toEqual(Array(9).fill(EVENTS.EPISODE_READY));
|
||||
expect(result.queue.map(entry => entry.event)).toEqual([
|
||||
EVENTS.FORCE_SYNC_PREPARE,
|
||||
EVENTS.FORCE_SYNC_EXECUTE
|
||||
]);
|
||||
});
|
||||
|
||||
it('retains the full Force Sync transaction if EXECUTE replay fails', async () => {
|
||||
let queue = enqueueQueuedEvent([], EVENTS.FORCE_SYNC_PREPARE, { targetTime: 100, seq: 1 }, { roomId }).queue;
|
||||
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_EXECUTE, { seq: 2 }, { roomId }).queue;
|
||||
const result = await drainQueuedBatch(queue, {
|
||||
roomId,
|
||||
maxWireEvents: 10,
|
||||
sendFrame: async frame => frame.event !== EVENTS.FORCE_SYNC_EXECUTE
|
||||
});
|
||||
expect(result.status).toBe('send_failed');
|
||||
expect(result.sentWireEvents).toBe(1);
|
||||
expect(result.queue).toEqual(queue);
|
||||
});
|
||||
|
||||
it('reports logical and actual-wire queue sizes separately', () => {
|
||||
let queue = reserve(media(EVENTS.PLAY, { currentTime: 10, seq: 1 }), 2);
|
||||
queue = enqueueQueuedEvent(queue, EVENTS.FORCE_SYNC_PREPARE, { seq: 3 }, { roomId }).queue;
|
||||
expect(queuedMediaIntentCount(queue, roomId)).toBe(1);
|
||||
expect(queuedEntryWireCount(queue[0])).toBe(2);
|
||||
expect(queuedWireCount(queue)).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -99,8 +99,8 @@ describe('target tab lifecycle', () => {
|
||||
});
|
||||
|
||||
it('routes every terminal room exit through the full target unhook', () => {
|
||||
const teardownStart = backgroundSource.indexOf('async function endRoomSession');
|
||||
const teardownEnd = backgroundSource.indexOf('async function leaveRoomAfterIdleGrace', teardownStart);
|
||||
const teardownStart = backgroundSource.indexOf('async function performRoomSessionTeardown');
|
||||
const teardownEnd = backgroundSource.indexOf('async function endRoomSession', teardownStart);
|
||||
const teardownSource = backgroundSource.slice(teardownStart, teardownEnd);
|
||||
expect(teardownSource).toContain('await deactivateTargetTab(currentTabId, currentContentTarget())');
|
||||
expect(teardownSource.indexOf('await deactivateTargetTab(currentTabId, currentContentTarget())'))
|
||||
@@ -116,11 +116,17 @@ describe('target tab lifecycle', () => {
|
||||
expect(backgroundSource).toContain("data.message === 'Removed from room after inactivity'");
|
||||
expect(backgroundSource).toContain('await endRoomSession({ reason: `Room session ended: ${data.message}` });');
|
||||
|
||||
const controlModeStart = backgroundSource.indexOf('case EVENTS.CONTROL_MODE:');
|
||||
const controlModeEnd = backgroundSource.indexOf('case EVENTS.ROOM_LIST:', controlModeStart);
|
||||
expect(backgroundSource.slice(controlModeStart, controlModeEnd)).toContain('if (!currentRoom) break;');
|
||||
|
||||
expect(sharedConstantsSource).toContain("ROOM_CLOSED: 'room_closed'");
|
||||
expect(sharedConstantsSource).toContain("PEER_TIMED_OUT: 'peer_timed_out'");
|
||||
expect(serverSource).toContain('code: ERROR_CODES.ROOM_CLOSED');
|
||||
expect(serverSource).toContain('code: ERROR_CODES.PEER_TIMED_OUT');
|
||||
expect(serverSource).toContain("removePeerFromRoom(sid, roomId, 'room-timeout')");
|
||||
expect(serverSource).toContain(
|
||||
"removePeerFromRoom(sid, roomId, 'room-timeout', { notifyRemainingPeers: false })"
|
||||
);
|
||||
});
|
||||
|
||||
it('does not promote a nested media target without confirmed parent visibility', () => {
|
||||
|
||||
@@ -107,6 +107,7 @@ function copyExtensionFiles(targetDir, browserName) {
|
||||
// Robust Extraction using flexible regex
|
||||
const eventsMatch = constantsContent.match(/export const EVENTS\s*=\s*({[\s\S]+?});/);
|
||||
const heartbeatMatch = constantsContent.match(/export const HEARTBEAT_INTERVAL\s*=\s*(\d+);/);
|
||||
const maxMediaTimeMatch = constantsContent.match(/export const MAX_MEDIA_TIME\s*=\s*(\d+);/);
|
||||
|
||||
if (!eventsMatch) {
|
||||
throw new Error('CRITICAL: Could not find EVENTS object in shared/constants.js');
|
||||
@@ -114,9 +115,13 @@ function copyExtensionFiles(targetDir, browserName) {
|
||||
if (!heartbeatMatch) {
|
||||
throw new Error('CRITICAL: Could not find HEARTBEAT_INTERVAL in shared/constants.js');
|
||||
}
|
||||
if (!maxMediaTimeMatch) {
|
||||
throw new Error('CRITICAL: Could not find MAX_MEDIA_TIME in shared/constants.js');
|
||||
}
|
||||
|
||||
const eventsObject = eventsMatch[1];
|
||||
const heartbeatVal = heartbeatMatch[1];
|
||||
const maxMediaTimeVal = maxMediaTimeMatch[1];
|
||||
|
||||
const items = fs.readdirSync(extDir);
|
||||
for (const item of items) {
|
||||
@@ -136,7 +141,7 @@ function copyExtensionFiles(targetDir, browserName) {
|
||||
const eStart = '// --- SHARED_EVENTS_INJECT_START ---';
|
||||
const eEnd = '// --- SHARED_EVENTS_INJECT_END ---';
|
||||
const ePattern = new RegExp(`${eStart}[\\s\\S]+?${eEnd}`);
|
||||
const eRep = `${eStart}\n // This block is automatically updated by /scripts/build-extension.cjs\n const EVENTS = ${eventsObject};\n ${eEnd}`;
|
||||
const eRep = `${eStart}\n // This block is automatically updated by /scripts/build-extension.cjs\n const EVENTS = ${eventsObject};\n const MAX_MEDIA_TIME = ${maxMediaTimeVal};\n ${eEnd}`;
|
||||
|
||||
content = replaceRequiredBlock(content, ePattern, eRep, 'Event injection');
|
||||
|
||||
@@ -184,6 +189,14 @@ function copyExtensionFiles(targetDir, browserName) {
|
||||
|
||||
fs.writeFileSync(destPath, content);
|
||||
console.log(`✓ Injected uninstall URL constants for ${browserName} into background.js`);
|
||||
} else if (item === 'canonical-media-state.js' || item === 'offline-media-intent.js') {
|
||||
let content = fs.readFileSync(srcPath, 'utf8');
|
||||
const sourceImport = "from '../shared/constants.js'";
|
||||
if (!content.includes(sourceImport)) {
|
||||
throw new Error(`CRITICAL: Source shared constants import missing in ${item}. Aborting build.`);
|
||||
}
|
||||
content = content.replace(sourceImport, "from './shared/constants.js'");
|
||||
fs.writeFileSync(destPath, content);
|
||||
} else if (item === 'popup.html') {
|
||||
let content = fs.readFileSync(srcPath, 'utf8');
|
||||
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
export const VITEST_COVERAGE_INCLUDE = Object.freeze([
|
||||
'server/chat.js',
|
||||
'server/media-state.js',
|
||||
'server/ops.js',
|
||||
'server/rate-limiter.js',
|
||||
'shared/blacklist.js',
|
||||
'shared/invite-links.js',
|
||||
'shared/names.js',
|
||||
'extension/canonical-media-state.js',
|
||||
'extension/chat-activity.js',
|
||||
'extension/chat-crypto.js',
|
||||
'extension/chat-format.js',
|
||||
@@ -13,6 +15,7 @@ export const VITEST_COVERAGE_INCLUDE = Object.freeze([
|
||||
'extension/episode-utils.js',
|
||||
'extension/host-access.js',
|
||||
'extension/media-frame-target.js',
|
||||
'extension/offline-media-intent.js',
|
||||
'extension/title-privacy.js',
|
||||
'scripts/release-artifact-checks.mjs'
|
||||
]);
|
||||
|
||||
+29
-17
@@ -1,5 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -15,67 +13,81 @@ export function replaceExactly(text, pattern, replacement, label) {
|
||||
return text.replace(pattern, replacement);
|
||||
}
|
||||
|
||||
function writeJson(root, relativePath, update) {
|
||||
function stageJson(stagedUpdates, root, relativePath, update) {
|
||||
const absolutePath = path.join(root, relativePath);
|
||||
const value = JSON.parse(fs.readFileSync(absolutePath, 'utf8'));
|
||||
const current = stagedUpdates.has(absolutePath)
|
||||
? stagedUpdates.get(absolutePath)
|
||||
: fs.readFileSync(absolutePath, 'utf8');
|
||||
const value = JSON.parse(current);
|
||||
update(value);
|
||||
fs.writeFileSync(absolutePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
||||
stagedUpdates.set(absolutePath, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function updateText(root, relativePath, pattern, replacement, label) {
|
||||
function stageText(stagedUpdates, root, relativePath, pattern, replacement, label) {
|
||||
const absolutePath = path.join(root, relativePath);
|
||||
const current = fs.readFileSync(absolutePath, 'utf8');
|
||||
fs.writeFileSync(absolutePath, replaceExactly(current, pattern, replacement, label), 'utf8');
|
||||
const current = stagedUpdates.has(absolutePath)
|
||||
? stagedUpdates.get(absolutePath)
|
||||
: fs.readFileSync(absolutePath, 'utf8');
|
||||
stagedUpdates.set(absolutePath, replaceExactly(current, pattern, replacement, label));
|
||||
}
|
||||
|
||||
export function prepareRelease(version, date = new Date(), root = repoRoot) {
|
||||
versionFromTag(`v${version}`);
|
||||
const timestamp = date.toISOString().replace(/\.\d{3}Z$/u, 'Z');
|
||||
writeJson(root, 'package.json', value => { value.version = version; });
|
||||
writeJson(root, 'package-lock.json', value => {
|
||||
const stagedUpdates = new Map();
|
||||
stageJson(stagedUpdates, root, 'package.json', value => { value.version = version; });
|
||||
stageJson(stagedUpdates, root, 'package-lock.json', value => {
|
||||
value.version = version;
|
||||
value.packages[''].version = version;
|
||||
});
|
||||
writeJson(root, 'extension/manifest.base.json', value => { value.version = version; });
|
||||
writeJson(root, 'website/version.json', value => {
|
||||
stageJson(stagedUpdates, root, 'extension/manifest.base.json', value => { value.version = version; });
|
||||
stageJson(stagedUpdates, root, 'website/version.json', value => {
|
||||
value.version = version;
|
||||
value.date = timestamp;
|
||||
});
|
||||
updateText(
|
||||
stageText(
|
||||
stagedUpdates,
|
||||
root,
|
||||
'shared/constants.js',
|
||||
/export const APP_VERSION = ["'][^"']+["'];/gu,
|
||||
`export const APP_VERSION = "${version}";`,
|
||||
'shared/constants.js'
|
||||
);
|
||||
updateText(
|
||||
stageText(
|
||||
stagedUpdates,
|
||||
root,
|
||||
'website/template.html',
|
||||
/"softwareVersion": "[^"]+"/gu,
|
||||
`"softwareVersion": "${version}"`,
|
||||
'website/template.html'
|
||||
);
|
||||
updateText(
|
||||
stageText(
|
||||
stagedUpdates,
|
||||
root,
|
||||
'website/llms.txt',
|
||||
/Current website release: .+/gu,
|
||||
`Current website release: ${version}`,
|
||||
'website/llms.txt'
|
||||
);
|
||||
updateText(
|
||||
stageText(
|
||||
stagedUpdates,
|
||||
root,
|
||||
'README.md',
|
||||
/Release-v\d+\.\d+\.\d+-blue/gu,
|
||||
`Release-v${version}-blue`,
|
||||
'README.md release badge'
|
||||
);
|
||||
updateText(
|
||||
stageText(
|
||||
stagedUpdates,
|
||||
root,
|
||||
'README.md',
|
||||
/New v\d+\.\d+\.\d+ Release!/gu,
|
||||
`New v${version} Release!`,
|
||||
'README.md release banner'
|
||||
);
|
||||
for (const [absolutePath, content] of stagedUpdates) {
|
||||
fs.writeFileSync(absolutePath, content, 'utf8');
|
||||
}
|
||||
console.log(`Prepared release v${version} at ${timestamp}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -87,4 +87,16 @@ describe('release preparation helpers', () => {
|
||||
.toThrow('vMAJOR.MINOR.PATCH');
|
||||
expect(readFixture(root)).toEqual(before);
|
||||
});
|
||||
|
||||
it('does not partially update release sources when a later marker is invalid', () => {
|
||||
const root = createReleaseFixture();
|
||||
const llmsPath = path.join(root, 'website/llms.txt');
|
||||
fs.writeFileSync(llmsPath, fs.readFileSync(llmsPath, 'utf8')
|
||||
.replace(/Current website release: .+/u, 'Release marker intentionally missing'), 'utf8');
|
||||
const before = readFixture(root);
|
||||
|
||||
expect(() => prepareRelease('9.8.7', new Date('2030-01-01T00:00:00Z'), root))
|
||||
.toThrow('website/llms.txt must contain exactly one release-version marker');
|
||||
expect(readFixture(root)).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
+581
-11
@@ -5,6 +5,12 @@ import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import { connectionCounts, clearRateLimitMaps } from '../server/rate-limiter.js';
|
||||
import {
|
||||
enqueueQueuedEvent,
|
||||
materializeMediaIntent,
|
||||
reserveLatestMediaIntentSequence
|
||||
} from '../extension/offline-media-intent.js';
|
||||
import { FORCE_SYNC_TARGET_DELAY_WARNING, FORCE_SYNC_TIMEOUT } from '../shared/constants.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(path.join(__dirname, '..', 'server', 'package.json'));
|
||||
@@ -22,11 +28,14 @@ async function c() {
|
||||
}
|
||||
function s(ws, evt, d={}) { ws.send(`42${JSON.stringify([evt,d])}`); }
|
||||
function a(ws) { if (ws._m.length) { const r=ws._m.shift(); return r.startsWith('42') ? JSON.parse(r.substring(2)) : r; } return new Promise((resolve, reject) => { const t=setTimeout(()=>reject(Error('timeout')),3e3); const h=(d)=>{clearTimeout(t);ws.removeListener('message',h);const r=d.toString();resolve(r.startsWith('42')?JSON.parse(r.substring(2)):r);};ws.on('message',h);}); }
|
||||
async function w(ws, evt, ms=3000) { const st=Date.now(); while(Date.now()-st<ms) { for(let i=0;i<ws._m.length;i++){const r=ws._m[i];ws._m.splice(i,1);if(r.startsWith('42')){try{const[e]=JSON.parse(r.substring(2));if(e===evt)return e}catch{/* skip */}}} await new Promise(r=>setTimeout(r,50));} throw Error(`wait:${evt}`); }
|
||||
async function w(ws, evt, ms=3000) { const st=Date.now(); while(Date.now()-st<ms) { for(let i=0;i<ws._m.length;i++){const r=ws._m[i];ws._m.splice(i,1);if(r.startsWith('42')){try{const[e,d]=JSON.parse(r.substring(2));if(e===evt)return d}catch{/* skip */}}} await new Promise(r=>setTimeout(r,50));} throw Error(`wait:${evt}`); }
|
||||
async function j(ws, rid, pid, pw=null, clientCapabilities=undefined) {
|
||||
s(ws,'join_room',{roomId:rid,peerId:pid,password:pw,protocolVersion:'1.0.0',clientCapabilities});
|
||||
assert.equal((await a(ws))[0],'room_data');
|
||||
const [event, data] = await a(ws);
|
||||
assert.equal(event,'room_data');
|
||||
return data;
|
||||
}
|
||||
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
function close() { clients.forEach(w=>{try{w.close()}catch{/* ignore */}}); clients.length=0; }
|
||||
// Test suite opens >10 connections/min — clear the IP connection counter so the
|
||||
// connection rate limiter doesn't mask test failures (test-only, never at runtime).
|
||||
@@ -40,10 +49,11 @@ try {
|
||||
|
||||
// --- Pool: 2 peers in 1 room, test everything ---
|
||||
const rid = 't-'+Date.now();
|
||||
const p1 = await c(), p2 = await c();
|
||||
const p1 = await c(), p2 = await c(), p3 = await c();
|
||||
|
||||
// Room + join
|
||||
await j(p1, rid, 'a'); await j(p2, rid, 'b'); p1._m.length = p2._m.length = 0;
|
||||
await j(p1, rid, 'a'); await j(p2, rid, 'b'); await j(p3, rid, 'c');
|
||||
p1._m.length = p2._m.length = p3._m.length = 0;
|
||||
|
||||
// Relay
|
||||
s(p1,'play',{currentTime:10}); await w(p2,'play');
|
||||
@@ -59,7 +69,33 @@ try {
|
||||
s(p2,'event_ack',{targetId:'a',actionTimestamp:Date.now()}); await w(p1,'event_ack');
|
||||
|
||||
// Lobby
|
||||
s(p1,'episode_lobby',{expectedTitle:'S01E01'}); await w(p2,'episode_lobby');
|
||||
s(p1,'episode_lobby',{expectedTitle:'S01E01'});
|
||||
await w(p2,'episode_lobby'); await w(p3,'episode_lobby');
|
||||
s(p3,'episode_lobby',{expectedTitle:'S01E02'});
|
||||
const authoritativeLobby = await w(p3, 'episode_lobby');
|
||||
assert.equal(authoritativeLobby.authoritative, true,
|
||||
'competing initiator receives an authoritative lobby correction');
|
||||
assert.equal(authoritativeLobby.expectedTitle, 'S01E01');
|
||||
assert.deepEqual(authoritativeLobby.readyPeers, ['a']);
|
||||
let competingLobbyDropped = false;
|
||||
try { await w(p2, 'episode_lobby', 500); } catch { competingLobbyDropped = true; }
|
||||
assert.ok(competingLobbyDropped, 'relay drops a competing lobby while one is active');
|
||||
assert.equal(mod.rooms.get(rid).activeLobby.expectedTitle, 'S01E01');
|
||||
|
||||
s(p3,'episode_ready',{expectedTitle:'S01E02',title:'S01E02'});
|
||||
let staleReadyDropped = false;
|
||||
try { await w(p2, 'episode_ready', 500); } catch { staleReadyDropped = true; }
|
||||
assert.ok(staleReadyDropped, 'relay drops ready frames for an obsolete lobby');
|
||||
assert.deepEqual(mod.rooms.get(rid).activeLobby.readyPeers, ['a']);
|
||||
|
||||
// Missing expectedTitle remains accepted for old-extension compatibility.
|
||||
s(p2,'episode_ready',{title:'S01E01'}); await w(p1,'episode_ready');
|
||||
assert.deepEqual(mod.rooms.get(rid).activeLobby.readyPeers, ['a', 'b']);
|
||||
|
||||
s(p3,'leave_room',{}); await w(p1,'peer_status');
|
||||
assert.equal(mod.rooms.get(rid).activeLobby.expectedTitle, 'S01E01',
|
||||
'an unrelated departure does not dissolve a lobby with two peers left');
|
||||
p1._m.length = p2._m.length = 0;
|
||||
|
||||
// Leave
|
||||
s(p1,'leave_room',{}); const [ev,d]=await a(p2); assert.equal(ev,'peer_status');assert.equal(d.status,'left');
|
||||
@@ -67,6 +103,68 @@ try {
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- Combined reconnect model: compacted new-client intent over legacy wire ---
|
||||
// The queue is entirely client-side. Feed its materialized PLAY/PAUSE/SEEK
|
||||
// frames through the real relay and an old-client-like receiver to prove the
|
||||
// optimization needs no new event, capability or ACK.
|
||||
const coalescedRid = 'coalesced-media-'+Date.now();
|
||||
const legacyReceiver = await c(), coalescingSender = await c();
|
||||
await j(legacyReceiver, coalescedRid, 'legacy-receiver');
|
||||
await j(coalescingSender, coalescedRid, 'coalesce-sender', null, ['chat-v1', 'media-state-v1']);
|
||||
legacyReceiver._m.length = coalescingSender._m.length = 0;
|
||||
|
||||
s(legacyReceiver, 'play', { currentTime: 100, seq: 1, actionTimestamp: 1 });
|
||||
await w(coalescingSender, 'play');
|
||||
const canonicalBeforeReplay = { ...mod.rooms.get(coalescedRid).mediaState };
|
||||
|
||||
let compactedQueue = enqueueQueuedEvent([], 'play', {
|
||||
currentTime: 500,
|
||||
seq: 10,
|
||||
actionTimestamp: 10
|
||||
}, { roomId: coalescedRid }).queue;
|
||||
compactedQueue = reserveLatestMediaIntentSequence(compactedQueue, coalescedRid, 11).queue;
|
||||
for (const [targetTime, seq] of [[540, 12], [600, 13]]) {
|
||||
compactedQueue = enqueueQueuedEvent(compactedQueue, 'seek', {
|
||||
currentTime: targetTime,
|
||||
targetTime,
|
||||
seq,
|
||||
actionTimestamp: seq
|
||||
}, { roomId: coalescedRid }).queue;
|
||||
}
|
||||
compactedQueue = enqueueQueuedEvent(compactedQueue, 'pause', {
|
||||
currentTime: 605,
|
||||
seq: 14,
|
||||
actionTimestamp: 14
|
||||
}, { roomId: coalescedRid }).queue;
|
||||
assert.equal(compactedQueue.length, 1, 'offline playback burst is one logical queue entry');
|
||||
const replayFrames = materializeMediaIntent(compactedQueue[0]);
|
||||
assert.deepEqual(replayFrames.map(frame => frame.event), ['seek', 'pause'],
|
||||
'compacted intent uses only the minimum existing legacy events');
|
||||
|
||||
const legacyReplayPayloads = [];
|
||||
for (const frame of replayFrames) {
|
||||
s(coalescingSender, frame.event, frame.data);
|
||||
legacyReplayPayloads.push([frame.event, await w(legacyReceiver, frame.event)]);
|
||||
}
|
||||
assert.equal(legacyReplayPayloads[0][1].targetTime, 605);
|
||||
assert.equal(legacyReplayPayloads[1][1].currentTime, 605);
|
||||
for (const [event, payload] of legacyReplayPayloads) {
|
||||
assert.ok(event === 'seek' || event === 'pause');
|
||||
assert.equal(payload.mediaState, undefined);
|
||||
assert.equal(payload.revision, undefined);
|
||||
}
|
||||
const canonicalAfterReplay = mod.rooms.get(coalescedRid).mediaState;
|
||||
assert.equal(canonicalAfterReplay.revision, canonicalBeforeReplay.revision + replayFrames.length);
|
||||
assert.equal(canonicalAfterReplay.playbackState, 'paused');
|
||||
assert.equal(canonicalAfterReplay.currentTime, 605);
|
||||
assert.equal(canonicalAfterReplay.updatedBy, 'coalesce-sender');
|
||||
|
||||
const coalescedLateJoiner = await c();
|
||||
const coalescedLateRoom = await j(coalescedLateJoiner, coalescedRid, 'coalesced-late');
|
||||
assert.equal(coalescedLateRoom.mediaState.revision, canonicalAfterReplay.revision);
|
||||
assert.equal(coalescedLateRoom.mediaState.playbackState, 'paused');
|
||||
assert.equal(coalescedLateRoom.mediaState.currentTime, 605);
|
||||
assert.equal(coalescedLateRoom.mediaState.updatedBy, 'coalesce-sender');
|
||||
// --- Stale peer reaper: terminal timeout + clean rejoin ---
|
||||
const staleClient = await c();
|
||||
const staleRoomId = 'stale-'+Date.now();
|
||||
@@ -95,23 +193,431 @@ try {
|
||||
'ROOM_DATA advertises the host-control capability');
|
||||
assert.ok(capData.capabilities.includes('chat'), 'ROOM_DATA advertises the chat capability');
|
||||
assert.ok(capData.capabilities.includes('chat-v1'), 'ROOM_DATA advertises the versioned chat capability');
|
||||
assert.ok(capData.capabilities.includes('media-state-v1'), 'ROOM_DATA advertises canonical media state v1');
|
||||
assert.equal(capData.mediaState, null, 'a new room starts without invented canonical media state');
|
||||
assert.equal(capData.chatHistory, undefined, 'ROOM_DATA never contains chat history');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- Mixed-version rollout: pre-media-state extension + current extension ---
|
||||
// Legacy intentionally omits clientCapabilities entirely and uses only the
|
||||
// pre-feature JOIN/PLAY/PAUSE/SEEK/Force Sync wire contract. The current
|
||||
// Current clients additionally advertise that they maintain canonical state
|
||||
// while solo. Recovery itself remains relay-gated and legacy clients still
|
||||
// omit clientCapabilities entirely.
|
||||
const mixedMediaRid = 'mixed-media-'+Date.now();
|
||||
const legacyMedia = await c(), currentMedia = await c();
|
||||
const legacyRoomData = await j(legacyMedia, mixedMediaRid, 'legacy-media');
|
||||
const currentRoomData = await j(currentMedia, mixedMediaRid, 'current-media', null, ['chat-v1', 'media-state-v1']);
|
||||
assert.equal(legacyRoomData.roomId, mixedMediaRid, 'legacy ROOM_DATA keeps roomId type/meaning');
|
||||
assert.ok(Array.isArray(legacyRoomData.peers), 'legacy ROOM_DATA keeps peers array');
|
||||
assert.equal(typeof legacyRoomData.controlMode, 'string', 'legacy ROOM_DATA keeps controlMode type');
|
||||
assert.ok(Array.isArray(legacyRoomData.controllers), 'legacy ROOM_DATA keeps controllers array');
|
||||
assert.equal(legacyRoomData.mediaState, null, 'new ROOM_DATA field is additive and initially null');
|
||||
assert.ok(currentRoomData.capabilities.includes('media-state-v1'), 'current client sees relay capability');
|
||||
legacyMedia._m.length = currentMedia._m.length = 0;
|
||||
|
||||
const assertUnchangedMediaWire = (payload, label) => {
|
||||
for (const field of ['revision', 'mediaState', 'updatedAt', 'updatedBy']) {
|
||||
assert.equal(payload[field], undefined, `${label} does not add canonical field ${field}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Legacy -> current: ordinary live relay is unchanged while internal state advances.
|
||||
s(legacyMedia, 'play', { currentTime: 100, seq: 1, actionTimestamp: 1001 });
|
||||
const legacyPlayRelay = await w(currentMedia, 'play');
|
||||
assert.equal(legacyPlayRelay.currentTime, 100);
|
||||
assert.equal(legacyPlayRelay.senderId, 'legacy-media');
|
||||
assertUnchangedMediaWire(legacyPlayRelay, 'legacy PLAY relay');
|
||||
assert.deepEqual(mod.rooms.get(mixedMediaRid).mediaState, {
|
||||
revision: 1,
|
||||
playbackState: 'playing',
|
||||
currentTime: 100,
|
||||
updatedAt: mod.rooms.get(mixedMediaRid).mediaState.updatedAt,
|
||||
updatedBy: 'legacy-media'
|
||||
}, 'legacy PLAY canonicalizes without a media-state client capability');
|
||||
|
||||
s(legacyMedia, 'seek', { currentTime: 1200, targetTime: 1200, seq: 2, actionTimestamp: 1002 });
|
||||
const legacySeekRelay = await w(currentMedia, 'seek');
|
||||
assert.equal(legacySeekRelay.currentTime, 1200);
|
||||
assert.equal(legacySeekRelay.targetTime, 1200);
|
||||
assertUnchangedMediaWire(legacySeekRelay, 'legacy SEEK relay');
|
||||
assert.deepEqual(mod.rooms.get(mixedMediaRid).mediaState, {
|
||||
revision: 2,
|
||||
playbackState: 'playing',
|
||||
currentTime: 1200,
|
||||
updatedAt: mod.rooms.get(mixedMediaRid).mediaState.updatedAt,
|
||||
updatedBy: 'legacy-media'
|
||||
});
|
||||
|
||||
s(legacyMedia, 'pause', { currentTime: 1200, seq: 3, actionTimestamp: 1003 });
|
||||
const legacyPauseRelay = await w(currentMedia, 'pause');
|
||||
assert.equal(legacyPauseRelay.currentTime, 1200);
|
||||
assertUnchangedMediaWire(legacyPauseRelay, 'legacy PAUSE relay');
|
||||
assert.deepEqual(mod.rooms.get(mixedMediaRid).mediaState, {
|
||||
revision: 3,
|
||||
playbackState: 'paused',
|
||||
currentTime: 1200,
|
||||
updatedAt: mod.rooms.get(mixedMediaRid).mediaState.updatedAt,
|
||||
updatedBy: 'legacy-media'
|
||||
});
|
||||
|
||||
// Current -> legacy: old receive path sees the same ordinary events and fields.
|
||||
s(currentMedia, 'play', { currentTime: 1300, seq: 1, actionTimestamp: 2001 });
|
||||
const currentPlayRelay = await w(legacyMedia, 'play');
|
||||
assert.equal(currentPlayRelay.currentTime, 1300);
|
||||
assertUnchangedMediaWire(currentPlayRelay, 'current PLAY relay to legacy client');
|
||||
s(currentMedia, 'seek', { currentTime: 1400, targetTime: 1400, seq: 2, actionTimestamp: 2002 });
|
||||
const currentSeekRelay = await w(legacyMedia, 'seek');
|
||||
assert.equal(currentSeekRelay.targetTime, 1400);
|
||||
assertUnchangedMediaWire(currentSeekRelay, 'current SEEK relay to legacy client');
|
||||
|
||||
// Legacy Force Sync needs no new target on EXECUTE, event or ACK. Canonical
|
||||
// bookkeeping remains internal and commits only after the existing execute.
|
||||
const beforeLegacyPrepare = { ...mod.rooms.get(mixedMediaRid).mediaState };
|
||||
s(legacyMedia, 'force_sync_prepare', { targetTime: 1600, seq: 4, actionTimestamp: 1004 });
|
||||
const legacyPrepareRelay = await w(currentMedia, 'force_sync_prepare');
|
||||
assert.equal(legacyPrepareRelay.targetTime, 1600);
|
||||
assertUnchangedMediaWire(legacyPrepareRelay, 'legacy Force Sync PREPARE relay');
|
||||
assert.deepEqual(mod.rooms.get(mixedMediaRid).mediaState, beforeLegacyPrepare,
|
||||
'legacy PREPARE remains choreography and does not commit canonical state');
|
||||
s(currentMedia, 'force_sync_ack', { seq: 3 });
|
||||
const currentAckRelay = await w(legacyMedia, 'force_sync_ack');
|
||||
assert.equal(currentAckRelay.senderId, 'current-media');
|
||||
assertUnchangedMediaWire(currentAckRelay, 'existing Force Sync ACK relay');
|
||||
s(legacyMedia, 'force_sync_execute', { seq: 5, actionTimestamp: 1005 });
|
||||
const legacyExecuteRelay = await w(currentMedia, 'force_sync_execute');
|
||||
assert.equal(legacyExecuteRelay.targetTime, undefined, 'legacy EXECUTE still requires no target field');
|
||||
assertUnchangedMediaWire(legacyExecuteRelay, 'legacy Force Sync EXECUTE relay');
|
||||
assert.deepEqual(mod.rooms.get(mixedMediaRid).mediaState, {
|
||||
revision: beforeLegacyPrepare.revision + 1,
|
||||
playbackState: 'playing',
|
||||
currentTime: 1600,
|
||||
updatedAt: mod.rooms.get(mixedMediaRid).mediaState.updatedAt,
|
||||
updatedBy: 'legacy-media'
|
||||
}, 'legacy Force Sync EXECUTE commits only internal canonical state');
|
||||
|
||||
// Host Control remains the sole authorization chokepoint, independent of
|
||||
// media-state client knowledge.
|
||||
s(legacyMedia, 'set_control_mode', { controlMode: 'host-only' });
|
||||
await w(legacyMedia, 'control_mode');
|
||||
await w(currentMedia, 'control_mode');
|
||||
legacyMedia._m.length = currentMedia._m.length = 0;
|
||||
const mixedHostBaseline = { ...mod.rooms.get(mixedMediaRid).mediaState };
|
||||
s(currentMedia, 'seek', { currentTime: 1650, targetTime: 1650, seq: 4, actionTimestamp: 2004 });
|
||||
let mixedGuestRejected = false;
|
||||
try { await w(legacyMedia, 'seek', 600); } catch { mixedGuestRejected = true; }
|
||||
assert.ok(mixedGuestRejected, 'current guest command remains rejected in mixed host-only room');
|
||||
assert.deepEqual(mod.rooms.get(mixedMediaRid).mediaState, mixedHostBaseline,
|
||||
'rejected mixed-version guest command does not mutate or revise canonical state');
|
||||
|
||||
s(legacyMedia, 'seek', { currentTime: 1700, targetTime: 1700, seq: 6, actionTimestamp: 1006 });
|
||||
const legacyHostSeek = await w(currentMedia, 'seek');
|
||||
assert.equal(legacyHostSeek.targetTime, 1700, 'allowed legacy controller command relays normally');
|
||||
assert.equal(mod.rooms.get(mixedMediaRid).mediaState.updatedBy, 'legacy-media');
|
||||
assert.equal(mod.rooms.get(mixedMediaRid).mediaState.currentTime, 1700);
|
||||
|
||||
// Make the final stable intent legacy-owned, then reconnect only the current client.
|
||||
s(legacyMedia, 'pause', { currentTime: 1700, seq: 7, actionTimestamp: 1007 });
|
||||
await w(currentMedia, 'pause');
|
||||
const legacyFinalRevision = mod.rooms.get(mixedMediaRid).mediaState.revision;
|
||||
const currentMediaRejoin = await c();
|
||||
const mixedRejoinData = await j(currentMediaRejoin, mixedMediaRid, 'current-media', null, ['chat-v1', 'media-state-v1']);
|
||||
assert.equal(mixedRejoinData.mediaState.revision, legacyFinalRevision);
|
||||
assert.equal(mixedRejoinData.mediaState.playbackState, 'paused');
|
||||
assert.equal(mixedRejoinData.mediaState.currentTime, 1700);
|
||||
assert.equal(mixedRejoinData.mediaState.updatedBy, 'legacy-media',
|
||||
'current reconnect snapshot reflects the legacy client latest accepted intent');
|
||||
assert.equal(legacyMedia._m.some(raw => raw.includes('media_state')), false,
|
||||
'legacy client receives no new canonical event or ACK requirement');
|
||||
currentMediaRejoin.close();
|
||||
await delay(100);
|
||||
assert.equal(mod.rooms.get(mixedMediaRid).mediaState, null,
|
||||
'canonical state is cleared when only a legacy solo-suppressing client remains');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// The inverse must remain true: a capable solo client keeps publishing
|
||||
// PLAY/PAUSE/SEEK, so removing a legacy peer must not discard valid state.
|
||||
const capableSoloRid = 'capable-solo-'+Date.now();
|
||||
const capableSolo = await c(), transientLegacy = await c();
|
||||
await j(capableSolo, capableSoloRid, 'capable-solo', null, ['chat-v1', 'media-state-v1']);
|
||||
await j(transientLegacy, capableSoloRid, 'transient-legacy');
|
||||
capableSolo._m.length = transientLegacy._m.length = 0;
|
||||
s(capableSolo, 'play', { currentTime: 42, mediaTitle: 'Series S03E04' });
|
||||
await w(transientLegacy, 'play');
|
||||
const capableSoloState = { ...mod.rooms.get(capableSoloRid).mediaState };
|
||||
transientLegacy.close();
|
||||
await delay(100);
|
||||
assert.deepEqual(mod.rooms.get(capableSoloRid).mediaState, capableSoloState,
|
||||
'canonical state remains valid when the sole remaining client advertises media-state-v1');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- Canonical Media State v1: late join, pause, seek and reconnect ---
|
||||
const msrid = 'media-state-'+Date.now();
|
||||
const msa = await c();
|
||||
const initialMediaRoom = await j(msa, msrid, 'msa');
|
||||
assert.equal(initialMediaRoom.mediaState, null, 'media state is initially null');
|
||||
|
||||
s(msa, 'play', {
|
||||
currentTime: 100,
|
||||
mediaTitle: 'Series S01E02',
|
||||
revision: 999,
|
||||
updatedBy: 'spoofed'
|
||||
});
|
||||
await delay(120);
|
||||
const msb = await c();
|
||||
const playingJoin = await j(msb, msrid, 'msb');
|
||||
assert.equal(playingJoin.mediaState.revision, 1, 'first accepted PLAY creates revision 1');
|
||||
assert.equal(playingJoin.mediaState.playbackState, 'playing');
|
||||
assert.equal(playingJoin.mediaState.mediaTitle, 'Series S01E02');
|
||||
assert.ok(playingJoin.mediaState.currentTime >= 100.08 && playingJoin.mediaState.currentTime < 101,
|
||||
`playing late join receives projected position (${playingJoin.mediaState.currentTime})`);
|
||||
assert.equal(playingJoin.mediaState.updatedBy, 'msa', 'updatedBy is server-tracked identity');
|
||||
|
||||
s(msa, 'pause', { currentTime: 150 });
|
||||
await delay(40);
|
||||
const pausedRevision = mod.rooms.get(msrid).mediaState.revision;
|
||||
await delay(100);
|
||||
const msc = await c();
|
||||
const pausedJoin = await j(msc, msrid, 'msc');
|
||||
assert.equal(pausedJoin.mediaState.revision, pausedRevision);
|
||||
assert.equal(pausedJoin.mediaState.playbackState, 'paused');
|
||||
assert.equal(pausedJoin.mediaState.currentTime, 150, 'paused late join position does not advance');
|
||||
|
||||
s(msa, 'seek', { currentTime: 500, targetTime: 600 });
|
||||
await delay(40);
|
||||
const seekState = mod.rooms.get(msrid).mediaState;
|
||||
assert.equal(seekState.currentTime, 600, 'SEEK uses targetTime rather than currentTime');
|
||||
assert.equal(seekState.playbackState, 'paused', 'SEEK preserves canonical playback state');
|
||||
|
||||
s(msa, 'play', { currentTime: 1800 });
|
||||
await delay(60);
|
||||
const reconnect1 = await c();
|
||||
const reconnectFirst = await j(reconnect1, msrid, 'reconnect');
|
||||
reconnect1.close();
|
||||
await delay(120);
|
||||
const beforePeerDedupe = { ...mod.rooms.get(msrid).mediaState };
|
||||
const reconnect2 = await c();
|
||||
const reconnectSecond = await j(reconnect2, msrid, 'reconnect');
|
||||
assert.equal(reconnectSecond.mediaState.revision, reconnectFirst.mediaState.revision,
|
||||
'lazy clock projection does not increment revision');
|
||||
assert.ok(reconnectSecond.mediaState.currentTime > reconnectFirst.mediaState.currentTime,
|
||||
'reconnect receives a newly projected playing position');
|
||||
assert.deepEqual(mod.rooms.get(msrid).mediaState, beforePeerDedupe,
|
||||
'peer dedupe/reconnect does not reset or revise canonical state');
|
||||
|
||||
const beforeOrderingRevision = mod.rooms.get(msrid).mediaState.revision;
|
||||
s(msa, 'seek', { targetTime: 100 });
|
||||
await delay(30);
|
||||
s(msb, 'seek', { targetTime: 200 });
|
||||
await delay(40);
|
||||
const orderedState = mod.rooms.get(msrid).mediaState;
|
||||
assert.equal(orderedState.revision, beforeOrderingRevision + 2, 'accepted controllers increment revision in server order');
|
||||
assert.equal(orderedState.currentTime, 200, 'last accepted controller wins');
|
||||
assert.equal(orderedState.updatedBy, 'msb');
|
||||
s(msb, 'seek', { targetTime: 300 });
|
||||
await delay(30);
|
||||
s(msa, 'seek', { targetTime: 400 });
|
||||
await delay(40);
|
||||
const reverseOrderedState = mod.rooms.get(msrid).mediaState;
|
||||
assert.equal(reverseOrderedState.revision, orderedState.revision + 2);
|
||||
assert.equal(reverseOrderedState.currentTime, 400, 'reversing server order reverses the winning controller');
|
||||
assert.equal(reverseOrderedState.updatedBy, 'msa');
|
||||
|
||||
// Heartbeats remain observations and cannot rewrite canonical intent.
|
||||
const beforeHeartbeat = { ...reverseOrderedState };
|
||||
s(msa, 'peer_status', { playbackState: 'paused', currentTime: 999 });
|
||||
await delay(40);
|
||||
assert.deepEqual(mod.rooms.get(msrid).mediaState, beforeHeartbeat, 'PEER_STATUS does not mutate canonical state');
|
||||
|
||||
// Force Sync PREPARE is choreography; matching EXECUTE commits its final target.
|
||||
s(msa, 'force_sync_prepare', { targetTime: 700 });
|
||||
await delay(40);
|
||||
assert.deepEqual(mod.rooms.get(msrid).mediaState, beforeHeartbeat, 'Force Sync PREPARE does not mutate canonical state');
|
||||
s(msa, 'force_sync_execute', {});
|
||||
await delay(40);
|
||||
const forceSyncState = mod.rooms.get(msrid).mediaState;
|
||||
assert.equal(forceSyncState.revision, beforeHeartbeat.revision + 1);
|
||||
assert.equal(forceSyncState.playbackState, 'playing');
|
||||
assert.equal(forceSyncState.currentTime, 700);
|
||||
|
||||
// The legacy wire exposes one room-wide prepared target. A later PREPARE
|
||||
// replaces what every peer has just sought to; any currently authorized
|
||||
// EXECUTE must commit that visible target instead of clearing it unmatched.
|
||||
s(msa, 'force_sync_prepare', { targetTime: 800 });
|
||||
await w(msb, 'force_sync_prepare');
|
||||
s(msb, 'force_sync_prepare', { targetTime: 900 });
|
||||
await w(msa, 'force_sync_prepare');
|
||||
const beforeCompetingExecute = { ...mod.rooms.get(msrid).mediaState };
|
||||
s(msa, 'force_sync_execute', {});
|
||||
await w(msb, 'force_sync_execute');
|
||||
const competingForceState = mod.rooms.get(msrid).mediaState;
|
||||
assert.equal(competingForceState.revision, beforeCompetingExecute.revision + 1);
|
||||
assert.equal(competingForceState.currentTime, 900,
|
||||
'authorized EXECUTE commits the latest target visible to legacy peers');
|
||||
assert.equal(competingForceState.updatedBy, 'msa');
|
||||
|
||||
// The initiator's normal ACK timeout stays below the relay's delayed-target
|
||||
// warning boundary and commits normally.
|
||||
s(msa, 'force_sync_prepare', { targetTime: 925 });
|
||||
await w(msb, 'force_sync_prepare');
|
||||
mod.rooms.get(msrid).forceSyncTarget.preparedAt = Date.now() - FORCE_SYNC_TIMEOUT;
|
||||
s(msa, 'force_sync_execute', {});
|
||||
await w(msb, 'force_sync_execute');
|
||||
assert.equal(mod.rooms.get(msrid).mediaState.currentTime, 925,
|
||||
'relay grace accepts EXECUTE at the client ACK-timeout boundary');
|
||||
|
||||
// Even beyond the warning boundary, a target that no newer room action
|
||||
// superseded remains the only safe way to release already-paused peers.
|
||||
s(msa, 'force_sync_prepare', { targetTime: 950 });
|
||||
await w(msb, 'force_sync_prepare');
|
||||
const beforeExpiredExecute = { ...mod.rooms.get(msrid).mediaState };
|
||||
mod.rooms.get(msrid).forceSyncTarget.preparedAt = Date.now() - FORCE_SYNC_TARGET_DELAY_WARNING - 1;
|
||||
msa._m.length = msb._m.length = 0;
|
||||
s(msa, 'force_sync_execute', {});
|
||||
await w(msb, 'force_sync_execute');
|
||||
assert.equal(mod.rooms.get(msrid).mediaState.revision, beforeExpiredExecute.revision + 1);
|
||||
assert.equal(mod.rooms.get(msrid).mediaState.currentTime, 950,
|
||||
'a delayed EXECUTE still releases peers and commits its unsuperseded prepared target');
|
||||
assert.equal(mod.rooms.get(msrid).forceSyncTarget, null);
|
||||
|
||||
// A relay restart cannot recover transient PREPARE state. Preserve the old
|
||||
// wire liveness fallback without inventing a canonical target.
|
||||
const beforeUntrackedExecute = { ...mod.rooms.get(msrid).mediaState };
|
||||
msa._m.length = msb._m.length = 0;
|
||||
s(msa, 'force_sync_execute', {});
|
||||
await w(msb, 'force_sync_execute');
|
||||
assert.deepEqual(mod.rooms.get(msrid).mediaState, beforeUntrackedExecute,
|
||||
'an untracked compatibility EXECUTE relays without canonical mutation');
|
||||
|
||||
s(msa, 'force_sync_prepare', { targetTime: 975 });
|
||||
await w(msb, 'force_sync_prepare');
|
||||
s(msb, 'seek', { targetTime: 'invalid' });
|
||||
await w(msa, 'seek');
|
||||
assert.equal(mod.rooms.get(msrid).forceSyncTarget.targetTime, 975,
|
||||
'a sanitized no-op SEEK does not supersede an in-flight prepared target');
|
||||
s(msa, 'force_sync_execute', {});
|
||||
await w(msb, 'force_sync_execute');
|
||||
assert.equal(mod.rooms.get(msrid).mediaState.currentTime, 975);
|
||||
|
||||
msa._m.length = msb._m.length = 0;
|
||||
s(msa, 'force_sync_prepare', { targetTime: 1_000 });
|
||||
await w(msb, 'force_sync_prepare');
|
||||
s(msb, 'pause', { currentTime: 1_100 });
|
||||
await w(msa, 'pause');
|
||||
const supersedingMediaState = { ...mod.rooms.get(msrid).mediaState };
|
||||
msa._m.length = msb._m.length = 0;
|
||||
s(msa, 'force_sync_execute', {});
|
||||
let orphanExecuteDropped = false;
|
||||
try { await w(msb, 'force_sync_execute', 500); } catch { orphanExecuteDropped = true; }
|
||||
assert.ok(orphanExecuteDropped, 'an action that supersedes PREPARE makes delayed EXECUTE invalid');
|
||||
assert.deepEqual(mod.rooms.get(msrid).mediaState, supersedingMediaState);
|
||||
|
||||
// Active Episode Lobby is additive ROOM_DATA state and does not rewrite mediaState.
|
||||
s(msa, 'episode_lobby', { expectedTitle: 'S02E03' });
|
||||
await delay(40);
|
||||
const beforeLobbyJoin = { ...mod.rooms.get(msrid).mediaState };
|
||||
const msLobbyJoiner = await c();
|
||||
const lobbyRoomData = await j(msLobbyJoiner, msrid, 'ms-lobby');
|
||||
assert.equal(lobbyRoomData.activeLobby.expectedTitle, 'S02E03');
|
||||
assert.equal(lobbyRoomData.mediaState.revision, beforeLobbyJoin.revision);
|
||||
assert.deepEqual(mod.rooms.get(msrid).mediaState, beforeLobbyJoin, 'Episode Lobby does not mutate canonical state');
|
||||
s(msa, 'leave_room', {});
|
||||
await delay(50);
|
||||
assert.deepEqual(mod.rooms.get(msrid).mediaState, beforeLobbyJoin,
|
||||
'host disconnect/reassignment preserves canonical state and revision');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- Canonical Media State v1: Host Control and validation chokepoints ---
|
||||
const msgateRid = 'media-gate-'+Date.now();
|
||||
const msgHost = await c(), msgGuest = await c(), msgUnjoined = await c();
|
||||
await j(msgHost, msgateRid, 'msg-host');
|
||||
await j(msgGuest, msgateRid, 'msg-guest');
|
||||
s(msgHost, 'play', { currentTime: 10 });
|
||||
await delay(40);
|
||||
s(msgHost, 'set_control_mode', { controlMode: 'host-only' });
|
||||
await w(msgGuest, 'control_mode');
|
||||
msgHost._m.length = msgGuest._m.length = 0;
|
||||
const gatedBaseline = { ...mod.rooms.get(msgateRid).mediaState };
|
||||
|
||||
s(msgGuest, 'seek', { targetTime: 900 });
|
||||
await delay(80);
|
||||
assert.deepEqual(mod.rooms.get(msgateRid).mediaState, gatedBaseline,
|
||||
'host-only rejected guest cannot mutate canonical state or revision');
|
||||
|
||||
s(msgHost, 'seek', { targetTime: 800 });
|
||||
await delay(40);
|
||||
assert.equal(mod.rooms.get(msgateRid).mediaState.revision, gatedBaseline.revision + 1);
|
||||
assert.equal(mod.rooms.get(msgateRid).mediaState.currentTime, 800);
|
||||
|
||||
// Current receivers ignore duplicate/regressing seq. The relay must make the
|
||||
// same decision before canonical mutation so late joiners see the same truth.
|
||||
msgHost._m.length = msgGuest._m.length = 0;
|
||||
s(msgHost, 'play', { currentTime: 820, seq: 10 });
|
||||
await w(msgGuest, 'play');
|
||||
const sequencedBaseline = { ...mod.rooms.get(msgateRid).mediaState };
|
||||
const sequencedPeerBaseline = {
|
||||
...Array.from(mod.rooms.get(msgateRid).peerData.values())
|
||||
.find(peer => peer.peerId === 'msg-host')
|
||||
};
|
||||
s(msgHost, 'pause', { currentTime: 1, playbackState: 'paused', seq: 10 });
|
||||
let duplicateSequenceDropped = false;
|
||||
try { await w(msgGuest, 'pause', 500); } catch { duplicateSequenceDropped = true; }
|
||||
assert.ok(duplicateSequenceDropped, 'duplicate media seq is not relayed');
|
||||
s(msgHost, 'seek', { targetTime: 5, seq: 9 });
|
||||
let staleSequenceDropped = false;
|
||||
try { await w(msgGuest, 'seek', 500); } catch { staleSequenceDropped = true; }
|
||||
assert.ok(staleSequenceDropped, 'regressing media seq is not relayed');
|
||||
assert.deepEqual(mod.rooms.get(msgateRid).mediaState, sequencedBaseline,
|
||||
'regressing media seq cannot revise canonical state');
|
||||
assert.deepEqual(
|
||||
Array.from(mod.rooms.get(msgateRid).peerData.values())
|
||||
.find(peer => peer.peerId === 'msg-host'),
|
||||
sequencedPeerBaseline,
|
||||
'duplicate/regressing media seq cannot alter peer state used by later canonical updates');
|
||||
const msgLateJoiner = await c();
|
||||
const msgLateRoom = await j(msgLateJoiner, msgateRid, 'msg-late');
|
||||
assert.equal(msgLateRoom.mediaState.revision, sequencedBaseline.revision);
|
||||
assert.equal(msgLateRoom.mediaState.playbackState, sequencedBaseline.playbackState);
|
||||
assert.ok(msgLateRoom.mediaState.currentTime >= sequencedBaseline.currentTime
|
||||
&& msgLateRoom.mediaState.currentTime < sequencedBaseline.currentTime + 5,
|
||||
'late joiner receives the accepted playing state with normal snapshot projection');
|
||||
|
||||
const validationBaseline = { ...mod.rooms.get(msgateRid).mediaState };
|
||||
for (const invalidPayload of [{ targetTime: null }, { targetTime: '50' }, { targetTime: {} }, {}]) {
|
||||
s(msgHost, 'seek', invalidPayload);
|
||||
}
|
||||
s(msgUnjoined, 'seek', { roomId: msgateRid, targetTime: 999, updatedBy: 'msg-host', revision: 999999 });
|
||||
await delay(80);
|
||||
assert.deepEqual(mod.rooms.get(msgateRid).mediaState, validationBaseline,
|
||||
'invalid and unjoined/cross-room payloads cannot corrupt canonical state');
|
||||
|
||||
s(msgHost, 'leave_room', {});
|
||||
s(msgGuest, 'leave_room', {});
|
||||
s(msgLateJoiner, 'leave_room', {});
|
||||
await delay(80);
|
||||
assert.equal(mod.rooms.has(msgateRid), false, 'empty-room cleanup removes canonical state with the room');
|
||||
// --- Terminal room timeout: coded error + complete membership cleanup ---
|
||||
const timeoutClient = await c();
|
||||
const timeoutPeer = await c();
|
||||
const timeoutRoomId = 'timeout-'+Date.now();
|
||||
await j(timeoutClient, timeoutRoomId, 'timeout-peer');
|
||||
timeoutClient._m.length = 0;
|
||||
await j(timeoutPeer, timeoutRoomId, 'timeout-peer-2');
|
||||
timeoutClient._m.length = timeoutPeer._m.length = 0;
|
||||
mod.rooms.get(timeoutRoomId).lastActivity = 0;
|
||||
mod.cleanupInactiveRooms(Date.now());
|
||||
const [timeoutEvent, timeoutData] = await a(timeoutClient);
|
||||
assert.equal(timeoutEvent, 'error');
|
||||
const timeoutData = await w(timeoutClient, 'error');
|
||||
const timeoutPeerData = await w(timeoutPeer, 'error');
|
||||
assert.equal(timeoutData.code, 'room_closed');
|
||||
assert.equal(timeoutData.message, 'Room closed');
|
||||
assert.equal(timeoutPeerData.code, 'room_closed');
|
||||
await delay(80);
|
||||
assert.deepEqual(timeoutClient._m, [], 'terminal room cleanup emits nothing after room_closed');
|
||||
assert.deepEqual(timeoutPeer._m, [], 'terminal room cleanup emits nothing after room_closed');
|
||||
assert.equal(mod.rooms.has(timeoutRoomId), false, 'inactive room is deleted');
|
||||
timeoutClient._m.length = 0;
|
||||
|
||||
// The same connected socket must be able to join that room again. This
|
||||
// proves timeout cleanup removed its stale socketToRoom membership.
|
||||
@@ -370,6 +876,70 @@ try {
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// A valid PREPARE remains executable if the room changes from everyone to
|
||||
// host-only before EXECUTE. An invalid PREPARE grants no such exemption.
|
||||
const transitionRid = 'force-transition-'+Date.now();
|
||||
const transitionHost = await c(), transitionGuest = await c();
|
||||
await j(transitionHost, transitionRid, 'transition-host');
|
||||
await j(transitionGuest, transitionRid, 'transition-guest');
|
||||
transitionHost._m.length = transitionGuest._m.length = 0;
|
||||
s(transitionGuest, 'force_sync_prepare', { targetTime: 321 });
|
||||
await w(transitionHost, 'force_sync_prepare');
|
||||
s(transitionHost, 'set_control_mode', { controlMode: 'host-only' });
|
||||
await w(transitionGuest, 'control_mode');
|
||||
transitionHost._m.length = transitionGuest._m.length = 0;
|
||||
s(transitionGuest, 'force_sync_execute', {});
|
||||
await w(transitionHost, 'force_sync_execute');
|
||||
assert.equal(mod.rooms.get(transitionRid).mediaState.currentTime, 321);
|
||||
|
||||
await delay(550);
|
||||
s(transitionHost, 'set_peer_role', { peerId: 'transition-guest', controller: true });
|
||||
await w(transitionGuest, 'control_mode');
|
||||
transitionHost._m.length = transitionGuest._m.length = 0;
|
||||
s(transitionGuest, 'force_sync_prepare', { targetTime: 'invalid' });
|
||||
let invalidPrepareRelayed = false;
|
||||
try { await w(transitionHost, 'force_sync_prepare', 500); } catch { invalidPrepareRelayed = true; }
|
||||
assert.ok(invalidPrepareRelayed, 'invalid PREPARE is not relayed');
|
||||
await delay(550);
|
||||
s(transitionHost, 'set_peer_role', { peerId: 'transition-guest', controller: false });
|
||||
await w(transitionGuest, 'control_mode');
|
||||
transitionHost._m.length = transitionGuest._m.length = 0;
|
||||
s(transitionGuest, 'force_sync_execute', {});
|
||||
let invalidExecuteGated = false;
|
||||
try { await w(transitionHost, 'force_sync_execute', 500); } catch { invalidExecuteGated = true; }
|
||||
assert.ok(invalidExecuteGated, 'invalid PREPARE grants no post-demotion EXECUTE exemption');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// A transient disconnect removes the co-host role but not the validated
|
||||
// room target. The same PREPARE initiator may still finish that already
|
||||
// visible transaction after reconnecting as a host-only guest.
|
||||
const forceReconnectRid = 'force-reconnect-'+Date.now();
|
||||
const forceReconnectHost = await c(), forceReconnectPeer = await c();
|
||||
await j(forceReconnectHost, forceReconnectRid, 'force-host');
|
||||
await j(forceReconnectPeer, forceReconnectRid, 'force-peer');
|
||||
forceReconnectHost._m.length = forceReconnectPeer._m.length = 0;
|
||||
s(forceReconnectHost, 'set_control_mode', { controlMode: 'host-only' });
|
||||
await w(forceReconnectHost, 'control_mode');
|
||||
await w(forceReconnectPeer, 'control_mode');
|
||||
forceReconnectHost._m.length = forceReconnectPeer._m.length = 0;
|
||||
s(forceReconnectHost, 'set_peer_role', { peerId: 'force-peer', controller: true });
|
||||
await w(forceReconnectPeer, 'control_mode');
|
||||
forceReconnectHost._m.length = forceReconnectPeer._m.length = 0;
|
||||
s(forceReconnectPeer, 'force_sync_prepare', { targetTime: 444 });
|
||||
await w(forceReconnectHost, 'force_sync_prepare');
|
||||
forceReconnectPeer.close();
|
||||
await delay(100);
|
||||
const forceReconnectReplacement = await c();
|
||||
await j(forceReconnectReplacement, forceReconnectRid, 'force-peer');
|
||||
forceReconnectHost._m.length = forceReconnectReplacement._m.length = 0;
|
||||
s(forceReconnectReplacement, 'force_sync_execute', {});
|
||||
await w(forceReconnectHost, 'force_sync_execute');
|
||||
assert.equal(mod.rooms.get(forceReconnectRid).mediaState.currentTime, 444);
|
||||
assert.equal(mod.rooms.get(forceReconnectRid).mediaState.playbackState, 'playing');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- A guest's stray EXECUTE (no matching PREPARE they initiated) is still gated ---
|
||||
const grid = 'h1b-'+Date.now();
|
||||
const go = await c(), gg = await c();
|
||||
@@ -450,12 +1020,12 @@ try {
|
||||
s(mxo,'play',{currentTime:1}); await w(mxn,'play');
|
||||
s(mxo,'seek',{currentTime:99}); await w(mxn,'seek');
|
||||
s(mxo,'force_sync_prepare',{targetTime:5}); await w(mxn,'force_sync_prepare');
|
||||
s(mxo,'episode_lobby',{expectedTitle:'S1E1'}); await w(mxn,'episode_lobby');
|
||||
// New → old
|
||||
mxo._m.length = mxn._m.length = 0;
|
||||
s(mxn,'force_sync_execute',{}); await w(mxo,'force_sync_execute');
|
||||
s(mxo,'episode_lobby',{expectedTitle:'S1E1'}); await w(mxn,'episode_lobby');
|
||||
s(mxn,'pause',{currentTime:2}); await w(mxo,'pause');
|
||||
s(mxn,'seek',{currentTime:50}); await w(mxo,'seek');
|
||||
s(mxn,'force_sync_execute',{}); await w(mxo,'force_sync_execute');
|
||||
s(mxn,'episode_lobby_cancel',{}); await w(mxo,'episode_lobby_cancel');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
@@ -106,5 +106,6 @@ and `server/rate-limiter.test.mjs`, or use `scripts/test-server-routes.mjs` and
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `index.js` | Express + Socket.IO server: room management, relay loop, graceful shutdown |
|
||||
| `media-state.js` | Canonical room media clock, control updates, and projected ROOM_DATA snapshots |
|
||||
| `rate-limiter.js` | Connection, event, health, and auth rate limiting with 6 functions + cleanup intervals |
|
||||
| `ops.js` | Health endpoint helpers, metrics payload builder, auth validation |
|
||||
|
||||
+213
-34
@@ -4,8 +4,13 @@ import { fileURLToPath } from 'url';
|
||||
import { Server } from 'socket.io';
|
||||
import crypto from 'crypto';
|
||||
import dotenv from 'dotenv';
|
||||
import { EVENTS, ERROR_CODES, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES } from '../shared/constants.js';
|
||||
import { EVENTS, ERROR_CODES, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES, FORCE_SYNC_TARGET_DELAY_WARNING, MAX_MEDIA_TIME } from '../shared/constants.js';
|
||||
import { createChatEnvelope } from './chat.js';
|
||||
import {
|
||||
commitForceSyncMediaState,
|
||||
snapshotMediaState,
|
||||
updateMediaStateFromControl
|
||||
} from './media-state.js';
|
||||
import {
|
||||
buildHealthPayload,
|
||||
checkCooldown,
|
||||
@@ -171,6 +176,18 @@ const HOST_ONLY_GATED_EVENTS = new Set([
|
||||
EVENTS.EPISODE_LOBBY_CANCEL
|
||||
]);
|
||||
|
||||
// Current clients sequence room-moving media commands. The relay mirrors the
|
||||
// receiver-side stale guard so a frame ignored by live peers cannot become the
|
||||
// canonical snapshot shown to a later joiner. Legacy clients omit seq entirely
|
||||
// and retain their pre-feature behavior.
|
||||
const SEQUENCED_ROOM_EVENTS = new Set([
|
||||
EVENTS.PLAY,
|
||||
EVENTS.PAUSE,
|
||||
EVENTS.SEEK,
|
||||
EVENTS.FORCE_SYNC_PREPARE,
|
||||
EVENTS.FORCE_SYNC_EXECUTE
|
||||
]);
|
||||
|
||||
// Features this relay supports, advertised to clients in ROOM_DATA so they can
|
||||
// enable matching UI/behavior only when the server actually backs it. Append a
|
||||
// flag here when a new server-gated feature ships (e.g. co-host promotion).
|
||||
@@ -178,7 +195,8 @@ const SERVER_CAPABILITIES = [
|
||||
CAPABILITIES.HOST_CONTROL,
|
||||
CAPABILITIES.CO_HOST,
|
||||
CAPABILITIES.CHAT,
|
||||
CAPABILITIES.CHAT_V1
|
||||
CAPABILITIES.CHAT_V1,
|
||||
CAPABILITIES.MEDIA_STATE_V1
|
||||
];
|
||||
|
||||
function normalizeClientCapabilities(value) {
|
||||
@@ -186,7 +204,8 @@ function normalizeClientCapabilities(value) {
|
||||
return [...new Set(value.slice(0, 16)
|
||||
.filter(capability => typeof capability === 'string')
|
||||
.map(capability => capability.substring(0, 32))
|
||||
.filter(capability => capability === CAPABILITIES.CHAT_V1)
|
||||
.filter(capability => capability === CAPABILITIES.CHAT_V1
|
||||
|| capability === CAPABILITIES.MEDIA_STATE_V1)
|
||||
)];
|
||||
}
|
||||
|
||||
@@ -195,6 +214,11 @@ function clientSupportsChat(socket) {
|
||||
socket.data.clientCapabilities.includes(CAPABILITIES.CHAT_V1);
|
||||
}
|
||||
|
||||
function clientSupportsMediaState(socket) {
|
||||
return Array.isArray(socket?.data?.clientCapabilities)
|
||||
&& socket.data.clientCapabilities.includes(CAPABILITIES.MEDIA_STATE_V1);
|
||||
}
|
||||
|
||||
// M-4: minimum interval between CONTROL_MODE changes per room. Stops a rapidly
|
||||
// toggling host from thrashing every guest's UI (locked/unlocked/locked...) and
|
||||
// from generating one broadcast per toggle across all peers.
|
||||
@@ -226,8 +250,10 @@ function log(type, message, details = '') {
|
||||
* @param {string} socketId - The socket.id being removed.
|
||||
* @param {string} roomId - The room it belongs to.
|
||||
* @param {string} reason - Log label ('disconnect', 'leave', 'reaper', 'dedupe', 'room-switch').
|
||||
* @param {object} options
|
||||
* @param {boolean} options.notifyRemainingPeers - Whether to emit room-state updates after removal.
|
||||
*/
|
||||
function removePeerFromRoom(socketId, roomId, reason) {
|
||||
function removePeerFromRoom(socketId, roomId, reason, { notifyRemainingPeers = true } = {}) {
|
||||
const room = rooms.get(roomId);
|
||||
if (!room) return;
|
||||
|
||||
@@ -251,14 +277,14 @@ function removePeerFromRoom(socketId, roomId, reason) {
|
||||
// 3. Notify remaining peers (use io.to so the removed socket itself
|
||||
// doesn't receive it — it has already left or is disconnecting)
|
||||
const isPeerStillConnected = Array.from(room.peerData.values()).some(data => data.peerId === peerId);
|
||||
if (!isPeerStillConnected) {
|
||||
if (notifyRemainingPeers && !isPeerStillConnected) {
|
||||
io.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' });
|
||||
}
|
||||
|
||||
// 3.5. Clean up active lobby if a peer leaves
|
||||
if (room.activeLobby) {
|
||||
room.activeLobby.readyPeers = room.activeLobby.readyPeers.filter(id => id !== peerId);
|
||||
if (room.activeLobby.readyPeers.length <= 1 || room.activeLobby.initiatorPeerId === peerId) {
|
||||
if (room.peers.size <= 1 || room.activeLobby.initiatorPeerId === peerId) {
|
||||
room.activeLobby = null; // Dissolve lobby
|
||||
}
|
||||
}
|
||||
@@ -277,11 +303,22 @@ function removePeerFromRoom(socketId, roomId, reason) {
|
||||
// limitation (no host grace period); see KNOWN_LIMITATIONS.md.
|
||||
const peerRejoining = peerJoinLocks.has(peerId);
|
||||
const peerGone = !isPeerStillConnected && !peerRejoining;
|
||||
if (room.peers.size === 1 && !peerRejoining) {
|
||||
const remainingSocketId = room.peers.values().next().value;
|
||||
const remainingSocket = io.sockets.sockets.get(remainingSocketId);
|
||||
if (!clientSupportsMediaState(remainingSocket)) {
|
||||
// Pre-feature extensions suppress PLAY/PAUSE/SEEK while solo. Their
|
||||
// last canonical snapshot can therefore become stale before the next
|
||||
// join; absence is safer than applying known-unreliable room truth.
|
||||
room.mediaState = null;
|
||||
}
|
||||
}
|
||||
if (peerGone && room.controllers && room.peers.size > 0) {
|
||||
const wasController = room.controllers.has(peerId);
|
||||
room.controllers.delete(peerId);
|
||||
// H-1: a leaving initiator strands the room's force-sync — release the
|
||||
// slot so a future controller's PREPARE can take over cleanly.
|
||||
// Release the post-demotion exemption, but retain the validated target:
|
||||
// an authorized initiator may reconnect and finish the already-visible
|
||||
// choreography. A newer PREPARE still replaces it normally.
|
||||
if (room.forceSyncInitiator === peerId) room.forceSyncInitiator = null;
|
||||
if (room.hostPeerId === peerId) {
|
||||
// Owner left → reassign owner + fall back to 'everyone' so the room is
|
||||
@@ -290,11 +327,11 @@ function removePeerFromRoom(socketId, roomId, reason) {
|
||||
room.hostPeerId = nextPeerData ? nextPeerData.peerId : null;
|
||||
room.controlMode = CONTROL_MODES.EVERYONE;
|
||||
room.controllers = new Set(room.hostPeerId ? [room.hostPeerId] : []);
|
||||
io.to(roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room));
|
||||
if (notifyRemainingPeers) io.to(roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room));
|
||||
log('ROOM', `Owner left room ${roomId.substring(0, 3)}*** — fell back to 'everyone', new owner: ${room.hostPeerId}`);
|
||||
} else if (wasController) {
|
||||
// A co-host left → keep the mode, just broadcast the updated controller list.
|
||||
io.to(roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room));
|
||||
if (notifyRemainingPeers) io.to(roomId).emit(EVENTS.CONTROL_MODE, controlModePayload(room));
|
||||
log('ROOM', `Controller ${peerId} left room ${roomId.substring(0, 3)}***`);
|
||||
}
|
||||
}
|
||||
@@ -378,6 +415,7 @@ io.on('connection', (socket) => {
|
||||
const clientCapabilities = normalizeClientCapabilities(payload.clientCapabilities);
|
||||
|
||||
if (!roomId || !peerId) return; // Guard: empty or invalid after sanitization
|
||||
if (!socket.connected) return;
|
||||
|
||||
try {
|
||||
// Protocol check
|
||||
@@ -413,6 +451,7 @@ io.on('connection', (socket) => {
|
||||
let lockPromise = roomCreationLocks.get(roomId);
|
||||
if (lockPromise) {
|
||||
await lockPromise;
|
||||
if (!socket.connected) return;
|
||||
room = rooms.get(roomId);
|
||||
}
|
||||
if (!room) {
|
||||
@@ -445,7 +484,16 @@ io.on('connection', (socket) => {
|
||||
// controller's FORCE_SYNC_EXECUTE through the host-only gate — without
|
||||
// it, demoting a co-host mid-force-sync would drop their EXECUTE and
|
||||
// leave every peer stuck paused.
|
||||
forceSyncInitiator: null
|
||||
forceSyncInitiator: null,
|
||||
// Canonical Media State v1 is lazy, room-local and absent until
|
||||
// the first accepted command establishes a trustworthy position.
|
||||
mediaState: null,
|
||||
// PREPARE is choreography, not stable room intent. Retain its
|
||||
// validated target only so the matching EXECUTE can commit it.
|
||||
forceSyncTarget: null,
|
||||
// Distinguishes an unknown target after relay restart from a
|
||||
// transaction explicitly replaced by newer room playback.
|
||||
forceSyncSuperseded: false
|
||||
};
|
||||
rooms.set(roomId, room);
|
||||
createdByMe = true;
|
||||
@@ -465,6 +513,7 @@ io.on('connection', (socket) => {
|
||||
let peerLockPromise = peerJoinLocks.get(peerId);
|
||||
if (peerLockPromise) {
|
||||
await peerLockPromise;
|
||||
if (!socket.connected) return;
|
||||
room = rooms.get(roomId);
|
||||
if (!room) {
|
||||
socket.emit(EVENTS.ERROR, { message: "Room no longer exists" });
|
||||
@@ -475,6 +524,7 @@ io.on('connection', (socket) => {
|
||||
peerLockPromise = new Promise(resolve => { resolvePeerLock = resolve; });
|
||||
peerJoinLocks.set(peerId, peerLockPromise);
|
||||
try {
|
||||
if (!socket.connected) return;
|
||||
if (!createdByMe) {
|
||||
if (room.passwordHash) {
|
||||
if (!password || hashPassword(password) !== room.passwordHash) {
|
||||
@@ -528,6 +578,7 @@ io.on('connection', (socket) => {
|
||||
peerToSocket.set(peerId, socket.id);
|
||||
|
||||
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, username: username || null, tabTitle: tabTitle || null, mediaTitle: mediaTitle || null, status: 'joined' });
|
||||
const snapshotAt = Date.now();
|
||||
socket.emit(EVENTS.ROOM_DATA, {
|
||||
roomId,
|
||||
peers: Array.from(room.peers).map(sid => room.peerData.get(sid)),
|
||||
@@ -535,6 +586,7 @@ io.on('connection', (socket) => {
|
||||
hostPeerId: room.hostPeerId || null,
|
||||
controlMode: room.controlMode || CONTROL_MODES.EVERYONE,
|
||||
controllers: room.controllers ? Array.from(room.controllers) : [],
|
||||
mediaState: snapshotMediaState(room.mediaState, snapshotAt),
|
||||
capabilities: SERVER_CAPABILITIES
|
||||
});
|
||||
log('ROOM', `Peer ${peerId} joined: ${roomId.substring(0, 3)}***`);
|
||||
@@ -581,17 +633,15 @@ io.on('connection', (socket) => {
|
||||
// a controller (the owner + any promoted co-hosts). Robust chokepoint:
|
||||
// independent of client behavior, kills spam. Heartbeats/ACKs pass.
|
||||
//
|
||||
// H-1 exception: a demoted co-host's FORCE_SYNC_EXECUTE still has to
|
||||
// land — otherwise their already-relayed PREPARE would leave the whole
|
||||
// room stuck paused. Track the in-flight initiator on PREPARE and let
|
||||
// their matching EXECUTE through regardless of current controllers set.
|
||||
if (eventName === EVENTS.FORCE_SYNC_PREPARE &&
|
||||
room.controlMode === CONTROL_MODES.HOST_ONLY &&
|
||||
room.controllers && room.controllers.has(mapping.peerId)) {
|
||||
room.forceSyncInitiator = mapping.peerId;
|
||||
}
|
||||
const isOwnForceSyncExecute = eventName === EVENTS.FORCE_SYNC_EXECUTE &&
|
||||
room.forceSyncInitiator && mapping.peerId === room.forceSyncInitiator;
|
||||
// H-1 exception: the latest valid PREPARE initiator's
|
||||
// FORCE_SYNC_EXECUTE still has to land after demotion —
|
||||
// otherwise the already-relayed room-wide choreography
|
||||
// would leave peers paused.
|
||||
const forceSyncInitiator = room.forceSyncTarget?.initiatorPeerId
|
||||
|| room.forceSyncInitiator;
|
||||
const isOwnForceSyncExecute = eventName === EVENTS.FORCE_SYNC_EXECUTE
|
||||
&& forceSyncInitiator
|
||||
&& mapping.peerId === forceSyncInitiator;
|
||||
if (!isOwnForceSyncExecute &&
|
||||
room.controlMode === CONTROL_MODES.HOST_ONLY &&
|
||||
!(room.controllers && room.controllers.has(mapping.peerId)) &&
|
||||
@@ -599,17 +649,35 @@ io.on('connection', (socket) => {
|
||||
log('ROOM', `Dropped ${eventName} from guest ${mapping.peerId} in host-only room ${mapping.roomId.substring(0, 3)}***`);
|
||||
return;
|
||||
}
|
||||
// Clear initiator tracking once the EXECUTE has been relayed.
|
||||
if (eventName === EVENTS.FORCE_SYNC_EXECUTE && room.forceSyncInitiator) {
|
||||
room.forceSyncInitiator = null;
|
||||
}
|
||||
|
||||
// --- S-2 & S-3: Sanitize ALL relay fields (strings, numbers, booleans) ---
|
||||
const clamp = (val, max) => typeof val === 'string' ? val.substring(0, max) : undefined;
|
||||
const clampNum = (val, min, max) => typeof val === 'number' && Number.isFinite(val) ? Math.max(min, Math.min(max, val)) : undefined;
|
||||
const validState = (val) => (val === 'playing' || val === 'paused') ? val : undefined;
|
||||
const validBool = (val) => typeof val === 'boolean' ? val : undefined;
|
||||
|
||||
const hasSequenceField = data.seq !== undefined;
|
||||
const sequence = Number.isSafeInteger(data.seq) && data.seq >= 0
|
||||
? data.seq
|
||||
: undefined;
|
||||
if (SEQUENCED_ROOM_EVENTS.has(eventName)) {
|
||||
if (hasSequenceField && sequence === undefined) {
|
||||
log('ROOM', `Dropped ${eventName} with invalid seq from ${mapping.peerId}`);
|
||||
return;
|
||||
}
|
||||
if (sequence !== undefined) {
|
||||
if (socket.data.mediaSequencePeerId !== mapping.peerId) {
|
||||
socket.data.mediaSequencePeerId = mapping.peerId;
|
||||
socket.data.lastMediaSequence = null;
|
||||
}
|
||||
if (Number.isSafeInteger(socket.data.lastMediaSequence)
|
||||
&& sequence <= socket.data.lastMediaSequence) {
|
||||
log('ROOM', `Dropped stale ${eventName} from ${mapping.peerId} (seq ${sequence} <= ${socket.data.lastMediaSequence})`);
|
||||
return;
|
||||
}
|
||||
socket.data.lastMediaSequence = sequence;
|
||||
}
|
||||
}
|
||||
|
||||
const existing = room.peerData.get(socket.id) || { peerId: mapping.peerId };
|
||||
room.peerData.set(socket.id, {
|
||||
...existing,
|
||||
@@ -617,7 +685,7 @@ io.on('connection', (socket) => {
|
||||
tabTitle: data.tabTitle === null ? null : (data.tabTitle !== undefined ? (clamp(data.tabTitle, 100) ?? existing.tabTitle) : existing.tabTitle),
|
||||
mediaTitle: data.mediaTitle === null ? null : (data.mediaTitle !== undefined ? (clamp(data.mediaTitle, 100) ?? existing.mediaTitle) : existing.mediaTitle),
|
||||
playbackState: data.playbackState !== undefined ? (validState(data.playbackState) ?? existing.playbackState) : existing.playbackState,
|
||||
currentTime: data.currentTime === null ? null : (data.currentTime !== undefined ? (clampNum(data.currentTime, 0, 86400) ?? existing.currentTime) : existing.currentTime),
|
||||
currentTime: data.currentTime === null ? null : (data.currentTime !== undefined ? (clampNum(data.currentTime, 0, MAX_MEDIA_TIME) ?? existing.currentTime) : existing.currentTime),
|
||||
volume: data.volume !== undefined ? (clampNum(data.volume, 0, 1) ?? existing.volume) : existing.volume,
|
||||
muted: data.muted !== undefined ? (validBool(data.muted) ?? existing.muted) : existing.muted,
|
||||
desynced: data.desynced !== undefined ? (validBool(data.desynced) === true) : (existing.desynced || false),
|
||||
@@ -627,9 +695,9 @@ io.on('connection', (socket) => {
|
||||
// --- S-3: Construct clean relay payload — never forward raw client data ---
|
||||
const relayPayload = {
|
||||
senderId: mapping.peerId,
|
||||
seq: clampNum(data.seq, 0, Number.MAX_SAFE_INTEGER),
|
||||
currentTime: data.currentTime === null ? null : clampNum(data.currentTime, 0, 86400),
|
||||
targetTime: clampNum(data.targetTime, 0, 86400),
|
||||
seq: sequence,
|
||||
currentTime: data.currentTime === null ? null : clampNum(data.currentTime, 0, MAX_MEDIA_TIME),
|
||||
targetTime: clampNum(data.targetTime, 0, MAX_MEDIA_TIME),
|
||||
playbackState: validState(data.playbackState),
|
||||
username: clamp(data.username, 30),
|
||||
tabTitle: data.tabTitle === null ? null : clamp(data.tabTitle, 100),
|
||||
@@ -645,10 +713,120 @@ io.on('connection', (socket) => {
|
||||
};
|
||||
// Strip undefined keys for clean wire format
|
||||
Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]);
|
||||
|
||||
// The first live lobby owns the room until completion or
|
||||
// cancellation. Drop concurrent lobby starts and stale ready
|
||||
// frames instead of letting clients build divergent lobbies.
|
||||
if (eventName === EVENTS.EPISODE_LOBBY && room.activeLobby) {
|
||||
log('ROOM', `Dropped competing episode lobby from ${mapping.peerId}`);
|
||||
socket.emit(EVENTS.EPISODE_LOBBY, {
|
||||
senderId: room.activeLobby.initiatorPeerId,
|
||||
peerId: room.activeLobby.initiatorPeerId,
|
||||
expectedTitle: room.activeLobby.expectedTitle,
|
||||
readyPeers: [...room.activeLobby.readyPeers],
|
||||
authoritative: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (eventName === EVENTS.EPISODE_LOBBY && !relayPayload.expectedTitle) {
|
||||
log('ROOM', `Dropped malformed episode lobby from ${mapping.peerId}`);
|
||||
return;
|
||||
}
|
||||
if (eventName === EVENTS.EPISODE_READY) {
|
||||
if (!room.activeLobby) {
|
||||
log('ROOM', `Dropped stale episode ready from ${mapping.peerId}`);
|
||||
return;
|
||||
}
|
||||
if (relayPayload.expectedTitle
|
||||
&& relayPayload.expectedTitle !== room.activeLobby.expectedTitle) {
|
||||
log('ROOM', `Dropped episode ready for an obsolete lobby from ${mapping.peerId}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const mediaStateNow = Date.now();
|
||||
|
||||
// Canonical Media State v1: mutate only after rate limiting,
|
||||
// room mapping, Host Control authorization and sanitization.
|
||||
// Heartbeats remain observational and never enter this path.
|
||||
const canonicalStateUpdated = updateMediaStateFromControl(
|
||||
room,
|
||||
eventName,
|
||||
relayPayload,
|
||||
mapping.peerId,
|
||||
{
|
||||
now: mediaStateNow,
|
||||
senderPlaybackState: existing.playbackState,
|
||||
senderMediaTitle: room.peerData.get(socket.id)?.mediaTitle
|
||||
}
|
||||
);
|
||||
const validLobbyTransition = (eventName === EVENTS.EPISODE_LOBBY
|
||||
&& typeof relayPayload.expectedTitle === 'string'
|
||||
&& relayPayload.expectedTitle.length > 0)
|
||||
|| eventName === EVENTS.EPISODE_LOBBY_CANCEL;
|
||||
if (canonicalStateUpdated || validLobbyTransition) {
|
||||
// A later room-driving action supersedes unfinished Force
|
||||
// Sync choreography. Do not let a delayed EXECUTE commit
|
||||
// an obsolete target after peers have moved elsewhere.
|
||||
room.forceSyncSuperseded = true;
|
||||
room.forceSyncInitiator = null;
|
||||
room.forceSyncTarget = null;
|
||||
}
|
||||
if (eventName === EVENTS.FORCE_SYNC_PREPARE) {
|
||||
// A malformed PREPARE must neither pause peers nor grant
|
||||
// the initiator a later Host Control EXECUTE exemption.
|
||||
if (!Number.isFinite(relayPayload.targetTime)) {
|
||||
log('ROOM', `Dropped invalid force_sync_prepare from ${mapping.peerId}`);
|
||||
return;
|
||||
}
|
||||
// One room-wide choreography is visible on the legacy
|
||||
// wire. A newer PREPARE replaces the target every peer
|
||||
// most recently received. Track its initiator in every
|
||||
// control mode so an everyone -> host-only transition
|
||||
// cannot strand that already-authorized transaction.
|
||||
room.forceSyncInitiator = mapping.peerId;
|
||||
room.forceSyncSuperseded = false;
|
||||
room.forceSyncTarget = {
|
||||
initiatorPeerId: mapping.peerId,
|
||||
targetTime: relayPayload.targetTime,
|
||||
preparedAt: mediaStateNow,
|
||||
mediaTitle: room.peerData.get(socket.id)?.mediaTitle || null
|
||||
};
|
||||
} else if (eventName === EVENTS.FORCE_SYNC_EXECUTE) {
|
||||
const forceSyncTarget = room.forceSyncTarget;
|
||||
if (!forceSyncTarget && room.forceSyncSuperseded) {
|
||||
log('ROOM', `Dropped obsolete force_sync_execute after newer room playback from ${mapping.peerId}`);
|
||||
room.forceSyncInitiator = null;
|
||||
return;
|
||||
}
|
||||
if (forceSyncTarget) {
|
||||
const targetDelayed = !Number.isFinite(forceSyncTarget.preparedAt)
|
||||
|| mediaStateNow - forceSyncTarget.preparedAt > FORCE_SYNC_TARGET_DELAY_WARNING;
|
||||
if (targetDelayed) {
|
||||
log('ROOM', `Relaying delayed force_sync_execute from ${mapping.peerId} to release prepared peers`);
|
||||
}
|
||||
commitForceSyncMediaState(
|
||||
room,
|
||||
forceSyncTarget.targetTime,
|
||||
mapping.peerId,
|
||||
mediaStateNow,
|
||||
forceSyncTarget.mediaTitle
|
||||
);
|
||||
} else {
|
||||
// A relay restart loses transient PREPARE state while legacy
|
||||
// receivers can remain paused in their existing pages. Preserve
|
||||
// the old wire behavior, but do not invent a canonical target.
|
||||
log('ROOM', `Relaying force_sync_execute without server target from ${mapping.peerId}`);
|
||||
}
|
||||
room.forceSyncInitiator = null;
|
||||
room.forceSyncTarget = null;
|
||||
room.forceSyncSuperseded = false;
|
||||
}
|
||||
|
||||
socket.to(mapping.roomId).emit(eventName, relayPayload);
|
||||
|
||||
// --- Side-effects: Server-side Episode Lobby Tracking ---
|
||||
if (eventName === EVENTS.EPISODE_LOBBY && relayPayload.expectedTitle && !room.activeLobby) {
|
||||
if (eventName === EVENTS.EPISODE_LOBBY && relayPayload.expectedTitle) {
|
||||
room.activeLobby = {
|
||||
expectedTitle: relayPayload.expectedTitle,
|
||||
initiatorPeerId: mapping.peerId,
|
||||
@@ -969,7 +1147,7 @@ export function cleanupInactiveRooms(now = Date.now()) {
|
||||
for (const sid of Array.from(currentRoom.peers)) {
|
||||
const memberSocket = io.sockets?.sockets?.get(sid);
|
||||
if (memberSocket) memberSocket.leave(roomId);
|
||||
removePeerFromRoom(sid, roomId, 'room-timeout');
|
||||
removePeerFromRoom(sid, roomId, 'room-timeout', { notifyRemainingPeers: false });
|
||||
}
|
||||
rooms.delete(roomId);
|
||||
log('CLEANUP', `Deleted room ${roomId.substring(0, 3)}*** (Empty/Inactive)`);
|
||||
@@ -1032,7 +1210,8 @@ export async function stopServerForTests() {
|
||||
peerJoinLocks.clear();
|
||||
clearRateLimitMaps();
|
||||
healthResponseCache.clear();
|
||||
io.removeAllListeners();
|
||||
// Keep the server's connection handler installed so an E2E process can
|
||||
// stop and restart this singleton relay for a later isolated scenario.
|
||||
io.disconnectSockets(true);
|
||||
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0, leaveRoom: 0 });
|
||||
if (!httpServer.listening) return;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { EVENTS, MAX_MEDIA_TIME } from '../shared/constants.js';
|
||||
|
||||
function clampMediaTime(value) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
||||
return Math.max(0, Math.min(MAX_MEDIA_TIME, value));
|
||||
}
|
||||
|
||||
function normalizeMediaTitle(value) {
|
||||
if (typeof value !== 'string' || !value) return null;
|
||||
return value.substring(0, 100);
|
||||
}
|
||||
|
||||
export function effectiveMediaPosition(mediaState, now = Date.now()) {
|
||||
if (!mediaState) return null;
|
||||
const currentTime = clampMediaTime(mediaState.currentTime);
|
||||
if (currentTime === null) return null;
|
||||
if (mediaState.playbackState !== 'playing') return currentTime;
|
||||
const updatedAt = typeof mediaState.updatedAt === 'number' && Number.isFinite(mediaState.updatedAt)
|
||||
? mediaState.updatedAt
|
||||
: now;
|
||||
return clampMediaTime(currentTime + Math.max(0, now - updatedAt) / 1000);
|
||||
}
|
||||
|
||||
export function snapshotMediaState(mediaState, now = Date.now()) {
|
||||
if (!mediaState) return null;
|
||||
const currentTime = effectiveMediaPosition(mediaState, now);
|
||||
if (currentTime === null
|
||||
|| !Number.isSafeInteger(mediaState.revision)
|
||||
|| mediaState.revision < 1
|
||||
|| (mediaState.playbackState !== 'playing' && mediaState.playbackState !== 'paused')) {
|
||||
return null;
|
||||
}
|
||||
const snapshot = {
|
||||
revision: mediaState.revision,
|
||||
playbackState: mediaState.playbackState,
|
||||
currentTime,
|
||||
updatedBy: mediaState.updatedBy
|
||||
};
|
||||
const mediaTitle = normalizeMediaTitle(mediaState.mediaTitle);
|
||||
if (mediaTitle) snapshot.mediaTitle = mediaTitle;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function commitMediaState(room, playbackState, currentTime, updatedBy, now, mediaTitle = null) {
|
||||
const normalizedTime = clampMediaTime(currentTime);
|
||||
if (normalizedTime === null
|
||||
|| (playbackState !== 'playing' && playbackState !== 'paused')
|
||||
|| typeof updatedBy !== 'string'
|
||||
|| !updatedBy) {
|
||||
return false;
|
||||
}
|
||||
const nextState = {
|
||||
revision: (room.mediaState?.revision || 0) + 1,
|
||||
playbackState,
|
||||
currentTime: normalizedTime,
|
||||
updatedAt: now,
|
||||
updatedBy
|
||||
};
|
||||
const normalizedTitle = normalizeMediaTitle(mediaTitle);
|
||||
if (normalizedTitle) nextState.mediaTitle = normalizedTitle;
|
||||
room.mediaState = nextState;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function updateMediaStateFromControl(room, eventName, payload, senderPeerId, {
|
||||
now = Date.now(),
|
||||
senderPlaybackState = null,
|
||||
senderMediaTitle = null
|
||||
} = {}) {
|
||||
if (!room || !payload || typeof payload !== 'object') return false;
|
||||
const mediaTitle = payload.mediaTitle === null
|
||||
? null
|
||||
: (normalizeMediaTitle(payload.mediaTitle) || normalizeMediaTitle(senderMediaTitle));
|
||||
|
||||
if (eventName === EVENTS.PLAY || eventName === EVENTS.PAUSE) {
|
||||
const eventPosition = clampMediaTime(payload.currentTime);
|
||||
const currentTime = eventPosition ?? effectiveMediaPosition(room.mediaState, now);
|
||||
if (currentTime === null) return false;
|
||||
return commitMediaState(
|
||||
room,
|
||||
eventName === EVENTS.PLAY ? 'playing' : 'paused',
|
||||
currentTime,
|
||||
senderPeerId,
|
||||
now,
|
||||
mediaTitle
|
||||
);
|
||||
}
|
||||
|
||||
if (eventName === EVENTS.SEEK) {
|
||||
const targetTime = clampMediaTime(payload.targetTime) ?? clampMediaTime(payload.currentTime);
|
||||
const playbackState = room.mediaState?.playbackState || senderPlaybackState;
|
||||
if (targetTime === null) return false;
|
||||
return commitMediaState(room, playbackState, targetTime, senderPeerId, now, mediaTitle);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function commitForceSyncMediaState(room, targetTime, senderPeerId, now = Date.now(), mediaTitle = null) {
|
||||
return commitMediaState(room, 'playing', targetTime, senderPeerId, now, mediaTitle);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { EVENTS, MAX_MEDIA_TIME } from '../shared/constants.js';
|
||||
import {
|
||||
commitForceSyncMediaState,
|
||||
effectiveMediaPosition,
|
||||
snapshotMediaState,
|
||||
updateMediaStateFromControl
|
||||
} from './media-state.js';
|
||||
|
||||
function room(mediaState = null) {
|
||||
return { mediaState };
|
||||
}
|
||||
|
||||
describe('canonical media state', () => {
|
||||
it('starts null and snapshots null', () => {
|
||||
expect(snapshotMediaState(null, 1000)).toBeNull();
|
||||
});
|
||||
|
||||
it('projects playing state and clamps elapsed time', () => {
|
||||
const state = { revision: 1, playbackState: 'playing', currentTime: 10, updatedAt: 1000, updatedBy: 'a' };
|
||||
expect(effectiveMediaPosition(state, 3500)).toBe(12.5);
|
||||
expect(effectiveMediaPosition(state, 500)).toBe(10);
|
||||
expect(effectiveMediaPosition({ ...state, currentTime: MAX_MEDIA_TIME }, 3500)).toBe(MAX_MEDIA_TIME);
|
||||
});
|
||||
|
||||
it('keeps paused state fixed', () => {
|
||||
const state = { revision: 1, playbackState: 'paused', currentTime: 10, updatedAt: 1000, updatedBy: 'a' };
|
||||
expect(effectiveMediaPosition(state, 601000)).toBe(10);
|
||||
expect(snapshotMediaState(state, 601000)).toEqual({
|
||||
revision: 1,
|
||||
playbackState: 'paused',
|
||||
currentTime: 10,
|
||||
updatedBy: 'a'
|
||||
});
|
||||
});
|
||||
|
||||
it('initializes and updates PLAY with server-owned revisions', () => {
|
||||
const target = room();
|
||||
expect(updateMediaStateFromControl(target, EVENTS.PLAY, { currentTime: 12, revision: 999 }, 'a', { now: 1000 })).toBe(true);
|
||||
expect(target.mediaState).toEqual({ revision: 1, playbackState: 'playing', currentTime: 12, updatedAt: 1000, updatedBy: 'a' });
|
||||
expect(updateMediaStateFromControl(target, EVENTS.PLAY, { currentTime: 20 }, 'b', { now: 2000 })).toBe(true);
|
||||
expect(target.mediaState.revision).toBe(2);
|
||||
expect(target.mediaState.updatedBy).toBe('b');
|
||||
});
|
||||
|
||||
it('does not invent an initial PLAY position and preserves a known effective position', () => {
|
||||
const target = room();
|
||||
expect(updateMediaStateFromControl(target, EVENTS.PLAY, {}, 'a', { now: 1000 })).toBe(false);
|
||||
expect(target.mediaState).toBeNull();
|
||||
target.mediaState = { revision: 1, playbackState: 'playing', currentTime: 5, updatedAt: 1000, updatedBy: 'a' };
|
||||
expect(updateMediaStateFromControl(target, EVENTS.PLAY, {}, 'a', { now: 3000 })).toBe(true);
|
||||
expect(target.mediaState.currentTime).toBe(7);
|
||||
});
|
||||
|
||||
it('freezes PAUSE at its event or effective canonical position', () => {
|
||||
const target = room({ revision: 1, playbackState: 'playing', currentTime: 10, updatedAt: 1000, updatedBy: 'a' });
|
||||
expect(updateMediaStateFromControl(target, EVENTS.PAUSE, {}, 'a', { now: 4000 })).toBe(true);
|
||||
expect(target.mediaState).toEqual({ revision: 2, playbackState: 'paused', currentTime: 13, updatedAt: 4000, updatedBy: 'a' });
|
||||
expect(effectiveMediaPosition(target.mediaState, 9000)).toBe(13);
|
||||
});
|
||||
|
||||
it('uses SEEK targetTime, preserves playback state, and lets the second controller win', () => {
|
||||
const target = room({ revision: 3, playbackState: 'playing', currentTime: 10, updatedAt: 1000, updatedBy: 'a' });
|
||||
expect(updateMediaStateFromControl(target, EVENTS.SEEK, { currentTime: 50, targetTime: 100 }, 'a', { now: 2000 })).toBe(true);
|
||||
expect(target.mediaState).toMatchObject({ revision: 4, playbackState: 'playing', currentTime: 100, updatedBy: 'a' });
|
||||
expect(updateMediaStateFromControl(target, EVENTS.SEEK, { targetTime: 200 }, 'b', { now: 2001 })).toBe(true);
|
||||
expect(target.mediaState).toMatchObject({ revision: 5, currentTime: 200, updatedBy: 'b' });
|
||||
});
|
||||
|
||||
it('tracks only the current sender shared media title and honors an explicit privacy null', () => {
|
||||
const target = room();
|
||||
expect(updateMediaStateFromControl(
|
||||
target,
|
||||
EVENTS.PLAY,
|
||||
{ currentTime: 10, mediaTitle: 'Series S01E01' },
|
||||
'a',
|
||||
{ now: 1000 }
|
||||
)).toBe(true);
|
||||
expect(snapshotMediaState(target.mediaState, 1000).mediaTitle).toBe('Series S01E01');
|
||||
|
||||
expect(updateMediaStateFromControl(
|
||||
target,
|
||||
EVENTS.SEEK,
|
||||
{ targetTime: 20 },
|
||||
'b',
|
||||
{ now: 2000, senderMediaTitle: 'Series S01E02' }
|
||||
)).toBe(true);
|
||||
expect(target.mediaState.mediaTitle).toBe('Series S01E02');
|
||||
|
||||
expect(updateMediaStateFromControl(
|
||||
target,
|
||||
EVENTS.PAUSE,
|
||||
{ currentTime: 20, mediaTitle: null },
|
||||
'b',
|
||||
{ now: 3000, senderMediaTitle: 'stale S01E01' }
|
||||
)).toBe(true);
|
||||
expect(target.mediaState).not.toHaveProperty('mediaTitle');
|
||||
});
|
||||
|
||||
it('ignores client-supplied playback state while seeking', () => {
|
||||
const target = room({ revision: 3, playbackState: 'paused', currentTime: 10, updatedAt: 1000, updatedBy: 'a' });
|
||||
expect(updateMediaStateFromControl(
|
||||
target,
|
||||
EVENTS.SEEK,
|
||||
{ targetTime: 100, playbackState: 'playing' },
|
||||
'b',
|
||||
{ now: 2000, senderPlaybackState: 'playing' }
|
||||
)).toBe(true);
|
||||
expect(target.mediaState).toMatchObject({ revision: 4, playbackState: 'paused', currentTime: 100 });
|
||||
});
|
||||
|
||||
it('uses an observed sender state only to establish an otherwise ambiguous first SEEK', () => {
|
||||
const target = room();
|
||||
expect(updateMediaStateFromControl(target, EVENTS.SEEK, { targetTime: 50 }, 'a', { now: 1000 })).toBe(false);
|
||||
expect(updateMediaStateFromControl(target, EVENTS.SEEK, { targetTime: 50 }, 'a', { now: 1000, senderPlaybackState: 'paused' })).toBe(true);
|
||||
expect(target.mediaState).toMatchObject({ revision: 1, playbackState: 'paused', currentTime: 50 });
|
||||
});
|
||||
|
||||
it('rejects non-finite/missing controls without corruption and clamps existing protocol bounds', () => {
|
||||
const original = { revision: 2, playbackState: 'paused', currentTime: 30, updatedAt: 1000, updatedBy: 'a' };
|
||||
for (const payload of [{ targetTime: NaN }, { targetTime: Infinity }, { targetTime: '50' }, {}]) {
|
||||
const target = room({ ...original });
|
||||
expect(updateMediaStateFromControl(target, EVENTS.SEEK, payload, 'b', { now: 2000 })).toBe(false);
|
||||
expect(target.mediaState).toEqual(original);
|
||||
}
|
||||
const low = room({ ...original });
|
||||
updateMediaStateFromControl(low, EVENTS.SEEK, { targetTime: -5 }, 'b', { now: 2000 });
|
||||
expect(low.mediaState.currentTime).toBe(0);
|
||||
const high = room({ ...original });
|
||||
updateMediaStateFromControl(high, EVENTS.SEEK, { targetTime: MAX_MEDIA_TIME + 5 }, 'b', { now: 2000 });
|
||||
expect(high.mediaState.currentTime).toBe(MAX_MEDIA_TIME);
|
||||
});
|
||||
|
||||
it('commits Force Sync only at execute time', () => {
|
||||
const target = room({ revision: 4, playbackState: 'paused', currentTime: 90, updatedAt: 1000, updatedBy: 'a' });
|
||||
expect(commitForceSyncMediaState(target, 500, 'b', 2000, 'Series S02E03')).toBe(true);
|
||||
expect(target.mediaState).toEqual({
|
||||
revision: 5,
|
||||
playbackState: 'playing',
|
||||
currentTime: 500,
|
||||
updatedAt: 2000,
|
||||
updatedBy: 'b',
|
||||
mediaTitle: 'Series S02E03'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ Browser extensions cannot import files outside their own root directory, so the
|
||||
- `CONTROL_MODES.HOST_ONLY`: only the host and promoted controllers may move playback.
|
||||
- `CAPABILITIES.HOST_CONTROL`: relay supports host-only room authority.
|
||||
- `CAPABILITIES.CO_HOST`: relay supports promoted controller peers.
|
||||
- `CAPABILITIES.MEDIA_STATE_V1`: relay exposes canonical room playback recovery snapshots.
|
||||
|
||||
Clients should enable capability-gated UI only when the relay advertises the matching flag in `room_data.capabilities`.
|
||||
|
||||
@@ -63,7 +64,9 @@ For the complete event list, read the `EVENTS` object in [`constants.js`](consta
|
||||
|
||||
- `HEARTBEAT_INTERVAL`: content heartbeat interval in milliseconds.
|
||||
- `FORCE_SYNC_TIMEOUT`: max wait for force-sync ACKs.
|
||||
- `FORCE_SYNC_TARGET_DELAY_WARNING`: threshold for logging delayed Force Sync execution; an unsuperseded prepared target remains executable for receiver liveness.
|
||||
- `EPISODE_LOBBY_TIMEOUT`: max wait for episode-lobby readiness.
|
||||
- `MAX_MEDIA_TIME`: shared relay/extension upper bound, in seconds, for synchronized media positions.
|
||||
|
||||
## Do Not Break
|
||||
|
||||
|
||||
+8
-1
@@ -90,9 +90,16 @@ export const CAPABILITIES = {
|
||||
HOST_CONTROL: 'host-control',
|
||||
CO_HOST: 'co-host', // owner promotes guests to additional controllers
|
||||
CHAT: 'chat', // legacy server capability used by the first chat beta
|
||||
CHAT_V1: 'chat-v1' // versioned client/server chat wire contract
|
||||
CHAT_V1: 'chat-v1', // versioned client/server chat wire contract
|
||||
MEDIA_STATE_V1: 'media-state-v1' // server-authoritative room playback recovery snapshot
|
||||
};
|
||||
|
||||
// Relay and extension media-time validation must use the same upper bound.
|
||||
export const MAX_MEDIA_TIME = 86400;
|
||||
|
||||
export const HEARTBEAT_INTERVAL = 15000; // 15s
|
||||
export const FORCE_SYNC_TIMEOUT = 8500; // 8.5s timeout for force sync ACKs (must be > content.js poll timeout of 8s)
|
||||
// Log unexpectedly delayed EXECUTE delivery after the normal ACK wait plus
|
||||
// transport grace. The target remains valid until newer room playback replaces it.
|
||||
export const FORCE_SYNC_TARGET_DELAY_WARNING = FORCE_SYNC_TIMEOUT + 2000;
|
||||
export const EPISODE_LOBBY_TIMEOUT = 60000; // 60s timeout for episode lobby
|
||||
|
||||
@@ -21,6 +21,7 @@ npm run test:e2e:race # @race scenarios, repeated 20 times
|
||||
| `extension.spec.mjs` | Loads `dist/chrome`, injects into a tab, applies remote play/pause/seek |
|
||||
| `room-sync.spec.mjs` | Starts a local relay and proves two packed clients, relay restart, and MV3 worker recovery |
|
||||
| `popup-accessibility.spec.mjs` | Checks visible control names and keyboard tab activation in the real popup |
|
||||
| `global-setup.mjs` | Owns the fixture server for the complete Playwright run and closes it during teardown |
|
||||
| `fixture-server.mjs` | Static server for the fixtures, with byte-range support for media |
|
||||
| `fixtures/pages/` | One page per scenario |
|
||||
| `fixtures/media/` | Small generated clips (see below) |
|
||||
@@ -32,6 +33,11 @@ Chrome MV3 APIs and a persistent service-worker context. The scheduled
|
||||
`.github/workflows/race-tests.yml` lane repeats tests marked `@race` and uploads
|
||||
traces/results on failure.
|
||||
|
||||
Locally, global setup reuses an already running fixture server on the configured
|
||||
port. CI always owns a fresh server so a port collision fails instead of testing
|
||||
against an unknown process. Keeping the server inside Playwright's lifecycle
|
||||
also prevents an orphaned `node` process from blocking teardown on Windows.
|
||||
|
||||
## Two rules worth keeping
|
||||
|
||||
**The specs run the shipped source, not a copy.** `helpers/content-source.mjs`
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { test, expect } from './helpers/extension-fixture.mjs';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION } from '../../shared/constants.js';
|
||||
|
||||
const testDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(path.join(testDir, '..', '..', 'server', 'package.json'));
|
||||
const NodeWebSocket = require('ws');
|
||||
|
||||
/**
|
||||
* Drives the packed extension itself: real background service worker, real
|
||||
@@ -10,7 +18,9 @@ import { test, expect } from './helpers/extension-fixture.mjs';
|
||||
/** Runs code in an extension page, where the privileged chrome.* APIs exist. */
|
||||
async function withExtensionPage(context, extensionId, fn) {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`chrome-extension://${extensionId}/popup.html`);
|
||||
// popup.html performs normal connection/settings initialization. Using it as
|
||||
// a test transport can race and overwrite a just-seeded custom relay URL.
|
||||
await page.goto(`chrome-extension://${extensionId}/audio-options.html`);
|
||||
const result = await fn(page);
|
||||
await page.close();
|
||||
return result;
|
||||
@@ -54,6 +64,109 @@ async function getExtensionState(context, extensionId, message) {
|
||||
));
|
||||
}
|
||||
|
||||
async function expectConnectedRoom(context, extensionId, roomId, expected = {}) {
|
||||
try {
|
||||
await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' }))
|
||||
.toMatchObject({ status: 'connected', roomId, ...expected });
|
||||
} catch (error) {
|
||||
const [status, logs] = await Promise.all([
|
||||
getExtensionState(context, extensionId, { type: 'GET_STATUS' }).catch(() => null),
|
||||
getExtensionState(context, extensionId, { type: 'GET_LOGS' }).catch(() => [])
|
||||
]);
|
||||
console.error(`Extension connection diagnostics: ${JSON.stringify({ status, logs: logs?.slice?.(0, 20) || logs })}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyCanonicalMediaState(context, extensionId, tabId, mediaState) {
|
||||
return withExtensionPage(context, extensionId, page => page.evaluate(async ({ tabId, mediaState }) => {
|
||||
return chrome.tabs.sendMessage(tabId, {
|
||||
type: 'APPLY_CANONICAL_MEDIA_STATE',
|
||||
mediaState
|
||||
});
|
||||
}, { tabId, mediaState }));
|
||||
}
|
||||
|
||||
async function sendContentServerCommand(context, extensionId, tabId, action, payload = {}) {
|
||||
return withExtensionPage(context, extensionId, page => page.evaluate(async ({ tabId, action, payload }) => {
|
||||
return chrome.tabs.sendMessage(tabId, {
|
||||
type: 'SERVER_COMMAND',
|
||||
action,
|
||||
payload,
|
||||
actionTimestamp: Date.now(),
|
||||
commandSenderId: 'e2e-newer-command'
|
||||
});
|
||||
}, { tabId, action, payload }));
|
||||
}
|
||||
|
||||
async function connectLegacyRelayClient(port) {
|
||||
const socket = new NodeWebSocket(
|
||||
`ws://127.0.0.1:${port}/socket.io/?EIO=4&transport=websocket&version=3.1.3&token=${OFFICIAL_SERVER_TOKEN}`
|
||||
);
|
||||
socket.messages = [];
|
||||
socket.on('message', value => socket.messages.push(value.toString()));
|
||||
await new Promise((resolve, reject) => {
|
||||
let timeout;
|
||||
const onError = error => {
|
||||
clearTimeout(timeout);
|
||||
socket.off('open', onOpen);
|
||||
reject(new Error(`legacy relay connection failed: ${error.message}`));
|
||||
};
|
||||
const onOpen = () => {
|
||||
clearTimeout(timeout);
|
||||
socket.off('error', onError);
|
||||
resolve();
|
||||
};
|
||||
timeout = setTimeout(() => {
|
||||
socket.off('open', onOpen);
|
||||
socket.off('error', onError);
|
||||
reject(new Error('legacy relay connection timed out'));
|
||||
}, 5000);
|
||||
socket.once('error', onError);
|
||||
socket.once('open', onOpen);
|
||||
});
|
||||
socket.send('40');
|
||||
await expect.poll(() => socket.messages.filter(message => message.startsWith('0') || message.startsWith('40')).length).toBeGreaterThanOrEqual(2);
|
||||
socket.messages.length = 0;
|
||||
return socket;
|
||||
}
|
||||
|
||||
function sendLegacyRelayEvent(socket, event, data = {}) {
|
||||
socket.send(`42${JSON.stringify([event, data])}`);
|
||||
}
|
||||
|
||||
async function waitForLegacyRelayEvent(socket, event, timeoutMs = 10_000) {
|
||||
await expect.poll(() => socket.messages.some(message => {
|
||||
if (!message.startsWith('42')) return false;
|
||||
try { return JSON.parse(message.substring(2))[0] === event; } catch { return false; }
|
||||
}), { timeout: timeoutMs }).toBe(true);
|
||||
const index = socket.messages.findIndex(message => {
|
||||
if (!message.startsWith('42')) return false;
|
||||
try { return JSON.parse(message.substring(2))[0] === event; } catch { return false; }
|
||||
});
|
||||
return JSON.parse(socket.messages.splice(index, 1)[0].substring(2))[1];
|
||||
}
|
||||
|
||||
async function joinLegacyRelayRoom(socket, roomId, peerId) {
|
||||
sendLegacyRelayEvent(socket, 'join_room', { roomId, peerId, protocolVersion: PROTOCOL_VERSION });
|
||||
return waitForLegacyRelayEvent(socket, 'room_data');
|
||||
}
|
||||
|
||||
async function terminateExtensionServiceWorker(context, extensionId, page) {
|
||||
const session = await context.newCDPSession(page);
|
||||
try {
|
||||
const { targetInfos } = await session.send('Target.getTargets');
|
||||
const target = targetInfos.find(candidate =>
|
||||
candidate.type === 'service_worker'
|
||||
&& candidate.url.startsWith(`chrome-extension://${extensionId}/`)
|
||||
);
|
||||
if (!target) throw new Error('extension service worker target not found');
|
||||
await session.send('Target.closeTarget', { targetId: target.targetId });
|
||||
} finally {
|
||||
await session.detach();
|
||||
}
|
||||
}
|
||||
|
||||
async function setAudioSettings(context, extensionId, settings) {
|
||||
return withExtensionPage(context, extensionId, page => page.evaluate(
|
||||
value => chrome.storage.local.set({ audioSettings: value }),
|
||||
@@ -263,6 +376,462 @@ test('applies remote play, pause and seek to the framed player', async ({ contex
|
||||
).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
test('applies canonical recovery without echoing media commands or activity', async ({ context, extensionId, baseURL }) => {
|
||||
const url = `${baseURL}/pages/simple-player.html`;
|
||||
const page = await context.newPage();
|
||||
await page.goto(url);
|
||||
await page.waitForFunction(() => window.__fixtureReady === true);
|
||||
|
||||
const { tabId } = await selectTargetTab(context, extensionId, url);
|
||||
await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true');
|
||||
const historyBefore = await getExtensionState(context, extensionId, { type: 'GET_HISTORY' });
|
||||
|
||||
const playingApply = await applyCanonicalMediaState(context, extensionId, tabId, {
|
||||
revision: 10,
|
||||
playbackState: 'playing',
|
||||
currentTime: 6,
|
||||
updatedBy: 'peer-a'
|
||||
});
|
||||
expect(playingApply).toMatchObject({ status: 'applied', revision: 10 });
|
||||
await expect.poll(() => page.locator('#player').evaluate(video => ({
|
||||
paused: video.paused,
|
||||
currentTime: video.currentTime
|
||||
}))).toMatchObject({ paused: false });
|
||||
await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime)).toBeGreaterThan(5);
|
||||
|
||||
const pausedApply = await applyCanonicalMediaState(context, extensionId, tabId, {
|
||||
revision: 11,
|
||||
playbackState: 'paused',
|
||||
currentTime: 10,
|
||||
updatedBy: 'peer-a'
|
||||
});
|
||||
expect(pausedApply).toMatchObject({ status: 'applied', revision: 11 });
|
||||
await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(true);
|
||||
await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime)).toBeGreaterThan(9);
|
||||
|
||||
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async selectedTabId => {
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId: selectedTabId },
|
||||
world: 'ISOLATED',
|
||||
func: () => {
|
||||
navigator.mediaSession.metadata = new globalThis.MediaMetadata({ title: 'Series S01E02' });
|
||||
}
|
||||
});
|
||||
}, tabId));
|
||||
const mismatchedApply = await applyCanonicalMediaState(context, extensionId, tabId, {
|
||||
revision: 12,
|
||||
playbackState: 'playing',
|
||||
currentTime: 2,
|
||||
updatedBy: 'peer-a',
|
||||
mediaTitle: 'Series S01E01'
|
||||
});
|
||||
expect(mismatchedApply).toMatchObject({ status: 'ignored_episode_mismatch' });
|
||||
await expect.poll(() => page.locator('#player').evaluate(video => ({
|
||||
paused: video.paused,
|
||||
currentTime: video.currentTime
|
||||
}))).toMatchObject({ paused: true });
|
||||
expect(await page.locator('#player').evaluate(video => video.currentTime)).toBeGreaterThan(9);
|
||||
|
||||
// Wait past seek debounce and play/pause coalescing windows. A leaked native
|
||||
// echo would have reached background.js and appeared as user activity by now.
|
||||
await page.waitForTimeout(700);
|
||||
const historyAfter = await getExtensionState(context, extensionId, { type: 'GET_HISTORY' });
|
||||
expect(historyAfter).toEqual(historyBefore);
|
||||
});
|
||||
|
||||
test('local media input cancels an in-flight canonical recovery', async ({ context, extensionId, baseURL }) => {
|
||||
const url = `${baseURL}/pages/simple-player.html`;
|
||||
const page = await context.newPage();
|
||||
await page.goto(url);
|
||||
await page.waitForFunction(() => window.__fixtureReady === true);
|
||||
|
||||
const { tabId } = await selectTargetTab(context, extensionId, url);
|
||||
await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true');
|
||||
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async selectedTabId => {
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId: selectedTabId },
|
||||
world: 'ISOLATED',
|
||||
func: () => {
|
||||
const video = document.querySelector('#player');
|
||||
if (!video) throw new Error('canonical local-supersession fixture video missing');
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
const nativePlay = video.play.bind(video);
|
||||
video.dataset.koalaDelayedPlayAttempts = '0';
|
||||
Object.defineProperty(video, 'play', {
|
||||
configurable: true,
|
||||
value: () => {
|
||||
const attempts = Number(video.dataset.koalaDelayedPlayAttempts || '0') + 1;
|
||||
video.dataset.koalaDelayedPlayAttempts = String(attempts);
|
||||
return nativePlay().then(() => new Promise((resolve, reject) => setTimeout(() => {
|
||||
if (video.paused) {
|
||||
const error = new Error('play interrupted by local pause');
|
||||
error.name = 'AbortError';
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
}, 400)));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}, tabId));
|
||||
|
||||
let applyResponse = null;
|
||||
const applyPromise = applyCanonicalMediaState(context, extensionId, tabId, {
|
||||
revision: 20,
|
||||
playbackState: 'playing',
|
||||
currentTime: 6,
|
||||
updatedBy: 'peer-a'
|
||||
}).then(response => {
|
||||
applyResponse = response;
|
||||
return response;
|
||||
});
|
||||
await expect.poll(async () => ({
|
||||
attempts: await page.locator('#player').evaluate(video =>
|
||||
Number(video.dataset.koalaDelayedPlayAttempts || '0')),
|
||||
response: applyResponse
|
||||
})).toMatchObject({ attempts: 1, response: null });
|
||||
await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(false);
|
||||
|
||||
await page.locator('#player').evaluate(video => {
|
||||
video.pause();
|
||||
video.currentTime = 10;
|
||||
});
|
||||
await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime))
|
||||
.toBeGreaterThan(9);
|
||||
|
||||
await expect(applyPromise).resolves.toMatchObject({ status: 'superseded' });
|
||||
await page.waitForTimeout(700);
|
||||
expect(await page.locator('#player').evaluate(video => video.paused)).toBe(true);
|
||||
expect(await page.locator('#player').evaluate(video => video.currentTime)).toBeGreaterThan(9);
|
||||
});
|
||||
|
||||
test('newer server command clears local recovery state before a delayed apply resolves', async ({ context, extensionId, baseURL }) => {
|
||||
const url = `${baseURL}/pages/simple-player.html`;
|
||||
const page = await context.newPage();
|
||||
await page.goto(url);
|
||||
await page.waitForFunction(() => window.__fixtureReady === true);
|
||||
|
||||
const { tabId } = await selectTargetTab(context, extensionId, url);
|
||||
await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true');
|
||||
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async selectedTabId => {
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId: selectedTabId },
|
||||
world: 'ISOLATED',
|
||||
func: () => {
|
||||
const video = document.querySelector('#player');
|
||||
if (!video) throw new Error('canonical server-supersession fixture video missing');
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
const nativePlay = video.play.bind(video);
|
||||
video.dataset.koalaDelayedPlayAttempts = '0';
|
||||
Object.defineProperty(video, 'play', {
|
||||
configurable: true,
|
||||
value: () => {
|
||||
const attempts = Number(video.dataset.koalaDelayedPlayAttempts || '0') + 1;
|
||||
video.dataset.koalaDelayedPlayAttempts = String(attempts);
|
||||
return nativePlay().then(() => new Promise(resolve => setTimeout(resolve, 400)));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}, tabId));
|
||||
|
||||
let applyResponse = null;
|
||||
const applyPromise = applyCanonicalMediaState(context, extensionId, tabId, {
|
||||
revision: 21,
|
||||
playbackState: 'playing',
|
||||
currentTime: 6,
|
||||
updatedBy: 'peer-a'
|
||||
}).then(response => {
|
||||
applyResponse = response;
|
||||
return response;
|
||||
});
|
||||
await expect.poll(async () => ({
|
||||
attempts: await page.locator('#player').evaluate(video =>
|
||||
Number(video.dataset.koalaDelayedPlayAttempts || '0')),
|
||||
response: applyResponse
|
||||
})).toMatchObject({ attempts: 1, response: null });
|
||||
await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(false);
|
||||
|
||||
await page.locator('#player').evaluate(video => {
|
||||
video.pause();
|
||||
video.currentTime = 10;
|
||||
});
|
||||
await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(true);
|
||||
await sendContentServerCommand(context, extensionId, tabId, 'play');
|
||||
|
||||
await expect(applyPromise).resolves.toMatchObject({ status: 'superseded' });
|
||||
await page.waitForTimeout(700);
|
||||
expect(await page.locator('#player').evaluate(video =>
|
||||
Number(video.dataset.koalaDelayedPlayAttempts || '0'))).toBe(2);
|
||||
expect(await page.locator('#player').evaluate(video => video.paused)).toBe(false);
|
||||
});
|
||||
|
||||
test('newer remote pause wins after a stale restoration play settles late', async ({ context, extensionId, baseURL }) => {
|
||||
const url = `${baseURL}/pages/simple-player.html`;
|
||||
const page = await context.newPage();
|
||||
await page.goto(url);
|
||||
await page.waitForFunction(() => window.__fixtureReady === true);
|
||||
|
||||
const { tabId } = await selectTargetTab(context, extensionId, url);
|
||||
await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true');
|
||||
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async selectedTabId => {
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId: selectedTabId },
|
||||
world: 'ISOLATED',
|
||||
func: () => {
|
||||
const video = document.querySelector('#player');
|
||||
if (!video) throw new Error('late restoration fixture video missing');
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
const nativePlay = video.play.bind(video);
|
||||
video.dataset.koalaDelayedPlayAttempts = '0';
|
||||
Object.defineProperty(video, 'play', {
|
||||
configurable: true,
|
||||
value: () => {
|
||||
const attempt = Number(video.dataset.koalaDelayedPlayAttempts || '0') + 1;
|
||||
video.dataset.koalaDelayedPlayAttempts = String(attempt);
|
||||
if (attempt === 1) {
|
||||
return nativePlay().then(() => new Promise(resolve => setTimeout(resolve, 200)));
|
||||
}
|
||||
return new Promise((resolve, reject) => setTimeout(() => {
|
||||
nativePlay().then(resolve, reject);
|
||||
}, 400));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}, tabId));
|
||||
|
||||
const applyPromise = applyCanonicalMediaState(context, extensionId, tabId, {
|
||||
revision: 22,
|
||||
playbackState: 'playing',
|
||||
currentTime: 6,
|
||||
updatedBy: 'peer-a'
|
||||
});
|
||||
await expect.poll(() => page.locator('#player').evaluate(video =>
|
||||
Number(video.dataset.koalaDelayedPlayAttempts || '0'))).toBe(1);
|
||||
|
||||
// Capture a superseding play intent while leaving the fixture paused, so
|
||||
// stale recovery has to enter its delayed restoration play path.
|
||||
await page.locator('#player').evaluate(video => {
|
||||
video.pause();
|
||||
});
|
||||
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(
|
||||
selectedTabId => chrome.tabs.sendMessage(selectedTabId, {
|
||||
type: 'CANCEL_CANONICAL_MEDIA_STATE',
|
||||
reason: 'local play',
|
||||
action: 'play',
|
||||
payload: { currentTime: 0 }
|
||||
}),
|
||||
tabId
|
||||
));
|
||||
await expect.poll(() => page.locator('#player').evaluate(video =>
|
||||
Number(video.dataset.koalaDelayedPlayAttempts || '0'))).toBe(2);
|
||||
|
||||
await sendContentServerCommand(context, extensionId, tabId, 'pause');
|
||||
await expect(applyPromise).resolves.toMatchObject({ status: 'superseded' });
|
||||
await page.waitForTimeout(500);
|
||||
expect(await page.locator('#player').evaluate(video => video.paused)).toBe(true);
|
||||
});
|
||||
|
||||
test('recovers relay ROOM_DATA through background retries into the packed player', async ({ context, extensionId, baseURL }) => {
|
||||
test.setTimeout(45_000);
|
||||
const relay = await import('../../server/index.js');
|
||||
let legacy = null;
|
||||
try {
|
||||
await relay.startServer(0, '127.0.0.1');
|
||||
const port = relay.httpServer.address().port;
|
||||
const roomId = `e2e-canonical-room-data-${Date.now()}`;
|
||||
legacy = await connectLegacyRelayClient(port);
|
||||
await joinLegacyRelayRoom(legacy, roomId, 'canonical-source');
|
||||
sendLegacyRelayEvent(legacy, 'play', { currentTime: 6, seq: 1, actionTimestamp: 1 });
|
||||
await expect.poll(() => relay.rooms.get(roomId)?.mediaState)
|
||||
.toMatchObject({ revision: 1, playbackState: 'playing', currentTime: 6 });
|
||||
|
||||
const url = `${baseURL}/pages/simple-player.html`;
|
||||
const page = await context.newPage();
|
||||
await page.goto(url);
|
||||
await page.waitForFunction(() => window.__fixtureReady === true);
|
||||
const { tabId } = await selectTargetTab(context, extensionId, url);
|
||||
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async selectedTabId => {
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId: selectedTabId },
|
||||
world: 'ISOLATED',
|
||||
func: () => {
|
||||
const video = document.querySelector('#player');
|
||||
if (!video) throw new Error('canonical recovery fixture video missing');
|
||||
const nativePlay = video.play.bind(video);
|
||||
video.dataset.koalaCanonicalPlayAttempts = '0';
|
||||
Object.defineProperty(video, 'play', {
|
||||
configurable: true,
|
||||
value: () => {
|
||||
const attempts = Number(video.dataset.koalaCanonicalPlayAttempts || '0') + 1;
|
||||
video.dataset.koalaCanonicalPlayAttempts = String(attempts);
|
||||
if (attempts === 1) {
|
||||
return Promise.reject(Object.assign(
|
||||
new Error('audit autoplay rejection'),
|
||||
{ name: 'NotAllowedError' }
|
||||
));
|
||||
}
|
||||
return nativePlay();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}, tabId));
|
||||
legacy.messages.length = 0;
|
||||
|
||||
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async settings => {
|
||||
await chrome.storage.local.set(settings);
|
||||
return chrome.runtime.sendMessage({ type: 'CONNECT' });
|
||||
}, {
|
||||
serverUrl: `ws://127.0.0.1:${port}`,
|
||||
useCustomServer: true,
|
||||
roomId,
|
||||
password: '',
|
||||
username: 'canonical-receiver'
|
||||
}));
|
||||
|
||||
await expectConnectedRoom(context, extensionId, roomId, { queuedLogicalEvents: 0 });
|
||||
await expect.poll(() => page.locator('#player').evaluate(video =>
|
||||
Number(video.dataset.koalaCanonicalPlayAttempts || '0')))
|
||||
.toBeGreaterThanOrEqual(2);
|
||||
await expect.poll(() => page.locator('#player').evaluate(video => ({
|
||||
paused: video.paused,
|
||||
currentTime: video.currentTime
|
||||
}))).toMatchObject({ paused: false });
|
||||
await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime))
|
||||
.toBeGreaterThan(5);
|
||||
|
||||
await page.waitForTimeout(700);
|
||||
const recoveryEchoes = legacy.messages.filter(message => {
|
||||
if (!message.startsWith('42')) return false;
|
||||
try { return ['play', 'pause', 'seek'].includes(JSON.parse(message.substring(2))[0]); } catch { return false; }
|
||||
});
|
||||
expect(recoveryEchoes).toEqual([]);
|
||||
} finally {
|
||||
try { legacy?.close(); } catch { /* already closed */ }
|
||||
await relay.stopServerForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('newer mixed-version playback supersedes an in-flight canonical recovery', async ({ context, extensionId, baseURL }) => {
|
||||
test.setTimeout(45_000);
|
||||
const relay = await import('../../server/index.js');
|
||||
let legacy = null;
|
||||
try {
|
||||
await relay.startServer(0, '127.0.0.1');
|
||||
const port = relay.httpServer.address().port;
|
||||
const roomId = `e2e-canonical-supersede-${Date.now()}`;
|
||||
legacy = await connectLegacyRelayClient(port);
|
||||
await joinLegacyRelayRoom(legacy, roomId, 'legacy-newer-control');
|
||||
sendLegacyRelayEvent(legacy, 'play', { currentTime: 6, seq: 1, actionTimestamp: 1 });
|
||||
await expect.poll(() => relay.rooms.get(roomId)?.mediaState)
|
||||
.toMatchObject({ revision: 1, playbackState: 'playing', currentTime: 6 });
|
||||
|
||||
const url = `${baseURL}/pages/simple-player.html`;
|
||||
const page = await context.newPage();
|
||||
await page.goto(url);
|
||||
await page.waitForFunction(() => window.__fixtureReady === true);
|
||||
const { tabId } = await selectTargetTab(context, extensionId, url);
|
||||
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async selectedTabId => {
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId: selectedTabId },
|
||||
world: 'ISOLATED',
|
||||
func: () => {
|
||||
const video = document.querySelector('#player');
|
||||
if (!video) throw new Error('canonical supersession fixture video missing');
|
||||
video.dataset.koalaCanonicalPlayAttempts = '0';
|
||||
Object.defineProperty(video, 'play', {
|
||||
configurable: true,
|
||||
value: () => {
|
||||
const attempts = Number(video.dataset.koalaCanonicalPlayAttempts || '0') + 1;
|
||||
video.dataset.koalaCanonicalPlayAttempts = String(attempts);
|
||||
return new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('delayed audit autoplay rejection')), 400);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}, tabId));
|
||||
|
||||
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async settings => {
|
||||
await chrome.storage.local.set(settings);
|
||||
return chrome.runtime.sendMessage({ type: 'CONNECT' });
|
||||
}, {
|
||||
serverUrl: `ws://127.0.0.1:${port}`,
|
||||
useCustomServer: true,
|
||||
roomId,
|
||||
password: '',
|
||||
username: 'current-superseded'
|
||||
}));
|
||||
await expectConnectedRoom(context, extensionId, roomId, { queuedLogicalEvents: 0 });
|
||||
await expect.poll(() => page.locator('#player').evaluate(video =>
|
||||
Number(video.dataset.koalaCanonicalPlayAttempts || '0')))
|
||||
.toBe(1);
|
||||
|
||||
sendLegacyRelayEvent(legacy, 'seek', { currentTime: 10, targetTime: 10, seq: 2, actionTimestamp: 2 });
|
||||
sendLegacyRelayEvent(legacy, 'pause', { currentTime: 10, seq: 3, actionTimestamp: 3 });
|
||||
await expect.poll(() => relay.rooms.get(roomId)?.mediaState)
|
||||
.toMatchObject({ revision: 3, playbackState: 'paused', currentTime: 10 });
|
||||
await expect.poll(() => page.locator('#player').evaluate(video => ({
|
||||
paused: video.paused,
|
||||
currentTime: video.currentTime
|
||||
}))).toMatchObject({ paused: true });
|
||||
await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime))
|
||||
.toBeGreaterThan(9);
|
||||
|
||||
await page.waitForTimeout(1_200);
|
||||
expect(await page.locator('#player').evaluate(video =>
|
||||
Number(video.dataset.koalaCanonicalPlayAttempts || '0'))).toBe(1);
|
||||
expect(await page.locator('#player').evaluate(video => video.paused)).toBe(true);
|
||||
} finally {
|
||||
try { legacy?.close(); } catch { /* already closed */ }
|
||||
await relay.stopServerForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps canonical media state current while the capable extension is solo', async ({ context, extensionId, baseURL }) => {
|
||||
test.setTimeout(35_000);
|
||||
const relay = await import('../../server/index.js');
|
||||
try {
|
||||
await relay.startServer(0, '127.0.0.1');
|
||||
const port = relay.httpServer.address().port;
|
||||
const roomId = `e2e-canonical-solo-${Date.now()}`;
|
||||
const url = `${baseURL}/pages/simple-player.html`;
|
||||
const page = await context.newPage();
|
||||
await page.goto(url);
|
||||
await page.waitForFunction(() => window.__fixtureReady === true);
|
||||
const { tabId } = await selectTargetTab(context, extensionId, url);
|
||||
|
||||
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async settings => {
|
||||
await chrome.storage.local.set(settings);
|
||||
return chrome.runtime.sendMessage({ type: 'CONNECT' });
|
||||
}, {
|
||||
serverUrl: `ws://127.0.0.1:${port}`,
|
||||
useCustomServer: true,
|
||||
roomId,
|
||||
password: '',
|
||||
username: 'current-solo'
|
||||
}));
|
||||
await expectConnectedRoom(context, extensionId, roomId);
|
||||
|
||||
expect(await sendServerCommand(context, extensionId, tabId, 'pause', { currentTime: 8 }))
|
||||
.toMatchObject({ status: 'ok' });
|
||||
expect(await sendServerCommand(context, extensionId, tabId, 'seek', { targetTime: 9 }))
|
||||
.toMatchObject({ status: 'ok' });
|
||||
await expect.poll(() => relay.rooms.get(roomId)?.mediaState)
|
||||
.toMatchObject({ revision: 2, playbackState: 'paused', currentTime: 9 });
|
||||
} finally {
|
||||
await relay.stopServerForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('@race reinjects after the target tab navigates', async ({ context, extensionId, baseURL }) => {
|
||||
const first = `${baseURL}/pages/iframe-player.html`;
|
||||
const page = await context.newPage();
|
||||
@@ -998,6 +1567,132 @@ test('keeps controlling a Drive-style player across an ordinary play and pause',
|
||||
await expect.poll(() => playerFrame.locator('video').evaluate(video => video.paused)).toBe(false);
|
||||
});
|
||||
|
||||
test('coalesces persisted offline media intent before canonical reconnect recovery', async ({ context, extensionId, baseURL }) => {
|
||||
test.setTimeout(60_000);
|
||||
const relay = await import('../../server/index.js');
|
||||
let legacy = null;
|
||||
let canonicalKeeper = null;
|
||||
let lateJoiner = null;
|
||||
try {
|
||||
await relay.startServer(0, '127.0.0.1');
|
||||
const port = relay.httpServer.address().port;
|
||||
const roomId = `e2e-coalesced-${Date.now()}`;
|
||||
legacy = await connectLegacyRelayClient(port);
|
||||
await joinLegacyRelayRoom(legacy, roomId, 'legacy-e2e');
|
||||
canonicalKeeper = await connectLegacyRelayClient(port);
|
||||
await joinLegacyRelayRoom(canonicalKeeper, roomId, 'legacy-keeper');
|
||||
|
||||
const url = `${baseURL}/pages/simple-player.html`;
|
||||
const page = await context.newPage();
|
||||
await page.goto(url);
|
||||
await page.waitForFunction(() => window.__fixtureReady === true);
|
||||
const { tabId } = await selectTargetTab(context, extensionId, url);
|
||||
|
||||
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async settings => {
|
||||
await chrome.storage.local.set(settings);
|
||||
return chrome.runtime.sendMessage({ type: 'CONNECT' });
|
||||
}, {
|
||||
serverUrl: `ws://127.0.0.1:${port}`,
|
||||
useCustomServer: true,
|
||||
roomId,
|
||||
password: '',
|
||||
username: 'current-e2e'
|
||||
}));
|
||||
await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' }))
|
||||
.toMatchObject({ status: 'connected', roomId, queuedLogicalEvents: 0 });
|
||||
|
||||
// Establish accepted server truth that would be stale for this client
|
||||
// after its later offline actions.
|
||||
sendLegacyRelayEvent(legacy, 'play', { currentTime: 1, seq: 1, actionTimestamp: 1 });
|
||||
sendLegacyRelayEvent(legacy, 'seek', { currentTime: 1, targetTime: 1, seq: 2, actionTimestamp: 2 });
|
||||
await expect.poll(() => relay.rooms.get(roomId)?.mediaState)
|
||||
.toMatchObject({ revision: 2, playbackState: 'playing', currentTime: 1, updatedBy: 'legacy-e2e' });
|
||||
await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime)).toBeGreaterThan(0.8);
|
||||
legacy.messages.length = 0;
|
||||
|
||||
const connectedStatus = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
|
||||
const extensionSocketId = Array.from(relay.rooms.get(roomId).peerData.entries())
|
||||
.find(([, data]) => data.peerId === connectedStatus.peerId)?.[0];
|
||||
expect(extensionSocketId).toBeTruthy();
|
||||
// Point future reconnect attempts at an unused local port, then sever
|
||||
// only the extension socket. The relay and legacy peer stay live.
|
||||
await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(
|
||||
serverUrl => chrome.storage.local.set({ serverUrl }),
|
||||
'ws://127.0.0.1:1'
|
||||
));
|
||||
relay.io.sockets.sockets.get(extensionSocketId).disconnect(true);
|
||||
await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' }).then(status => status.status))
|
||||
.not.toBe('connected');
|
||||
|
||||
expect(await sendServerCommand(context, extensionId, tabId, 'play', { currentTime: 2 })).toMatchObject({ status: 'ok' });
|
||||
expect(await sendServerCommand(context, extensionId, tabId, 'seek', { targetTime: 4 })).toMatchObject({ status: 'ok' });
|
||||
expect(await sendServerCommand(context, extensionId, tabId, 'seek', { targetTime: 6 })).toMatchObject({ status: 'ok' });
|
||||
expect(await sendServerCommand(context, extensionId, tabId, 'pause', { currentTime: 6 })).toMatchObject({ status: 'ok' });
|
||||
await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' }))
|
||||
.toMatchObject({ queuedLogicalEvents: 1, queuedMediaIntents: 1, queuedWireEvents: 2 });
|
||||
await expect.poll(() => page.locator('#player').evaluate(video => ({ paused: video.paused, currentTime: video.currentTime })))
|
||||
.toMatchObject({ paused: true });
|
||||
|
||||
// Terminate the actual MV3 worker. The next runtime message starts a new
|
||||
// worker, which must migrate/restore the logical queue and local sequence.
|
||||
await terminateExtensionServiceWorker(context, extensionId, page);
|
||||
await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' }).then(status => status.queuedMediaIntents))
|
||||
.toBe(1);
|
||||
const restoredQueue = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
|
||||
expect(restoredQueue.queuedLogicalEvents).toBeGreaterThanOrEqual(1);
|
||||
expect(restoredQueue.queuedWireEvents).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await page.locator('#player').evaluate(video => {
|
||||
window.__koalaReconnectSeeks = [];
|
||||
video.addEventListener('seeked', () => window.__koalaReconnectSeeks.push(video.currentTime));
|
||||
});
|
||||
|
||||
const retryResult = await withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async serverUrl => {
|
||||
await chrome.storage.local.set({ serverUrl });
|
||||
return chrome.runtime.sendMessage({ type: 'RETRY_CONNECT' });
|
||||
}, `ws://127.0.0.1:${port}`));
|
||||
expect(retryResult).toMatchObject({ status: 'ok' });
|
||||
const replaySeek = await waitForLegacyRelayEvent(legacy, 'seek', 20_000);
|
||||
const replayPause = await waitForLegacyRelayEvent(legacy, 'pause', 20_000);
|
||||
expect(replaySeek).toMatchObject({ currentTime: 6, targetTime: 6 });
|
||||
expect(replayPause).toMatchObject({ currentTime: 6 });
|
||||
expect(replaySeek.seq).toBeLessThan(replayPause.seq);
|
||||
await expect.poll(() => getExtensionState(context, extensionId, { type: 'GET_STATUS' }))
|
||||
.toMatchObject({ status: 'connected', roomId, queuedLogicalEvents: 0, queuedMediaIntents: 0 });
|
||||
await expect.poll(() => relay.rooms.get(roomId)?.mediaState)
|
||||
.toMatchObject({ playbackState: 'paused', currentTime: 6 });
|
||||
expect(relay.rooms.get(roomId).mediaState.revision).toBeGreaterThan(2);
|
||||
|
||||
// The stale r2 snapshot must never have sought the local player back to 1
|
||||
// before its authorized pending intent replayed.
|
||||
const reconnectSeeks = await page.evaluate(() => window.__koalaReconnectSeeks || []);
|
||||
expect(reconnectSeeks.some(value => value < 3)).toBe(false);
|
||||
await expect.poll(() => page.locator('#player').evaluate(video => ({ paused: video.paused, currentTime: video.currentTime })))
|
||||
.toMatchObject({ paused: true });
|
||||
|
||||
lateJoiner = await connectLegacyRelayClient(port);
|
||||
const lateRoom = await joinLegacyRelayRoom(lateJoiner, roomId, 'late-e2e');
|
||||
expect(lateRoom.mediaState).toMatchObject({
|
||||
revision: relay.rooms.get(roomId).mediaState.revision,
|
||||
playbackState: 'paused',
|
||||
currentTime: 6
|
||||
});
|
||||
|
||||
await page.waitForTimeout(700);
|
||||
const leakedMediaEvents = legacy.messages.filter(message => {
|
||||
if (!message.startsWith('42')) return false;
|
||||
try { return ['play', 'pause', 'seek'].includes(JSON.parse(message.substring(2))[0]); } catch { return false; }
|
||||
});
|
||||
expect(leakedMediaEvents).toEqual([]);
|
||||
} finally {
|
||||
await context.setOffline(false).catch(() => {});
|
||||
try { legacy?.close(); } catch { /* already closed */ }
|
||||
try { canonicalKeeper?.close(); } catch { /* already closed */ }
|
||||
try { lateJoiner?.close(); } catch { /* already closed */ }
|
||||
await relay.stopServerForTests();
|
||||
}
|
||||
});
|
||||
|
||||
function FRAMED_VIDEO_PAUSED() {
|
||||
return document.querySelector('iframe').contentDocument.querySelector('video').paused;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'fixtures');
|
||||
const port = Number(process.argv[2] || 4173);
|
||||
const modulePath = fileURLToPath(import.meta.url);
|
||||
const root = path.resolve(path.dirname(modulePath), 'fixtures');
|
||||
|
||||
const TYPES = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
@@ -18,62 +18,88 @@ const TYPES = {
|
||||
'.json': 'application/json'
|
||||
};
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const requested = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
|
||||
if (requested === '/redirect/hidden-player') {
|
||||
res.writeHead(302, {
|
||||
Location: `http://127.0.0.1:${port}/pages/frames/player-frame-2.html?redirected=hidden`,
|
||||
'Cache-Control': 'no-store'
|
||||
}).end();
|
||||
return;
|
||||
}
|
||||
const filePath = path.resolve(root, `.${requested}`);
|
||||
export function createFixtureServer(port) {
|
||||
return http.createServer((req, res) => {
|
||||
const requested = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
|
||||
if (requested === '/redirect/hidden-player') {
|
||||
res.writeHead(302, {
|
||||
Location: `http://127.0.0.1:${port}/pages/frames/player-frame-2.html?redirected=hidden`,
|
||||
'Cache-Control': 'no-store'
|
||||
}).end();
|
||||
return;
|
||||
}
|
||||
const filePath = path.resolve(root, `.${requested}`);
|
||||
|
||||
if (!filePath.startsWith(root + path.sep)) {
|
||||
res.writeHead(403).end('forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
fs.stat(filePath, (statErr, stat) => {
|
||||
if (statErr || !stat.isFile()) {
|
||||
res.writeHead(404).end('not found');
|
||||
if (!filePath.startsWith(root + path.sep)) {
|
||||
res.writeHead(403).end('forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = TYPES[path.extname(filePath)] || 'application/octet-stream';
|
||||
// Media needs byte ranges: without them Chromium reports an empty
|
||||
// seekable range and seeking silently does nothing, which would make
|
||||
// the remote-seek test fail for a reason that has nothing to do with
|
||||
// the extension.
|
||||
const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '');
|
||||
if (range) {
|
||||
const start = range[1] ? Number(range[1]) : 0;
|
||||
const end = range[2] ? Number(range[2]) : stat.size - 1;
|
||||
if (Number.isNaN(start) || Number.isNaN(end) || start > end || end >= stat.size) {
|
||||
res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` }).end();
|
||||
fs.stat(filePath, (statErr, stat) => {
|
||||
if (statErr || !stat.isFile()) {
|
||||
res.writeHead(404).end('not found');
|
||||
return;
|
||||
}
|
||||
res.writeHead(206, {
|
||||
|
||||
const contentType = TYPES[path.extname(filePath)] || 'application/octet-stream';
|
||||
// Media needs byte ranges: without them Chromium reports an empty
|
||||
// seekable range and seeking silently does nothing, which would make
|
||||
// the remote-seek test fail for a reason that has nothing to do with
|
||||
// the extension.
|
||||
const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '');
|
||||
if (range) {
|
||||
const start = range[1] ? Number(range[1]) : 0;
|
||||
const end = range[2] ? Number(range[2]) : stat.size - 1;
|
||||
if (Number.isNaN(start) || Number.isNaN(end) || start > end || end >= stat.size) {
|
||||
res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` }).end();
|
||||
return;
|
||||
}
|
||||
res.writeHead(206, {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': end - start + 1,
|
||||
'Content-Range': `bytes ${start}-${end}/${stat.size}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Cache-Control': 'no-store'
|
||||
});
|
||||
fs.createReadStream(filePath, { start, end }).pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': end - start + 1,
|
||||
'Content-Range': `bytes ${start}-${end}/${stat.size}`,
|
||||
'Content-Length': stat.size,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Cache-Control': 'no-store'
|
||||
});
|
||||
fs.createReadStream(filePath, { start, end }).pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': stat.size,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Cache-Control': 'no-store'
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
});
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
export async function startFixtureServer(port = Number(process.env.KOALA_E2E_PORT || 4173)) {
|
||||
const server = createFixtureServer(port);
|
||||
await new Promise((resolve, reject) => {
|
||||
const onError = error => reject(error);
|
||||
server.once('error', onError);
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
server.off('error', onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
||||
export async function stopFixtureServer(server) {
|
||||
if (!server?.listening) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close(error => error ? reject(error) : resolve());
|
||||
server.closeAllConnections?.();
|
||||
});
|
||||
}
|
||||
|
||||
const isMainModule = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(modulePath);
|
||||
if (isMainModule) {
|
||||
const port = Number(process.argv[2] || process.env.KOALA_E2E_PORT || 4173);
|
||||
await startFixtureServer(port);
|
||||
console.log(`fixture server on http://localhost:${port}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import http from 'node:http';
|
||||
import { startFixtureServer, stopFixtureServer } from './fixture-server.mjs';
|
||||
|
||||
function fixtureIsRunning(port) {
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
const finish = result => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(result);
|
||||
};
|
||||
const request = http.get({
|
||||
hostname: '127.0.0.1',
|
||||
port,
|
||||
path: '/pages/simple-player.html'
|
||||
}, response => {
|
||||
response.resume();
|
||||
response.once('error', () => finish(false));
|
||||
response.once('end', () => finish(response.statusCode === 200));
|
||||
});
|
||||
request.once('error', () => finish(false));
|
||||
request.setTimeout(1000, () => {
|
||||
request.destroy();
|
||||
finish(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default async function globalSetup() {
|
||||
const port = Number(process.env.KOALA_E2E_PORT || 4173);
|
||||
if (!process.env.CI && await fixtureIsRunning(port)) return undefined;
|
||||
|
||||
const server = await startFixtureServer(port);
|
||||
return async () => stopFixtureServer(server);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ const PORT = Number(process.env.KOALA_E2E_PORT || 4173);
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
testMatch: '**/*.spec.mjs',
|
||||
globalSetup: fileURLToPath(new URL('./global-setup.mjs', import.meta.url)),
|
||||
// Extension tests drive a persistent context and a service worker; running
|
||||
// them in parallel makes the profile directories fight each other.
|
||||
workers: 1,
|
||||
@@ -46,12 +47,5 @@ export default defineConfig({
|
||||
name: 'extension-chromium',
|
||||
testIgnore: 'detection.spec.mjs'
|
||||
}
|
||||
],
|
||||
webServer: {
|
||||
command: `node "${fileURLToPath(new URL('./fixture-server.mjs', import.meta.url))}" ${PORT}`,
|
||||
url: `http://localhost:${PORT}/pages/simple-player.html`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
stdout: 'ignore',
|
||||
stderr: 'pipe'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user