mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-28 11:37:16 +00:00
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:
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user