mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-09-04 06:55:21 +00:00
fix(sync): harden episode transition compatibility
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import { test, expect } from './helpers/extension-fixture.mjs';
|
||||
|
||||
async function withExtensionPage(context, extensionId, fn) {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`chrome-extension://${extensionId}/audio-options.html`);
|
||||
try {
|
||||
return await fn(page);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function selectTargetTab(context, extensionId, pageUrl) {
|
||||
return withExtensionPage(context, extensionId, page => page.evaluate(async url => {
|
||||
const [tab] = await chrome.tabs.query({ url });
|
||||
if (!tab) throw new Error(`no tab matched ${url}`);
|
||||
await chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId: tab.id });
|
||||
return tab.id;
|
||||
}, pageUrl));
|
||||
}
|
||||
|
||||
async function extensionMessage(context, extensionId, message) {
|
||||
return withExtensionPage(context, extensionId, page => page.evaluate(
|
||||
payload => chrome.runtime.sendMessage(payload),
|
||||
message
|
||||
));
|
||||
}
|
||||
|
||||
async function prepareEpisodePage(page, title, currentTime) {
|
||||
await page.waitForFunction(() => window.__fixtureReady === true);
|
||||
await page.evaluate(async ({ title, currentTime }) => {
|
||||
navigator.mediaSession.metadata = new window.MediaMetadata({ title });
|
||||
const video = document.querySelector('#player');
|
||||
video.muted = true;
|
||||
video.playbackRate = 0.1;
|
||||
video.currentTime = currentTime;
|
||||
await video.play();
|
||||
}, { title, currentTime });
|
||||
}
|
||||
|
||||
async function contentLogs(context, extensionId) {
|
||||
const logs = await extensionMessage(context, extensionId, { type: 'GET_LOGS' });
|
||||
return logs.filter(entry => entry.message.includes('[Content]')).map(entry => entry.message);
|
||||
}
|
||||
|
||||
test('keeps ordinary play/pause outside an episode boundary on the immediate path @episode-transition', async ({ context, extensionId, baseURL }) => {
|
||||
const url = `${baseURL}/pages/simple-player.html`;
|
||||
const page = await context.newPage();
|
||||
await page.goto(url);
|
||||
await prepareEpisodePage(page, 'Series S01E05', 3);
|
||||
await selectTargetTab(context, extensionId, url);
|
||||
await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true');
|
||||
|
||||
const before = await extensionMessage(context, extensionId, { type: 'GET_HISTORY' });
|
||||
await page.locator('#player').evaluate(video => video.pause());
|
||||
await expect.poll(async () => {
|
||||
const history = await extensionMessage(context, extensionId, { type: 'GET_HISTORY' });
|
||||
return history.length;
|
||||
}, { timeout: 750 }).toBe(before.length + 1);
|
||||
});
|
||||
|
||||
test('quarantines a suspicious boundary pause, then relays the final intent when no episode changes @episode-transition', async ({ context, extensionId, baseURL }) => {
|
||||
const url = `${baseURL}/pages/simple-player.html`;
|
||||
const page = await context.newPage();
|
||||
await page.goto(url);
|
||||
await prepareEpisodePage(page, 'Series S01E05', 9.25);
|
||||
await selectTargetTab(context, extensionId, url);
|
||||
await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true');
|
||||
|
||||
const before = await extensionMessage(context, extensionId, { type: 'GET_HISTORY' });
|
||||
await page.locator('#player').evaluate(video => video.pause());
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
expect(await extensionMessage(context, extensionId, { type: 'GET_HISTORY' })).toEqual(before);
|
||||
await expect.poll(async () => {
|
||||
const history = await extensionMessage(context, extensionId, { type: 'GET_HISTORY' });
|
||||
return history.length;
|
||||
}, { timeout: 4000 }).toBe(before.length + 1);
|
||||
const after = await extensionMessage(context, extensionId, { type: 'GET_HISTORY' });
|
||||
expect(after[0]).toMatchObject({ action: 'pause' });
|
||||
});
|
||||
|
||||
test('relays a deliberate boundary pause immediately @episode-transition', async ({ context, extensionId, baseURL }) => {
|
||||
const url = `${baseURL}/pages/simple-player.html`;
|
||||
const page = await context.newPage();
|
||||
await page.goto(url);
|
||||
await prepareEpisodePage(page, 'Series S01E05', 9.25);
|
||||
await selectTargetTab(context, extensionId, url);
|
||||
await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true');
|
||||
|
||||
const before = await extensionMessage(context, extensionId, { type: 'GET_HISTORY' });
|
||||
await page.locator('#player').dispatchEvent('pointerdown');
|
||||
await page.locator('#player').evaluate(video => video.pause());
|
||||
await expect.poll(async () => {
|
||||
const history = await extensionMessage(context, extensionId, { type: 'GET_HISTORY' });
|
||||
return history.length;
|
||||
}, { timeout: 750 }).toBe(before.length + 1);
|
||||
});
|
||||
|
||||
test('does not consume title-before-loadeddata or loadeddata-before-title transitions @episode-transition', async ({ context, extensionId, baseURL }) => {
|
||||
const runOrdering = async ordering => {
|
||||
const url = `${baseURL}/pages/simple-player.html?ordering=${ordering}-${Date.now()}`;
|
||||
const page = await context.newPage();
|
||||
await page.goto(url);
|
||||
await prepareEpisodePage(page, 'Series S01E05', 1);
|
||||
await selectTargetTab(context, extensionId, url);
|
||||
await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true');
|
||||
|
||||
if (ordering === 'title-first') {
|
||||
await page.evaluate(() => {
|
||||
navigator.mediaSession.metadata = new window.MediaMetadata({ title: 'Series S01E06' });
|
||||
document.querySelector('#player').dispatchEvent(new window.Event('loadeddata'));
|
||||
});
|
||||
} else {
|
||||
await page.evaluate(() => document.querySelector('#player').dispatchEvent(new window.Event('loadeddata')));
|
||||
await page.waitForTimeout(150);
|
||||
await page.evaluate(() => {
|
||||
navigator.mediaSession.metadata = new window.MediaMetadata({ title: 'Series S01E06' });
|
||||
});
|
||||
}
|
||||
|
||||
await expect.poll(async () => {
|
||||
const logs = await contentLogs(context, extensionId);
|
||||
return logs.some(line => line.includes('Episode transition detected: "Series S01E06"'));
|
||||
}, { timeout: 3000 }).toBe(true);
|
||||
await page.close();
|
||||
await extensionMessage(context, extensionId, { type: 'CLEAR_LOGS' });
|
||||
};
|
||||
|
||||
await runOrdering('title-first');
|
||||
await runOrdering('loadeddata-first');
|
||||
});
|
||||
|
||||
test('discards source-swap pause/play churn after the episode is confirmed @episode-transition', async ({ context, extensionId, baseURL }) => {
|
||||
const url = `${baseURL}/pages/simple-player.html`;
|
||||
const page = await context.newPage();
|
||||
await page.goto(url);
|
||||
await prepareEpisodePage(page, 'Series S01E05', 11);
|
||||
await selectTargetTab(context, extensionId, url);
|
||||
await expect.poll(() => page.locator('#player').getAttribute('data-koala-attached')).toBe('true');
|
||||
const before = await extensionMessage(context, extensionId, { type: 'GET_HISTORY' });
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const video = document.querySelector('#player');
|
||||
video.pause();
|
||||
navigator.mediaSession.metadata = new window.MediaMetadata({ title: 'Series S01E06' });
|
||||
video.src = '../media/player-1080p-30s.mp4';
|
||||
video.load();
|
||||
await new Promise(resolve => video.addEventListener('loadeddata', resolve, { once: true }));
|
||||
await video.play();
|
||||
});
|
||||
|
||||
await expect.poll(async () => {
|
||||
const logs = await contentLogs(context, extensionId);
|
||||
return logs.some(line => line.includes('Episode transition detected: "Series S01E06"'));
|
||||
}).toBe(true);
|
||||
await page.waitForTimeout(2300);
|
||||
expect(await extensionMessage(context, extensionId, { type: 'GET_HISTORY' })).toEqual(before);
|
||||
});
|
||||
@@ -1598,10 +1598,102 @@ test('completes Episode Sync v2 only after the packed player is stably prepared'
|
||||
lobby.transactionId
|
||||
);
|
||||
expect(execute).toMatchObject({ phase: 'execute', transactionId: lobby.transactionId, targetTime: 0 });
|
||||
await expect.poll(() => relay.rooms.get(roomId)?.episodeSyncV2?.executedPeers || [])
|
||||
.toContain(extensionPeerId);
|
||||
expect(relay.rooms.get(roomId)?.mediaState).toBeNull();
|
||||
sendLegacyRelayEvent(coordinator, 'episode_sync_v2', {
|
||||
phase: 'executed',
|
||||
transactionId: lobby.transactionId
|
||||
});
|
||||
const complete = await waitForLegacyRelayPhase(
|
||||
coordinator,
|
||||
'episode_sync_v2',
|
||||
'complete',
|
||||
lobby.transactionId
|
||||
);
|
||||
expect(complete.executedPeers).toEqual(expect.arrayContaining([coordinatorPeerId, extensionPeerId]));
|
||||
await expect.poll(() => relay.rooms.get(roomId)?.episodeSyncV2).toBeNull();
|
||||
await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(false);
|
||||
await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime)).toBeLessThan(3);
|
||||
|
||||
// A peer can fail after this packed player has already started. The
|
||||
// terminal cancel must actively settle it back to paused at 0:00.
|
||||
coordinator.messages.length = 0;
|
||||
sendLegacyRelayEvent(coordinator, 'episode_sync_v2', {
|
||||
phase: 'start',
|
||||
expectedTitle: 'S1:E6 - Visiting Ours',
|
||||
expectedEpisodeId: 'S01E06'
|
||||
});
|
||||
const rollbackLobby = await waitForLegacyRelayEvent(coordinator, 'episode_sync_v2');
|
||||
expect(rollbackLobby.phase).toBe('lobby');
|
||||
sendLegacyRelayEvent(coordinator, 'episode_sync_v2', {
|
||||
phase: 'loaded',
|
||||
transactionId: rollbackLobby.transactionId
|
||||
});
|
||||
const rollbackPrepare = await waitForLegacyRelayPhase(
|
||||
coordinator,
|
||||
'episode_sync_v2',
|
||||
'prepare',
|
||||
rollbackLobby.transactionId
|
||||
);
|
||||
expect(rollbackPrepare.loadedPeers).toEqual(expect.arrayContaining([coordinatorPeerId, extensionPeerId]));
|
||||
try {
|
||||
await expect.poll(() => relay.rooms.get(roomId)?.episodeSyncV2?.preparedPeers || [])
|
||||
.toContain(extensionPeerId);
|
||||
} catch (error) {
|
||||
const [logs, playerState] = await Promise.all([
|
||||
getExtensionState(context, extensionId, { type: 'GET_LOGS' }).catch(() => []),
|
||||
page.locator('#player').evaluate(video => ({
|
||||
paused: video.paused,
|
||||
currentTime: video.currentTime,
|
||||
readyState: video.readyState,
|
||||
seeking: video.seeking
|
||||
}))
|
||||
]);
|
||||
const transaction = relay.rooms.get(roomId)?.episodeSyncV2;
|
||||
console.error(`Episode v2 rollback prepare diagnostics: ${JSON.stringify({
|
||||
transaction: transaction ? {
|
||||
transactionId: transaction.transactionId,
|
||||
phase: transaction.phase,
|
||||
loadedPeers: transaction.loadedPeers,
|
||||
preparedPeers: transaction.preparedPeers,
|
||||
deadlineAt: transaction.deadlineAt
|
||||
} : null,
|
||||
playerState,
|
||||
logs: logs.slice(-20)
|
||||
})}`);
|
||||
throw error;
|
||||
}
|
||||
sendLegacyRelayEvent(coordinator, 'episode_sync_v2', {
|
||||
phase: 'prepared',
|
||||
transactionId: rollbackLobby.transactionId
|
||||
});
|
||||
await waitForLegacyRelayPhase(
|
||||
coordinator,
|
||||
'episode_sync_v2',
|
||||
'execute',
|
||||
rollbackLobby.transactionId
|
||||
);
|
||||
await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(false);
|
||||
sendLegacyRelayEvent(coordinator, 'episode_sync_v2', {
|
||||
phase: 'failed_execute',
|
||||
transactionId: rollbackLobby.transactionId,
|
||||
reason: 'fixture_failure'
|
||||
});
|
||||
const rollbackCancel = await waitForLegacyRelayPhase(
|
||||
coordinator,
|
||||
'episode_sync_v2',
|
||||
'cancel',
|
||||
rollbackLobby.transactionId
|
||||
);
|
||||
expect(rollbackCancel).toMatchObject({
|
||||
reason: 'execute_failed',
|
||||
settlePlaybackState: 'paused',
|
||||
targetTime: 0
|
||||
});
|
||||
await expect.poll(() => page.locator('#player').evaluate(video => video.paused)).toBe(true);
|
||||
await expect.poll(() => page.locator('#player').evaluate(video => video.currentTime)).toBeLessThan(1);
|
||||
|
||||
const legacyForceFrames = coordinator.messages.filter(message => message.startsWith('42') && (() => {
|
||||
try { return JSON.parse(message.substring(2))[0].startsWith('force_sync_'); } catch { return false; }
|
||||
})());
|
||||
|
||||
Reference in New Issue
Block a user