fix(sync): harden episode transition compatibility

This commit is contained in:
Timo
2026-09-01 07:08:39 +02:00
parent 742386064c
commit dc7eb86ce6
14 changed files with 1625 additions and 220 deletions
@@ -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);
});
+92
View File
@@ -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; }
})());