mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-29 03:57:09 +00:00
test: harden release and browser gates
This commit is contained in:
@@ -19,6 +19,8 @@ npm run test:e2e:race # @race scenarios, repeated 20 times
|
||||
| :--- | :--- |
|
||||
| `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) |
|
||||
|
||||
@@ -263,7 +263,7 @@ test('applies remote play, pause and seek to the framed player', async ({ contex
|
||||
).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -286,7 +286,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);
|
||||
@@ -313,7 +313,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);
|
||||
@@ -342,7 +342,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);
|
||||
@@ -451,7 +451,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);
|
||||
@@ -476,7 +476,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);
|
||||
@@ -530,7 +530,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();
|
||||
@@ -580,7 +580,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);
|
||||
@@ -668,7 +668,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`;
|
||||
@@ -804,7 +804,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,
|
||||
|
||||
@@ -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,62 @@
|
||||
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 response = await fetch(`http://127.0.0.1:${port}/health`);
|
||||
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,7 +12,7 @@ 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: {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
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();
|
||||
const port = await reservePort();
|
||||
let relay = await startRelay(port);
|
||||
try {
|
||||
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