mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-30 12:29:27 +00:00
feat(sync): coalesce offline media intent
This commit is contained in:
@@ -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
|
||||
@@ -63,6 +71,61 @@ async function applyCanonicalMediaState(context, extensionId, tabId, mediaState)
|
||||
}, { tabId, mediaState }));
|
||||
}
|
||||
|
||||
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) => {
|
||||
const timeout = setTimeout(() => reject(new Error('legacy relay connection timed out')), 5000);
|
||||
socket.once('open', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
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 }),
|
||||
@@ -1047,6 +1110,127 @@ 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 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');
|
||||
|
||||
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 withExtensionPage(context, extensionId, extensionPage => extensionPage.evaluate(async serverUrl => {
|
||||
await chrome.storage.local.set({ serverUrl });
|
||||
chrome.alarms.create('keepAlive', { when: Date.now() + 50 });
|
||||
}, `ws://127.0.0.1:${port}`));
|
||||
|
||||
await page.locator('#player').evaluate(video => {
|
||||
window.__koalaReconnectSeeks = [];
|
||||
video.addEventListener('seeked', () => window.__koalaReconnectSeeks.push(video.currentTime));
|
||||
});
|
||||
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 { lateJoiner?.close(); } catch { /* already closed */ }
|
||||
await relay.stopServerForTests();
|
||||
}
|
||||
});
|
||||
|
||||
function FRAMED_VIDEO_PAUSED() {
|
||||
return document.querySelector('iframe').contentDocument.querySelector('video').paused;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user