merge main and harden canonical media recovery

This commit is contained in:
Timo
2026-08-25 16:58:15 +02:00
81 changed files with 3422 additions and 1133 deletions
+12
View File
@@ -8,6 +8,9 @@ enough to control it.
npm run test:e2e:install # once, downloads the browsers
npm run build:extension # extension.spec.mjs loads dist/chrome
npm run test:e2e
npm run test:e2e:detection # finder only: Chromium, Firefox, WebKit
npm run test:e2e:extension # packed extension only: Chromium MV3
npm run test:e2e:race # @race scenarios, repeated 20 times
```
## Layout
@@ -16,11 +19,19 @@ npm run test:e2e
| :--- | :--- |
| `detection.spec.mjs` | Runs the shipped `findVideo()` against the fixture pages |
| `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 |
| `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) |
| `helpers/content-source.mjs` | Lifts the real finder out of `extension/content.js` |
The detection fixtures run as three Playwright projects: Chromium, Firefox,
and WebKit. Packed-extension tests remain Chromium-only because they exercise
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.
## Two rules worth keeping
**The specs run the shipped source, not a copy.** `helpers/content-source.mjs`
@@ -45,6 +56,7 @@ reads as a broken fixture instead of a scoring regression.
| `late-frame.html` | Player frame attached after the page settled |
| `shadow-player.html` | Player in a shadow root, tiny teaser in the light DOM |
| `muted-player.html` | Mute must not disqualify the only player |
| `display-contents-player.html` | A visible player survives a boxless `display: contents` wrapper |
| `hidden-preload.html` | A `display:none` preload still reports 1080p; it must lose |
| `ad-frame.html` | 1080p asset in a 300x250 ad slot must lose to the real player |
| `background-loop.html` | Silent looping hero must lose despite being the largest |
+1
View File
@@ -13,6 +13,7 @@ const SCENARIOS = [
{ page: 'nested-frame.html', expected: 'framed-player', what: 'a player two frame levels down' },
{ page: 'shadow-player.html', expected: 'shadow-player', what: 'a player inside a shadow root over a light-DOM teaser' },
{ page: 'muted-player.html', expected: 'player', what: 'the only player even when muted' },
{ page: 'display-contents-player.html', expected: 'player', what: 'a visible player inside a boxless display-contents wrapper' },
{ page: 'hidden-preload.html', expected: 'player', what: 'the visible player over a hidden higher-resolution preload' },
{ page: 'ad-frame.html', expected: 'player', what: 'the real player over a muted ad in a first-party frame' },
{ page: 'background-loop.html', expected: 'player', what: 'the real player over a large looping background video' },
+32 -19
View File
@@ -78,11 +78,24 @@ async function connectLegacyRelayClient(port) {
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', () => {
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);
@@ -375,7 +388,7 @@ test('applies canonical recovery without echoing media commands or activity', as
expect(historyAfter).toEqual(historyBefore);
});
test('reinjects after the target tab navigates', async ({ context, extensionId, baseURL }) => {
test('@race reinjects after the target tab navigates', async ({ context, extensionId, baseURL }) => {
const first = `${baseURL}/pages/iframe-player.html`;
const page = await context.newPage();
await page.goto(first);
@@ -398,7 +411,7 @@ test('reinjects after the target tab navigates', async ({ context, extensionId,
).toBe('true');
});
test('re-attaches after the player frame swaps its document', async ({ context, extensionId, baseURL }) => {
test('@race re-attaches after the player frame swaps its document', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/reloading-frame.html`;
const page = await context.newPage();
await page.goto(url);
@@ -425,7 +438,7 @@ test('re-attaches after the player frame swaps its document', async ({ context,
).toBe('true');
});
test('re-attaches when a nested player frame swaps its document', async ({ context, extensionId, baseURL }) => {
test('@race re-attaches when a nested player frame swaps its document', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/nested-frame.html`;
const page = await context.newPage();
await page.goto(url);
@@ -454,7 +467,7 @@ test('re-attaches when a nested player frame swaps its document', async ({ conte
).toBe('true');
});
test('moves local event listeners after a CSS-only player switch', async ({ context, extensionId, baseURL }) => {
test('@race moves local event listeners after a CSS-only player switch', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/player-css-switch.html`;
const page = await context.newPage();
await page.goto(url);
@@ -563,7 +576,7 @@ test('targets a visible nested cross-origin player and keeps top-page debug cont
});
});
test('re-elects the visible cross-origin player after an iframe switch', async ({ context, extensionId, baseURL }) => {
test('@race re-elects the visible cross-origin player after an iframe switch', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/cross-origin-switching.html`;
const page = await context.newPage();
await page.goto(url);
@@ -588,7 +601,7 @@ test('re-elects the visible cross-origin player after an iframe switch', async (
expect(await first.locator('video').evaluate(video => video.paused)).toBe(true);
});
test('immediately adopts and syncs when switching mirrors while first mirror was active and playing', async ({ context, extensionId, baseURL }) => {
test('@race immediately adopts and syncs when switching mirrors while first mirror was active and playing', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/cross-origin-switching.html`;
const page = await context.newPage();
await page.goto(url);
@@ -642,7 +655,7 @@ test('keeps commands flowing during continuous player-frame geometry changes', a
}
});
test('deactivates media monitors in child frames after a target-tab switch', async ({ context, extensionId, baseURL }) => {
test('@race deactivates media monitors in child frames after a target-tab switch', async ({ context, extensionId, baseURL }) => {
const firstUrl = `${baseURL}/pages/cross-origin-nested.html`;
const secondUrl = `${baseURL}/pages/simple-player.html`;
const firstPage = await context.newPage();
@@ -692,7 +705,7 @@ test('re-attaches after a selected cross-origin frame navigates', async ({ conte
expect(state).toMatchObject({ found: true, inIframe: true });
});
test('discovers a video inserted late inside a cross-origin frame', async ({ context, extensionId, baseURL }) => {
test('@race discovers a video inserted late inside a cross-origin frame', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/cross-origin-late.html`;
const page = await context.newPage();
await page.goto(url);
@@ -780,7 +793,7 @@ test('selects the visible anime player nested behind a same-origin wrapper', asy
});
});
test('selects an anime tab before playback and promotes the player once it appears', async ({ context, extensionId, baseURL }) => {
test('@race selects an anime tab before playback and promotes the player once it appears', async ({ context, extensionId, baseURL }) => {
// The live case: at selection time the page has no video anywhere, because
// the host only builds the player when the viewer presses play.
const url = `${baseURL}/pages/yummy-deferred-player.html`;
@@ -849,7 +862,7 @@ test('polling video state on a page with no video does not restart the target',
.toBe('true');
});
test('stays ready on a page whose ad frames keep mutating', async ({ context, extensionId, baseURL }) => {
test('@race stays ready on a page whose ad frames keep mutating', async ({ context, extensionId, baseURL }) => {
test.setTimeout(90000);
// Live ad churn wakes the media-frame monitor several times a second. Each
// wake used to schedule a trailing refresh that rebuilt the target
@@ -916,7 +929,7 @@ test('controls and adopts a nested player even while the top frame is elected',
expect(status).toMatchObject({ targetTabId: tabId, targetHasVideo: true });
});
test('recovers when the adopted player frame is torn down and rebuilt', async ({ context, extensionId, baseURL }) => {
test('@race recovers when the adopted player frame is torn down and rebuilt', async ({ context, extensionId, baseURL }) => {
test.setTimeout(90000);
// Kodik rebuilds its player frame on quality and part changes, which kills
// the documentId the election is pinned to. The election has to be given up,
@@ -1182,16 +1195,16 @@ test('coalesces persisted offline media intent before canonical reconnect recove
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' });
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 });
@@ -0,0 +1,16 @@
<!doctype html>
<meta charset="utf-8">
<title>Display contents player</title>
<h1>Visible player inside a boxless wrapper</h1>
<div id="app-contents" style="display: contents">
<div class="video-player-wrapper">
<video id="player" data-expected width="854" height="480" src="../media/player-480p-12s.mp4"></video>
</div>
</div>
<script>
const wrapper = document.getElementById('app-contents');
if (wrapper.checkVisibility() !== false || wrapper.getBoundingClientRect().width !== 0) {
throw new Error('display: contents fixture must expose a boxless wrapper');
}
</script>
<script src="ready.js"></script>
+87 -17
View File
@@ -7,30 +7,83 @@ import { test as base, chromium } from '@playwright/test';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
export const extensionPath = path.join(repoRoot, 'dist/chrome');
function isLocalTestUrl(rawUrl) {
try {
const url = new URL(rawUrl);
if (!['http:', 'https:', 'ws:', 'wss:'].includes(url.protocol)) return true;
return url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1';
} catch (_error) {
return false;
}
}
export async function launchExtensionContext() {
if (!fs.existsSync(path.join(extensionPath, 'manifest.json'))) {
throw new Error('dist/chrome is missing. Run: npm run build:extension');
}
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'koalasync-e2e-'));
let context;
try {
context = await chromium.launchPersistentContext(userDataDir, {
channel: 'chromium',
headless: true,
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
'--autoplay-policy=no-user-gesture-required',
'--host-resolver-rules=MAP * 0.0.0.0, EXCLUDE localhost, EXCLUDE 127.0.0.1'
]
});
await context.route('**/*', route => {
if (isLocalTestUrl(route.request().url())) return route.continue();
return route.abort('blockedbyclient');
});
if (typeof context.routeWebSocket === 'function') {
await context.routeWebSocket(/.*/u, webSocketRoute => {
if (isLocalTestUrl(webSocketRoute.url())) {
webSocketRoute.connectToServer();
} else {
webSocketRoute.close({ code: 1008, reason: 'External network blocked by E2E harness' });
}
});
}
let [worker] = context.serviceWorkers();
if (!worker) worker = await context.waitForEvent('serviceworker');
const extensionId = worker.url().split('/')[2];
return {
context,
extensionId,
async close() {
try {
await context.close();
} finally {
fs.rmSync(userDataDir, { recursive: true, force: true });
}
}
};
} catch (error) {
if (context) await context.close().catch(() => {});
if (fs.existsSync(userDataDir)) {
fs.rmSync(userDataDir, { recursive: true, force: true });
}
throw error;
}
}
/**
* A browser with the packed extension loaded, plus its extension id. Each test
* gets a throwaway profile so storage from one test cannot leak into the next.
*/
export const test = base.extend({
context: async ({}, use) => {
if (!fs.existsSync(path.join(extensionPath, 'manifest.json'))) {
throw new Error('dist/chrome is missing. Run: npm run build:extension');
// The headless shell does not run MV3 service workers; the full
// Chromium build in new headless mode does.
const launched = await launchExtensionContext();
try {
await use(launched.context);
} finally {
await launched.close();
}
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'koalasync-e2e-'));
const context = await chromium.launchPersistentContext(userDataDir, {
// The headless shell does not run MV3 service workers; the full
// Chromium build in new headless mode does.
channel: 'chromium',
headless: true,
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
'--autoplay-policy=no-user-gesture-required'
]
});
await use(context);
await context.close();
fs.rmSync(userDataDir, { recursive: true, force: true });
},
extensionId: async ({ context }, use) => {
let [worker] = context.serviceWorkers();
@@ -85,3 +138,20 @@ export async function readStorage(page, keys) {
export async function writeStorage(page, values) {
return page.evaluate(v => chrome.storage.local.set(v), values);
}
export async function terminateServiceWorker(context, extensionId) {
const page = await context.newPage();
let session;
try {
await page.goto(`chrome-extension://${extensionId}/audio-options.html`);
session = await context.newCDPSession(page);
const { targetInfos } = await session.send('Target.getTargets');
const worker = targetInfos.find(target => target.type === 'service_worker'
&& target.url.startsWith(`chrome-extension://${extensionId}/`));
if (!worker) throw new Error(`service worker target missing for ${extensionId}`);
await session.send('Target.closeTarget', { targetId: worker.targetId });
} finally {
if (session) await session.detach().catch(() => {});
await page.close().catch(() => {});
}
}
+65
View File
@@ -0,0 +1,65 @@
import { spawn } from 'node:child_process';
import net from 'node:net';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
export async function reservePort() {
const server = net.createServer();
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
const port = typeof address === 'object' && address ? address.port : null;
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
if (!port) throw new Error('failed to reserve relay port');
return port;
}
export async function startRelay(port) {
const output = [];
const child = spawn(process.execPath, ['server/index.js'], {
cwd: repoRoot,
env: {
...process.env,
PORT: String(port),
SERVER_SALT: 'koalasync-e2e-relay-salt-with-more-than-thirty-two-chars'
},
stdio: ['ignore', 'pipe', 'pipe']
});
child.stdout.on('data', chunk => output.push(String(chunk)));
child.stderr.on('data', chunk => output.push(String(chunk)));
const deadline = Date.now() + 15000;
while (Date.now() < deadline) {
if (child.exitCode !== null) {
throw new Error(`relay exited with ${child.exitCode}: ${output.join('')}`);
}
try {
const remainingMs = Math.max(1, deadline - Date.now());
const response = await fetch(`http://127.0.0.1:${port}/health`, {
signal: globalThis.AbortSignal.timeout(Math.min(1000, remainingMs))
});
if (response.ok) return { child, output };
} catch (_error) {
// Relay is still starting.
}
await new Promise(resolve => setTimeout(resolve, 100));
}
child.kill('SIGTERM');
throw new Error(`relay did not become healthy: ${output.join('')}`);
}
export async function stopRelay(relay) {
if (!relay || relay.child.exitCode !== null) return;
relay.child.kill('SIGTERM');
const stopped = await Promise.race([
new Promise(resolve => relay.child.once('exit', () => resolve(true))),
new Promise(resolve => setTimeout(() => resolve(false), 7000))
]);
if (stopped) return;
relay.child.kill('SIGKILL');
await new Promise(resolve => relay.child.once('exit', resolve));
throw new Error(`relay required SIGKILL: ${relay.output.join('')}`);
}
+30 -8
View File
@@ -12,19 +12,41 @@ export default defineConfig({
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 0,
reporter: process.env.CI ? 'list' : [['list']],
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']],
timeout: 30_000,
expect: { timeout: 10_000 },
use: {
baseURL: `http://localhost:${PORT}`,
trace: 'retain-on-failure',
launchOptions: {
// Several fixtures hinge on a video actually playing. Without this
// the browser's autoplay heuristics decide whether the fixture is
// valid, which shows up later as an unexplained flake.
args: ['--autoplay-policy=no-user-gesture-required']
}
trace: 'retain-on-failure'
},
projects: [
{
name: 'detection-chromium',
testMatch: 'detection.spec.mjs',
use: {
browserName: 'chromium',
launchOptions: {
// Chromium alone supports this switch. Passing it through
// the shared config makes Linux WebKit refuse to launch.
args: ['--autoplay-policy=no-user-gesture-required']
}
}
},
{
name: 'detection-firefox',
testMatch: 'detection.spec.mjs',
use: { browserName: 'firefox' }
},
{
name: 'detection-webkit',
testMatch: 'detection.spec.mjs',
use: { browserName: 'webkit' }
},
{
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`,
+60
View File
@@ -0,0 +1,60 @@
import { expect, openPopup, test } from './helpers/extension-fixture.mjs';
test('popup exposes names and keyboard access for every visible control', async ({ context, extensionId }) => {
const page = await openPopup(context, extensionId, { openEditor: false });
const unnamed = await page.locator('button, input, select, textarea, a[href]').evaluateAll(elements => elements
.filter(element => {
const style = window.getComputedStyle(element);
return !element.disabled && style.display !== 'none' && style.visibility !== 'hidden'
&& element.getClientRects().length > 0;
})
.filter(element => {
const label = element.getAttribute('aria-label')
|| element.getAttribute('aria-labelledby')
|| element.getAttribute('title')
|| element.labels?.[0]?.textContent
|| element.textContent;
return !String(label || '').trim();
})
.map(element => `${element.tagName.toLowerCase()}#${element.id || '<no-id>'}`));
expect(unnamed).toEqual([]);
await page.locator('body').press('Tab');
await expect(page.locator(':focus')).not.toHaveCount(0);
const settingsTab = page.locator('.tab-btn[data-tab="tab-settings"]');
await settingsTab.focus();
await page.keyboard.press('Enter');
await expect(settingsTab).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('#tab-settings')).toBeVisible();
});
test('popup root remains 360px when a dynamic child overflows', async ({ context, extensionId }) => {
const page = await openPopup(context, extensionId, { openEditor: false });
const geometry = await page.evaluate(() => {
const probe = document.createElement('div');
probe.id = 'popup-overflow-probe';
probe.style.width = '1200px';
probe.style.height = '1px';
document.body.appendChild(probe);
const htmlStyle = window.getComputedStyle(document.documentElement);
const bodyStyle = window.getComputedStyle(document.body);
return {
htmlWidth: document.documentElement.getBoundingClientRect().width,
bodyWidth: document.body.getBoundingClientRect().width,
htmlOverflowX: htmlStyle.overflowX,
bodyOverflowX: bodyStyle.overflowX,
bodyContain: bodyStyle.contain,
probeWidth: probe.getBoundingClientRect().width
};
});
expect(geometry).toEqual({
htmlWidth: 360,
bodyWidth: 360,
htmlOverflowX: 'hidden',
bodyOverflowX: 'hidden',
bodyContain: 'inline-size',
probeWidth: 1200
});
});
+118
View File
@@ -0,0 +1,118 @@
import {
expect,
launchExtensionContext,
terminateServiceWorker,
test
} from './helpers/extension-fixture.mjs';
import { reservePort, startRelay, stopRelay } from './helpers/relay-process.mjs';
// popup.html intentionally performs connection setup. Use a neutral extension
// page for privileged test messages so opening the test transport cannot race
// the server settings being exercised.
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();
}
}
function getStatus(context, extensionId) {
return withExtensionPage(context, extensionId, page => page.evaluate(
() => chrome.runtime.sendMessage({ type: 'GET_STATUS' })
));
}
async function selectTarget(context, extensionId, url) {
return withExtensionPage(context, extensionId, page => page.evaluate(async targetUrl => {
const [tab] = await chrome.tabs.query({ url: targetUrl });
if (!tab) throw new Error(`target tab missing: ${targetUrl}`);
const result = await chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId: tab.id, tabTitle: tab.title });
return { tabId: tab.id, result };
}, url));
}
async function connect(context, extensionId, { relayUrl, roomId, username }) {
await withExtensionPage(context, extensionId, page => page.evaluate(async settings => {
await chrome.storage.local.set({
roomId: settings.roomId,
password: '',
chatKey: '',
username: settings.username,
useCustomServer: true,
serverUrl: settings.relayUrl
});
await chrome.runtime.sendMessage({ type: 'CONNECT' });
}, { relayUrl, roomId, username }));
}
test('@race synchronizes two packed clients across relay and service-worker restarts', async ({ context, extensionId, baseURL }) => {
test.setTimeout(120000);
const second = await launchExtensionContext();
let relay = null;
try {
const port = await reservePort();
relay = await startRelay(port);
const firstUrl = `${baseURL}/pages/simple-player.html?client=first`;
const secondUrl = `${baseURL}/pages/simple-player.html?client=second`;
const firstPage = await context.newPage();
const secondPage = await second.context.newPage();
await Promise.all([firstPage.goto(firstUrl), secondPage.goto(secondUrl)]);
await Promise.all([
firstPage.waitForFunction(() => window.__fixtureReady === true),
secondPage.waitForFunction(() => window.__fixtureReady === true)
]);
await selectTarget(context, extensionId, firstUrl);
await selectTarget(second.context, second.extensionId, secondUrl);
const connection = { relayUrl: `ws://127.0.0.1:${port}`, roomId: 'E2E-ROOM-42' };
await connect(context, extensionId, { ...connection, username: 'First' });
await expect.poll(() => getStatus(context, extensionId)).toMatchObject({
status: 'connected',
roomId: connection.roomId,
peers: [expect.objectContaining({ username: 'First' })]
});
await connect(second.context, second.extensionId, { ...connection, username: 'Second' });
let latestStates = [];
try {
await expect.poll(async () => {
latestStates = await Promise.all([
getStatus(context, extensionId),
getStatus(second.context, second.extensionId)
]);
return latestStates.map(state => state.peers.length);
}).toEqual([2, 2]);
} catch (error) {
console.error(`Two-client join diagnostics: ${JSON.stringify(latestStates)}`);
console.error(`Relay diagnostics: ${relay.output.join('')}`);
throw error;
}
for (const state of latestStates) expect(state.serverUrl).toBe(connection.relayUrl);
await firstPage.locator('video').evaluate(video => video.play());
await expect.poll(() => secondPage.locator('video').evaluate(video => video.paused)).toBe(false);
await firstPage.locator('video').evaluate(video => video.pause());
await expect.poll(() => secondPage.locator('video').evaluate(video => video.paused)).toBe(true);
await stopRelay(relay);
relay = null;
await expect.poll(() => getStatus(context, extensionId).then(status => status.status))
.not.toBe('connected');
relay = await startRelay(port);
await expect.poll(() => Promise.all([
getStatus(context, extensionId),
getStatus(second.context, second.extensionId)
]).then(states => states.map(state => state.status)), { timeout: 45000 }).toEqual(['connected', 'connected']);
await terminateServiceWorker(second.context, second.extensionId);
await expect.poll(() => getStatus(second.context, second.extensionId), { timeout: 45000 })
.toMatchObject({ status: 'connected', roomId: connection.roomId });
await expect.poll(() => getStatus(context, extensionId).then(status => status.peers.length), { timeout: 45000 })
.toBe(2);
} finally {
await stopRelay(relay);
await second.close();
}
});