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
+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'
}
});