mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-30 12:29:27 +00:00
merge main and harden canonical media recovery
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('')}`);
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user