feat: rank player candidates by ordered signals, add browser E2E suite

The weighted score summed incomparable units, so size could outvote traits
that disqualify an element outright. Measured on a real page: a display:none
preload reports its full 1080p intrinsic size and scored 2073600, beating a
visible unmuted player at 509920.

Selection now compares an ordered list of signals, highest priority first:
has a source, is rendered, is not a silent background loop, rendered size
bucket, is playing, has controls, duration. Rendered size replaces intrinsic
resolution, and mute state is gone from the ranking entirely: it is a viewer
preference, not evidence about which element is the player.

It stays a ranking rather than a filter, so a page of only bad candidates
still yields one and findVideo never returns null where a video exists.

The new tests/e2e suite runs the shipped finder against real fixture pages
and drives the packed extension for injection, reinjection and remote
play/pause/seek into a first-party frame. All five scoring scenarios fail
against the previous implementation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
KoalaDev
2026-08-14 07:23:12 +02:00
parent 79a2204a97
commit 36e1291d2c
30 changed files with 781 additions and 29 deletions
+29
View File
@@ -46,3 +46,32 @@ jobs:
- name: Run verification suite
run: npm run verify
e2e:
# Kept separate from `verify`: this job needs a downloaded browser, so a
# failure here should read as "the browser flow broke", not as a broken
# lint or unit run. `verify` stays usable offline and in the release job.
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install root dependencies
run: npm ci
- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium chromium-headless-shell
# The extension specs load dist/chrome, so the artifact has to exist.
- name: Build the extension
run: npm run build:extension
- name: Run extension E2E smoke tests
run: npm run test:e2e
+5
View File
@@ -45,6 +45,11 @@ extension/shared/
website/www/
website/.avif-cache.json
# Playwright E2E artifacts (the fixtures themselves are committed)
test-results/
playwright-report/
blob-report/
# Temporary scratch files
scratch/
+5
View File
@@ -11,6 +11,11 @@ All notable changes to the KoalaSync browser extension and relay server.
- **Extension: Editable Hide-Clutter list** — Adds a validated, deduplicated domain editor in Settings, prefilled with the shipped blacklist, with a defaults reset and per-device persistence in `chrome.storage.local`. The editor groups entries into your own and the shipped defaults, and only your changes are stored, so domains added to the shipped list in later versions still reach you without overriding what you removed or added. Lists saved by an earlier version are migrated automatically.
- **Extension: Independent default audio boost** — Adds a configurable `020 dB` output gain in half-decibel steps. The boost works with or without the compressor and applies live to the selected video tab.
- **Testing: Browser E2E smoke suite** — Adds a local Playwright suite (`npm run test:e2e`) that runs the shipped video finder against real fixture pages and drives the packed extension end to end: injection into the target tab, reinjection after navigation, and remote play, pause and seek applied to a player inside a first-party frame. Runs as its own CI job so `npm run verify` stays browser-free.
### Changed
- **Extension: Rebuilt player selection** — Replaces the weighted score with an ordered list of signals, so a video that cannot be the player (no source, not rendered) is never picked just for being large. Rendered size now decides instead of intrinsic resolution, a video that is actually playing wins between equally sized players, silent looping background videos are demoted, and mute state no longer influences the choice at all.
### Fixed
- **Extension: Google Drive tab selection** — A broad parent domain in the Hide-Clutter list no longer hides a host that has its own supported player path, so Drive videos stay selectable while the filter is on. An exact entry for the host itself still filters it.
- **Extension: Debug report frame visibility** — Video counts now include players inside same-origin frames and the report states whether the selected video sits in a frame.
+5 -6
View File
@@ -57,13 +57,12 @@
- **Possible approach:** Add an opt-in frame bridge (`allFrames: true` injection) where child frames announce detected videos to the top frame, and the top frame routes remote play/pause/seek commands to the active child video. Needs a frame-election rule so ad frames cannot claim the session.
- **Status:** Same-origin part completed; cross-origin frame bridge still open. Not needed for current Emby behavior.
### Local extension E2E smoke tests
### Sync a second video source per room
- **Priority:** P2
- **Category:** Testing / Release Confidence
- **Background:** The release verification covers unit tests, server integration, syntax, lint, audits, and builds, but it does not currently run a real browser extension flow. A small local E2E smoke suite would catch regressions in content-script injection, tab navigation reinjection, remote seek handling, and iframe player support.
- **Possible approach:** Add a separate local-only Playwright smoke command that loads the unpacked extension, opens two controlled video pages, and verifies play/pause/seek through the actual extension path. Keep it outside `npm run verify` until it is stable enough for CI.
- **Status:** Backlog, recommended before larger content-script or frame-bridge changes.
- **Priority:** P3
- **Category:** Compatibility / Player Selection
- **Background:** Detection picks exactly one `<video>` per tab. Pages that legitimately show two players side by side (a lecture feed plus slides, a multi-camera stream) can only ever sync one of them.
- **Status:** Backlog, no demand yet. Listed so the single-player assumption in the ranking is a recorded decision rather than an accident.
---
+78 -22
View File
@@ -566,18 +566,21 @@
};
tryOnce();
tryOnce();
}
}
// Buffer-aware snap-back (#3). An involuntary pause/seek often coincides with the
// player buffering — and a player can't actually play while it's stalled. Snapping
// immediately just fights the buffer (seek → re-buffer → another pause → …), which
// looks like stutter. Instead: if the player isn't ready, wait until it can play
// player buffering — and a player can't actually play while it's stalled. Snapping
// immediately just fights the buffer (seek → re-buffer → another pause → …), which
// (readyState>=3, not seeking), then snap ONCE to the host's *current* position
// (re-queried, since the captured target may be stale by the time buffering ends).
@@ -589,28 +592,81 @@
if (hcmDeferredSnapPending) return; // already waiting — don't stack polls
if (hcmDeferredSnapPending) return; // already waiting — don't stack polls
hcmDeferredSnapPending = true;
const deadline = Date.now() + HCM_BUFFER_WAIT_MS;
const deadline = Date.now() + HCM_BUFFER_WAIT_MS;
const poll = () => {
// Abort if the reason to snap is gone: user went solo, we're no longer a
const poll = () => {
// gated guest, or the video vanished.
if (hcmDesynced || !hcmIsGuestGated()) { hcmDeferredSnapPending = false; return; }
const video = findVideo();
const ready = video && video.readyState >= 3 && !video.seeking;
if (ready || Date.now() >= deadline) {
hcmDeferredSnapPending = false;
hcmRequestHostSyncWithRetry(); // fresh host position + snap once
return;
}
scheduleLifecycleTimeout(poll, 300);
};
poll();
}
// Entry point: background told us our local action was blocked in host-only.
function hcmHandleBlocked(action, target) {
// HOST_BLOCKED is only ever sent to a gated guest (background verifies
// host-only + !host before sending), so it's authoritative. Adopt the
// role/mode from it in case our CONTROL_MODE broadcast hasn't landed yet
// (join race, EC-5) — otherwise we'd miss the dialog/snap-back.
hcmControlMode = 'host-only';
hcmAmController = false;
if (hcmDesynced) return; // already solo, nothing to do
const intent = hcmClassifyIntent();
if (intent === 'live') return; // EC-15: leave the guest alone on live
if (intent === 'involuntary') {
// EC-4 loop guard: only the silent auto snap-back is suppressed by the
// cooldown — the deliberate dialog path below must still go through,
// otherwise a second deliberate pause inside the cooldown window leaves
// the user stuck paused with no UI (M-3).
// gated guest, or the video vanished.
if (hcmDesynced || !hcmIsGuestGated()) { hcmDeferredSnapPending = false; return; }
const video = findVideo();
const ready = video && video.readyState >= 3 && !video.seeking;
if (ready || Date.now() >= deadline) {
hcmDeferredSnapPending = false;
if (Date.now() < hcmSnapBackCooldownUntil || hcmDeferredSnapPending) return;
// Buffering/ads/throttle — silently re-sync, no dialog spam.
const video = findVideo();
if (video && video.readyState >= 3 && !video.seeking) {
// Ready now → snap immediately. Use the captured target if it's
+2
View File
@@ -14,6 +14,8 @@
"lint:fix": "eslint . --fix",
"subset-flags": "node website/tools/subset-flag-font.mjs",
"test": "npm run verify",
"test:e2e": "playwright test --config tests/e2e/playwright.config.mjs",
"test:e2e:install": "playwright install chromium chromium-headless-shell",
"test:unit": "vitest run",
"verify": "node scripts/verify-release.mjs"
},
+27 -1
View File
@@ -61,7 +61,33 @@ const fakeDocument = {
}
};
const fnSource = extractFunction('findVideo', source);
// findVideo delegates to a small set of ranking helpers; they have to be lifted
// together or this would silently test a stale shape of the finder.
const VIDEO_FINDER_PARTS = [
'findVideo',
'collectVideoCandidates',
'getRenderedVideoArea',
'getVideoSizeBucket',
'isVideoRendered',
'hasPlayableVideoSource',
'isBackgroundVideo',
'isVideoPlaying',
'compareVideoRanks',
'pickBestVideo'
];
function extractRankingTable(text) {
const start = text.indexOf('const VIDEO_RANKING_SIGNALS');
assert.notStrictEqual(start, -1, 'VIDEO_RANKING_SIGNALS not found');
const end = text.indexOf('];', start);
assert.notStrictEqual(end, -1, 'VIDEO_RANKING_SIGNALS did not terminate');
return text.slice(start, end + 2);
}
const fnSource = [
...VIDEO_FINDER_PARTS.map(name => extractFunction(name, source)),
extractRankingTable(source)
].join('\n');
const findVideo = Function('document', `${fnSource}; return findVideo;`)(fakeDocument);
const selected = findVideo(fakeDocument);
+63
View File
@@ -0,0 +1,63 @@
# Extension E2E smoke tests
Browser-level tests for the parts that unit tests cannot reach: which `<video>`
the extension picks on a real page, and whether the packed extension gets far
enough to control it.
```bash
npm run test:e2e:install # once, downloads the browsers
npm run build:extension # extension.spec.mjs loads dist/chrome
npm run test:e2e
```
## Layout
| Path | Purpose |
| :--- | :--- |
| `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 |
| `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` |
## Two rules worth keeping
**The specs run the shipped source, not a copy.** `helpers/content-source.mjs`
extracts `findVideo` and its ranking helpers straight out of
`extension/content.js`. A fixture that passes against a reimplementation would
prove nothing about the extension. If you split the finder into more functions,
add them to `VIDEO_FINDER_EXPORTS` there and to `VIDEO_FINDER_PARTS` in
`scripts/test-content-video-finder.cjs`, or the extraction fails loudly.
**Each fixture marks its own answer.** The element that must win carries
`data-expected`; videos that have to be playing carry `data-autoplay`, and
`ready.js` holds the page back until metadata and playback have settled. The
specs assert those preconditions before judging the finder, so a broken fixture
reads as a broken fixture instead of a scoring regression.
## Scenarios
| Fixture | What it pins down |
| :--- | :--- |
| `simple-player.html` | The ordinary case |
| `iframe-player.html` | Player inside a same-origin frame, empty top document |
| `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 |
| `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 |
| `multi-player.html` | Between equal players, the playing one wins |
| `sourceless.html` | A large `<video>` with no source can never be the player |
## Regenerating the media
Solid-colour clips, a few KB each, committed so the suite needs no network:
```bash
ffmpeg -y -f lavfi -i "color=c=blue:s=1920x1080:d=30:r=10" -c:v libx264 -preset veryfast -crf 45 -pix_fmt yuv420p -movflags +faststart fixtures/media/player-1080p-30s.mp4
```
Same command with `green/854x480/12`, `red/640x360/5` and `gray/1280x720/3` for
the other three.
+86
View File
@@ -0,0 +1,86 @@
import { test, expect } from '@playwright/test';
import { buildVideoFinderScript } from './helpers/content-source.mjs';
/**
* Runs the shipped findVideo() against real pages in a real browser, where
* videoWidth, offsetParent, paused and duration all carry their true values.
* Each fixture marks the element that must win with data-expected.
*/
const SCENARIOS = [
{ page: 'simple-player.html', expected: 'player', what: 'the only visible player' },
{ page: 'iframe-player.html', expected: 'framed-player', what: 'a player inside a same-origin frame' },
{ 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: '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' },
{ page: 'multi-player.html', expected: 'watched', what: 'the player that is actually playing' },
{ page: 'sourceless.html', expected: 'player', what: 'the real player over a large sourceless placeholder' }
];
test.describe('video detection', () => {
for (const { page: fixture, expected, what } of SCENARIOS) {
test(`picks ${what} (${fixture})`, async ({ page }) => {
await page.goto(`/pages/${fixture}`);
await page.waitForFunction(() => window.__fixtureReady === true);
// Assert the fixture is in the state it claims before judging the
// finder, so a broken fixture never reads as a scoring regression.
const preconditions = await page.evaluate(() => {
const videos = [];
const walk = (doc) => {
for (const v of doc.querySelectorAll('video')) videos.push(v);
for (const f of doc.querySelectorAll('iframe')) {
try { if (f.contentDocument) walk(f.contentDocument); } catch (_e) { /* cross-origin */ }
}
for (const el of doc.querySelectorAll('*')) if (el.shadowRoot) walk(el.shadowRoot);
};
walk(document);
return videos
.filter(v => v.dataset.autoplay !== undefined)
.map(v => ({ id: v.id, paused: v.paused, ended: v.ended }));
});
for (const video of preconditions) {
expect(video.paused, `fixture video ${video.id} should be playing`).toBe(false);
expect(video.ended, `fixture video ${video.id} should not have ended`).toBe(false);
}
await page.addScriptTag({ content: buildVideoFinderScript() });
const picked = await page.evaluate(() => {
const video = window.__koalaFindVideo();
if (!video) return null;
return {
id: video.id,
expected: video.dataset.expected !== undefined,
inFrame: video.ownerDocument !== document
};
});
expect(picked, 'a video must be found at all').not.toBeNull();
expect(picked.id).toBe(expected);
expect(picked.expected, `${picked.id} is not the element marked data-expected`).toBe(true);
});
}
test('finds a player frame that is attached later (late-frame.html)', async ({ page }) => {
await page.goto('/pages/late-frame.html');
await page.addScriptTag({ content: buildVideoFinderScript() });
// Nothing to find while the slot is still empty.
expect(await page.evaluate(() => window.__koalaFindVideo() === null)).toBe(true);
await page.waitForFunction(() => {
const video = window.__koalaFindVideo();
return !!video && video.readyState >= 1;
});
expect(await page.evaluate(() => window.__koalaFindVideo().id)).toBe('framed-player');
});
test('returns null on a page without any video', async ({ page }) => {
await page.setContent('<h1>no media here</h1>');
await page.addScriptTag({ content: buildVideoFinderScript() });
expect(await page.evaluate(() => window.__koalaFindVideo())).toBeNull();
});
});
+143
View File
@@ -0,0 +1,143 @@
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import { test as base, expect, chromium } from '@playwright/test';
/**
* Drives the packed extension itself: real background service worker, real
* chrome.scripting injection, real runtime messaging. The detection specs cover
* which element gets picked; this file covers whether the extension ever gets
* far enough to pick one.
*/
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const extensionPath = path.join(repoRoot, 'dist/chrome');
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');
}
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();
if (!worker) worker = await context.waitForEvent('serviceworker');
await use(worker.url().split('/')[2]);
}
});
/** Runs code in an extension page, where the privileged chrome.* APIs exist. */
async function withExtensionPage(context, extensionId, fn) {
const page = await context.newPage();
await page.goto(`chrome-extension://${extensionId}/popup.html`);
const result = await fn(page);
await page.close();
return result;
}
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}`);
const response = await chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId: tab.id });
return { tabId: tab.id, response };
}, pageUrl));
}
async function sendServerCommand(context, extensionId, tabId, action, payload) {
return withExtensionPage(context, extensionId, page => page.evaluate(async ({ tabId, action, payload }) => {
return chrome.tabs.sendMessage(tabId, {
type: 'SERVER_COMMAND',
action,
payload,
actionTimestamp: Date.now(),
commandSenderId: 'e2e'
});
}, { tabId, action, payload }));
}
test('injects into the target tab and attaches to a same-origin frame player', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/iframe-player.html`;
const page = await context.newPage();
await page.goto(url);
await page.waitForFunction(() => window.__fixtureReady === true);
const { response } = await selectTargetTab(context, extensionId, url);
expect(response?.status, 'SET_TARGET_TAB should not report a failure').not.toBe('error');
await expect.poll(
() => page.evaluate(() => {
const video = document.querySelector('iframe').contentDocument.querySelector('video');
return video ? video.dataset.koalaAttached : null;
}),
{ message: 'content script should attach to the video inside the frame' }
).toBe('true');
});
test('applies remote play, pause and seek to the framed player', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/iframe-player.html`;
const page = await context.newPage();
await page.goto(url);
await page.waitForFunction(() => window.__fixtureReady === true);
const { tabId } = await selectTargetTab(context, extensionId, url);
await expect.poll(() => page.evaluate(() => {
const video = document.querySelector('iframe').contentDocument.querySelector('video');
return video ? video.dataset.koalaAttached : null;
})).toBe('true');
await sendServerCommand(context, extensionId, tabId, 'play');
await expect.poll(() => page.evaluate(FRAMED_VIDEO_PAUSED), { message: 'remote play should start playback' }).toBe(false);
await sendServerCommand(context, extensionId, tabId, 'pause');
await expect.poll(() => page.evaluate(FRAMED_VIDEO_PAUSED), { message: 'remote pause should stop playback' }).toBe(true);
await sendServerCommand(context, extensionId, tabId, 'seek', { targetTime: 6 });
await expect.poll(
() => page.evaluate(() => document.querySelector('iframe').contentDocument.querySelector('video').currentTime),
{ message: 'remote seek should move the framed player' }
).toBeGreaterThan(5);
});
test('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);
await page.waitForFunction(() => window.__fixtureReady === true);
await selectTargetTab(context, extensionId, first);
await expect.poll(() => page.evaluate(() => {
const video = document.querySelector('iframe').contentDocument.querySelector('video');
return video ? video.dataset.koalaAttached : null;
})).toBe('true');
await page.goto(`${baseURL}/pages/simple-player.html`);
await page.waitForFunction(() => window.__fixtureReady === true);
await expect.poll(
() => page.evaluate(() => {
const video = document.getElementById('player');
return video ? video.dataset.koalaAttached : null;
}),
{ message: 'the content script should come back after a navigation' }
).toBe('true');
});
function FRAMED_VIDEO_PAUSED() {
return document.querySelector('iframe').contentDocument.querySelector('video').paused;
}
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env node
/**
* Static file server for the E2E fixtures. Local only, no directory listing,
* paths are resolved and then checked to stay inside the fixture root.
*/
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'fixtures');
const port = Number(process.argv[2] || 4173);
const TYPES = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mp4': 'video/mp4',
'.json': 'application/json'
};
const server = http.createServer((req, res) => {
const requested = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
const filePath = path.resolve(root, `.${requested}`);
if (!filePath.startsWith(root + path.sep)) {
res.writeHead(403).end('forbidden');
return;
}
fs.stat(filePath, (statErr, stat) => {
if (statErr || !stat.isFile()) {
res.writeHead(404).end('not found');
return;
}
const contentType = TYPES[path.extname(filePath)] || 'application/octet-stream';
// Media needs byte ranges: without them Chromium reports an empty
// seekable range and seeking silently does nothing, which would make
// the remote-seek test fail for a reason that has nothing to do with
// the extension.
const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '');
if (range) {
const start = range[1] ? Number(range[1]) : 0;
const end = range[2] ? Number(range[2]) : stat.size - 1;
if (Number.isNaN(start) || Number.isNaN(end) || start > end || end >= stat.size) {
res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` }).end();
return;
}
res.writeHead(206, {
'Content-Type': contentType,
'Content-Length': end - start + 1,
'Content-Range': `bytes ${start}-${end}/${stat.size}`,
'Accept-Ranges': 'bytes',
'Cache-Control': 'no-store'
});
fs.createReadStream(filePath, { start, end }).pipe(res);
return;
}
res.writeHead(200, {
'Content-Type': contentType,
'Content-Length': stat.size,
'Accept-Ranges': 'bytes',
'Cache-Control': 'no-store'
});
fs.createReadStream(filePath).pipe(res);
});
});
server.listen(port, '127.0.0.1', () => {
console.log(`fixture server on http://localhost:${port}`);
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
<!doctype html>
<meta charset="utf-8">
<title>Ad in a same-origin frame</title>
<h1>Real player plus a muted autoplay ad in a first-party frame</h1>
<video id="player" data-expected width="854" height="480" controls data-autoplay src="../media/player-480p-12s.mp4"></video>
<iframe width="300" height="250" src="frames/ad-frame.html"></iframe>
<script src="ready.js"></script>
@@ -0,0 +1,13 @@
<!doctype html>
<meta charset="utf-8">
<title>Background hero loop</title>
<style>
body { margin: 0 }
#hero { display: block; width: 1280px; height: 720px; object-fit: cover }
</style>
<!-- Marketing hero: large, muted, looping, no controls. Bigger than the real
player in every dimension the old scoring looked at. -->
<video id="hero" muted loop data-autoplay src="../media/loop-720p-3s.mp4"></video>
<h1>Article with an embedded player below the hero</h1>
<video id="player" data-expected width="640" height="360" controls src="../media/player-480p-12s.mp4"></video>
<script src="ready.js"></script>
@@ -0,0 +1,7 @@
<!doctype html>
<meta charset="utf-8">
<title>Ad slot</title>
<style>body { margin: 0 }</style>
<!-- A 1080p asset rendered into a small ad slot: intrinsic size and rendered
size disagree, which is exactly what the scoring has to get right. -->
<video id="ad" width="300" height="250" muted loop data-autoplay src="../../media/player-1080p-30s.mp4"></video>
@@ -0,0 +1,5 @@
<!doctype html>
<meta charset="utf-8">
<title>V</title>
<style>body { margin: 0 }</style>
<video id="framed-player" data-expected width="854" height="480" controls src="../../media/player-480p-12s.mp4"></video>
@@ -0,0 +1,9 @@
<!doctype html>
<meta charset="utf-8">
<title>Hidden preload</title>
<h1>Visible 480p player next to a hidden 1080p preload</h1>
<!-- The hidden element reports its full intrinsic resolution even though it is
not rendered at all, so any scoring based on videoWidth prefers it. -->
<video id="preload" style="display:none" muted src="../media/player-1080p-30s.mp4"></video>
<video id="player" data-expected width="854" height="480" controls src="../media/player-480p-12s.mp4"></video>
<script src="ready.js"></script>
@@ -0,0 +1,6 @@
<!doctype html>
<meta charset="utf-8">
<title>Same-origin iframe player</title>
<h1>Player lives in a first-party frame, top document has no video</h1>
<iframe class="player_frame" width="860" height="490" src="frames/player-frame.html"></iframe>
<script src="ready.js"></script>
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<meta charset="utf-8">
<title>Late loading player frame</title>
<h1>The player frame is attached after the page has settled</h1>
<div id="slot"></div>
<script>
// Mirrors sites that build the player frame from script once the episode
// data arrives, well after the content script was injected.
setTimeout(() => {
const frame = document.createElement('iframe');
frame.width = 860;
frame.height = 490;
frame.src = 'frames/player-frame.html';
document.getElementById('slot').appendChild(frame);
}, 400);
</script>
@@ -0,0 +1,11 @@
<!doctype html>
<meta charset="utf-8">
<title>Two players, one playing</title>
<h1>Identical players, the second one is the one being watched</h1>
<!-- Same size, same asset, same mute state. Playback is the only signal that
tells the two apart, so a scorer that ignores it picks by DOM order. -->
<!-- The longer asset gives the watched player enough runway that it cannot
reach its end while the test is still looking at it. -->
<video id="idle" width="854" height="480" controls src="../media/player-1080p-30s.mp4"></video>
<video id="watched" data-expected width="854" height="480" controls data-autoplay src="../media/player-1080p-30s.mp4"></video>
<script src="ready.js"></script>
@@ -0,0 +1,8 @@
<!doctype html>
<meta charset="utf-8">
<title>Muted real player</title>
<h1>The only player on the page, muted by the viewer</h1>
<!-- Guard against over-filtering: mute is a viewer preference, not a reason to
disqualify the one real player. -->
<video id="player" data-expected width="854" height="480" controls muted src="../media/player-480p-12s.mp4"></video>
<script src="ready.js"></script>
+51
View File
@@ -0,0 +1,51 @@
/**
* Fixture helper. Marks the page ready once every <video> that is supposed to
* carry metadata has it, and once every video marked data-autoplay is actually
* playing. Tests wait for window.__fixtureReady instead of sleeping, so the
* scoring signals (videoWidth, duration, paused) are settled before we look.
*/
(function () {
window.__fixtureReady = false;
function collectVideos(doc, out) {
for (const video of doc.querySelectorAll('video')) out.push(video);
for (const frame of doc.querySelectorAll('iframe')) {
let frameDoc = null;
try { frameDoc = frame.contentDocument; } catch (_e) { frameDoc = null; }
if (frameDoc) collectVideos(frameDoc, out);
}
for (const host of doc.querySelectorAll('*')) {
if (host.shadowRoot) collectVideos(host.shadowRoot, out);
}
return out;
}
function metadataReady(video) {
if (video.dataset.sourceless !== undefined) return true;
return video.readyState >= 1;
}
function playbackReady(video) {
if (video.dataset.autoplay === undefined) return true;
return !video.paused;
}
function check() {
const videos = collectVideos(document, []);
if (!videos.length) return false;
if (!videos.every(v => metadataReady(v) && playbackReady(v))) return false;
window.__fixtureReady = true;
return true;
}
function start() {
for (const video of collectVideos(document, [])) {
if (video.dataset.autoplay !== undefined) video.play().catch(() => {});
}
if (check()) return;
const timer = setInterval(() => { if (check()) clearInterval(timer); }, 50);
}
if (document.readyState === 'complete') start();
else window.addEventListener('load', start);
})();
@@ -0,0 +1,19 @@
<!doctype html>
<meta charset="utf-8">
<title>Shadow DOM player</title>
<h1>Tiny light-DOM teaser, real player inside a shadow root</h1>
<video id="teaser" width="160" height="90" muted src="../media/ad-360p-5s.mp4"></video>
<div id="player-host"></div>
<script>
const host = document.getElementById('player-host');
const root = host.attachShadow({ mode: 'open' });
const video = document.createElement('video');
video.id = 'shadow-player';
video.setAttribute('data-expected', '');
video.width = 854;
video.height = 480;
video.controls = true;
video.src = '../media/player-480p-12s.mp4';
root.appendChild(video);
</script>
<script src="ready.js"></script>
@@ -0,0 +1,6 @@
<!doctype html>
<meta charset="utf-8">
<title>Simple player</title>
<h1>Single visible player</h1>
<video id="player" data-expected width="854" height="480" controls src="../media/player-480p-12s.mp4"></video>
<script src="ready.js"></script>
+9
View File
@@ -0,0 +1,9 @@
<!doctype html>
<meta charset="utf-8">
<title>Sourceless placeholder</title>
<h1>Large empty placeholder next to the real player</h1>
<!-- No src at all: readyState stays 0 and it can never be synchronized, but it
occupies a big layout box. -->
<video id="placeholder" data-sourceless style="width:1280px;height:720px"></video>
<video id="player" data-expected width="640" height="360" controls src="../media/player-480p-12s.mp4"></video>
<script src="ready.js"></script>
+65
View File
@@ -0,0 +1,65 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
const contentPath = path.join(repoRoot, 'extension/content.js');
/**
* Pulls a top-level function out of content.js by brace matching.
*
* The E2E suite deliberately runs the shipped source rather than a copy: a
* fixture that passes against a reimplementation proves nothing about what the
* extension actually does.
*/
export function extractFunction(name, source = fs.readFileSync(contentPath, 'utf8')) {
const start = source.indexOf(`function ${name}`);
if (start === -1) throw new Error(`${name} not found in extension/content.js`);
let depth = 0;
for (let i = source.indexOf('{', start); i < source.length; i++) {
if (source[i] === '{') depth++;
if (source[i] === '}') {
depth--;
if (depth === 0) return source.slice(start, i + 1);
}
}
throw new Error(`${name} body did not terminate`);
}
/**
* Every function the video finder needs, as one evaluatable script that exposes
* window.__koalaFindVideo. Keeping this list here means a refactor that splits
* findVideo into helpers fails loudly instead of silently testing stale code.
*/
export const VIDEO_FINDER_EXPORTS = [
'findVideo',
'collectVideoCandidates',
'getRenderedVideoArea',
'getVideoSizeBucket',
'isVideoRendered',
'hasPlayableVideoSource',
'isBackgroundVideo',
'isVideoPlaying',
'compareVideoRanks',
'pickBestVideo'
];
/** The ranking table lives outside a function, so it is lifted by pattern. */
function extractRankingTable(source) {
const start = source.indexOf('const VIDEO_RANKING_SIGNALS');
if (start === -1) throw new Error('VIDEO_RANKING_SIGNALS not found in extension/content.js');
const end = source.indexOf('];', start);
if (end === -1) throw new Error('VIDEO_RANKING_SIGNALS did not terminate');
return source.slice(start, end + 2);
}
export function buildVideoFinderScript() {
const source = fs.readFileSync(contentPath, 'utf8');
const bodies = VIDEO_FINDER_EXPORTS.map(name => extractFunction(name, source));
return [
...bodies,
extractRankingTable(source),
'window.__koalaFindVideo = findVideo;'
].join('\n');
}
+34
View File
@@ -0,0 +1,34 @@
import { defineConfig } from '@playwright/test';
const PORT = Number(process.env.KOALA_E2E_PORT || 4173);
export default defineConfig({
testDir: '.',
testMatch: '**/*.spec.mjs',
// Extension tests drive a persistent context and a service worker; running
// them in parallel makes the profile directories fight each other.
workers: 1,
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 0,
reporter: process.env.CI ? 'list' : [['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']
}
},
webServer: {
command: `node ${new URL('./fixture-server.mjs', import.meta.url).pathname} ${PORT}`,
url: `http://localhost:${PORT}/pages/simple-player.html`,
reuseExistingServer: !process.env.CI,
stdout: 'ignore',
stderr: 'pipe'
}
});