liseur-sync/internal/webui/testdata/readerfirefox.mjs
copilot-swe-agent[bot] de60837fa1
Some checks failed
build / binary (push) Waiting to run
build / container (push) Waiting to run
test / test (push) Waiting to run
test / lint-openapi (push) Waiting to run
Copilot Setup Steps / copilot-setup-steps (push) Has been cancelled
fix: keep reader selection colors owned by the active theme
Co-authored-by: chmouel <98980+chmouel@users.noreply.github.com>
2026-09-09 09:47:13 +02:00

448 lines
20 KiB
JavaScript

// Firefox harness for the reader, driven over WebDriver BiDi.
//
// Chromium is not the only browser that has to lay a book out, and the
// two disagree about exactly the things this reader depends on: how a
// sandboxed frame inherits a policy, and what a blocked script reports.
// This runs the same checks as the Chromium harness against Firefox, so
// "works here" means something.
import { spawn } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const firefox = process.env.SMOKE_FIREFOX || 'firefox';
const url = process.env.SMOKE_URL;
const cookie = process.env.SMOKE_COOKIE;
const host = process.env.SMOKE_HOST;
const detached = process.env.SMOKE_DETACHED === '1';
const readerHost = process.env.SMOKE_READER_HOST || '';
// A browser that never answers must not become a ten-minute test. The
// watchdog reports the last thing that was asked of it, which is the
// only useful thing to know about a hang.
let step = 'starting firefox';
const at = (s) => { step = s; };
setTimeout(() => {
console.error('firefox harness stuck at: ' + step);
process.exit(2);
}, 120000);
const profile = mkdtempSync(join(tmpdir(), 'ffsmoke-'));
// Firefox will not resolve a made-up hostname, and unlike Chromium it
// has no resolver-rules flag. A proxy autoconfig that sends everything
// to the loopback listener is the equivalent.
if (readerHost) {
const port = readerHost.split(':')[1];
const pac = `function FindProxyForURL(u, h) { return "PROXY 127.0.0.1:${port}"; }`;
writeFileSync(join(profile, 'proxy.pac'), pac);
writeFileSync(join(profile, 'user.js'), [
'user_pref("network.proxy.type", 2);',
`user_pref("network.proxy.autoconfig_url", "file://${join(profile, 'proxy.pac')}");`,
'user_pref("network.proxy.allow_hijacking_localhost", true);',
].join('\n'));
}
const proc = spawn(firefox, [
'--headless', '--no-remote', '--profile', profile,
'--remote-debugging-port=0', 'about:blank',
], { stdio: ['ignore', 'ignore', 'pipe'] });
process.on('exit', () => proc.kill());
process.on('SIGTERM', () => process.exit(1));
const wsURL = await new Promise((res, rej) => {
let buf = '';
const to = setTimeout(() => rej(new Error('firefox printed no bidi url: ' + buf)), 30000);
proc.stderr.on('data', (d) => {
buf += d;
const m = buf.match(/ws:\/\/[^\s]+/);
if (m) { clearTimeout(to); res(m[0]); }
});
});
at('opening the bidi socket');
// Firefox prints the endpoint, but a session is only created on the
// /session path; the bare endpoint answers with a non-101 and the
// socket hangs open-less forever.
const ws = new WebSocket(wsURL + '/session');
ws.addEventListener('error', (e) => {
console.error('bidi socket failed: ' + (e.message || 'unknown'));
process.exit(2);
});
await new Promise((r) => ws.addEventListener('open', r, { once: true }));
let nextID = 0;
const pending = new Map();
const consoleErrors = [];
ws.addEventListener('message', (ev) => {
const msg = JSON.parse(ev.data);
if (msg.id && pending.has(msg.id)) {
const { res, rej } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? rej(new Error(JSON.stringify(msg))) : res(msg.result);
return;
}
if (msg.method === 'log.entryAdded' && msg.params.level === 'error') {
const where = msg.params.source?.realm || '';
if (!/favicon/.test(msg.params.text || '')) {
consoleErrors.push(msg.params.text + (where ? ' @ ' + where : ''));
}
}
});
const send = (method, params = {}) => new Promise((res, rej) => {
const id = ++nextID;
pending.set(id, { res, rej });
ws.send(JSON.stringify({ id, method, params }));
});
at('session.new');
await send('session.new', { capabilities: {} });
await send('session.subscribe', { events: ['log.entryAdded'] });
at('getTree');
const tree = await send('browsingContext.getTree', {});
const context = tree.contexts[0].context;
const [name, value] = cookie.split('=');
at('setCookie');
await send('storage.setCookie', {
cookie: {
name, value: { type: 'string', value },
domain: host.split(':')[0], path: '/',
},
});
at('navigate');
await send('browsingContext.navigate', { context, url, wait: 'complete' });
const evalIn = async (expression) => {
const r = await send('script.evaluate', {
expression, target: { context }, awaitPromise: true, resultOwnership: 'none',
});
if (r.type === 'exception') throw new Error('eval threw: ' + JSON.stringify(r.exceptionDetails));
return r.result.value;
};
async function waitFor(expression, description, timeout = 10000) {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
if (await evalIn(expression)) return;
await new Promise(resolve => setTimeout(resolve, 50));
}
const state = await evalIn(`JSON.stringify((() => {
const view = document.querySelector('readium-view');
return { location: view?.lastLocation, contents: view?.renderer?.getContents?.().map(({ doc, index }) => ({
index, selection: String(doc.getSelection()), href: doc.documentElement.dataset.readerHref,
frame: doc.defaultView.frameElement.getBoundingClientRect().toJSON(),
})) };
})())`);
throw new Error('Timed out waiting for ' + description + ': ' + state);
}
await waitFor(`document.querySelector('readium-view')?.renderer?.getContents?.().some(({ doc }) => doc?.body) &&
document.getElementById('reader-status')?.textContent === ''`, 'the publication to open');
const fail = [];
const check = (name, ok, extra = '') => {
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`);
if (!ok) fail.push(name);
};
check('page loads', typeof (await evalIn('document.title')) === 'string',
await evalIn('document.title'));
if (detached) {
const at = JSON.parse(await evalIn('JSON.stringify({href: location.href, cookie: document.cookie})'));
check('the reader was handed off to the other origin', at.href.includes(readerHost.split(':')[0]), at.href);
check('the credential was erased from the URL', !at.href.includes('#'), at.href);
check('the reader origin holds no cookie', at.cookie === '', at.cookie);
}
// Readium owns the frame pool, so the probe goes through the engine's public
// API rather than depending on its frame layout.
const probe = `(() => {
const view = document.querySelector('readium-view');
const contents = view?.renderer?.getContents?.() ?? [];
const doc = contents[0]?.doc;
const body = doc?.body;
const loc = view?.lastLocation;
return JSON.stringify({
status: document.getElementById('reader-status')?.textContent,
chapter: document.getElementById('reader-chapter')?.textContent,
progress: document.getElementById('reader-progress-text')?.textContent,
title: document.getElementById('reader-title-text')?.textContent,
hasDoc: !!doc,
frameSandboxed: (() => {
const frame = document.querySelector('#reader-view iframe');
return !!frame && frame.contentWindow.location.href.startsWith('blob:') &&
frame.sandbox.contains('allow-same-origin') &&
frame.sandbox.contains('allow-scripts') &&
!frame.sandbox.contains('allow-top-navigation');
})(),
text: body ? (body.innerText || '').slice(0, 60) : '',
colour: body ? doc.defaultView.getComputedStyle(body).color : '',
selectionBackground: body ? doc.defaultView.getComputedStyle(body, '::selection').backgroundColor : '',
selectionColour: body ? doc.defaultView.getComputedStyle(body, '::selection').color : '',
selectionTokenBackground: doc
? doc.defaultView.getComputedStyle(doc.documentElement).getPropertyValue('--liseur-selection-bg').trim() : '',
selectionTokenColour: doc
? doc.defaultView.getComputedStyle(doc.documentElement).getPropertyValue('--liseur-selection-fg').trim() : '',
stageBackground: document.getElementById('reader-view')
? getComputedStyle(document.getElementById('reader-view')).backgroundColor : '',
fraction: typeof loc?.fraction === 'number' ? +loc.fraction.toFixed(4) : -1,
cfi: loc?.cfi || JSON.stringify(loc?.locator || ''),
ran: doc ? !!doc.documentElement.dataset.publicationRan : null,
svgRan: doc ? !!doc.documentElement.dataset.svgRan : null,
extRan: doc ? typeof doc.defaultView.htmx !== 'undefined' : null,
pageTitle: document.title,
});
})()`;
at('first probe');
const diag = JSON.parse(await evalIn(probe));
console.log('diag:', JSON.stringify(diag));
check('no error banner', !diag.status, diag.status);
check('the engine rendered a chapter', diag.hasDoc && diag.text.length > 10,
`doc=${diag.hasDoc} text=${JSON.stringify(diag.text)}`);
check('the title came out of the publication', diag.title === 'Moby-Dick', diag.title);
check('reader shows the book: own chapter label',
diag.chapter === 'Title Page', diag.chapter);
check('publication stylesheet was applied',
diag.colour.replace(/\s/g, '') === 'rgb(17,34,51)', diag.colour);
check('publication script did not run', diag.ran === false, String(diag.ran));
at('turning pages');
const seen = [];
for (let i = 0; i < 10; i++) {
// Observe the button's navigation promise so the next click cannot overlap
// a chapter transition on slower browser engines.
await evalIn(`(async () => {
const view = document.querySelector('readium-view');
const turn = view.turn;
let navigation;
view.turn = function(...args) { return navigation = turn.apply(this, args); };
try {
document.getElementById('reader-next').click();
if (!navigation) throw new Error('The next-page button did not navigate');
await navigation;
} finally { view.turn = turn; }
})()`);
await waitFor(`document.querySelector('readium-view').renderer.getContents().some(({ doc }) => doc?.body)`,
'the turned page to render');
const now = JSON.parse(await evalIn(probe));
seen.push({ page: i + 2, chapter: now.chapter, progress: now.progress, fraction: now.fraction, cfi: now.cfi });
}
console.log('page turns:', JSON.stringify(seen, null, 1));
const distinct = new Set(seen.map((p) => p.chapter + '|' + p.fraction + '|' + p.cfi)).size;
check('the book pages past page 2', distinct >= 6, `${distinct} distinct pages in 10 turns`);
check('the reader leaves the first chapter',
seen.some((p) => p.chapter !== diag.chapter), seen.map((p) => p.chapter).join(' '));
// Same appearance round-trip as the Chromium harness: the theme owns the
// publication colours, stage background and live selection treatment, all
// of which must switch without reopening the book.
at('appearance settings');
for (const [value, label, colour, background, selectionTokenBackground, selectionTokenColour,
selectionBackground, selectionColour] of [
['light', 'Light', 'rgb(27,27,31)', 'rgb(255,255,255)', '#cfe3ff', '#1b1b1f', 'rgb(207,227,255)', 'rgb(27,27,31)'],
['sepia', 'Sepia', 'rgb(91,70,54)', 'rgb(246,236,217)', '#d8c2a0', '#4a392c', 'rgb(216,194,160)', 'rgb(74,57,44)'],
['dark', 'Dark', 'rgb(207,207,212)', 'rgb(32,33,36)', '#4f6fbe', '#f5f7ff', 'rgb(79,111,190)', 'rgb(245,247,255)'],
['tokyo-night', 'Tokyo Night', 'rgb(192,202,245)', 'rgb(26,27,38)', '#445c9b', '#eef2ff', 'rgb(68,92,155)', 'rgb(238,242,255)'],
['rose-pine', 'Rosé Pine', 'rgb(224,222,244)', 'rgb(25,23,36)', '#5c4a88', '#f7f4ff', 'rgb(92,74,136)', 'rgb(247,244,255)'],
['black', 'Black', 'rgb(171,171,174)', 'rgb(0,0,0)', '#375f9d', '#f5f7ff', 'rgb(55,95,157)', 'rgb(245,247,255)'],
]) {
await evalIn(`(() => {
const radio = document.querySelector(
'#reader-settings-form input[name="theme"][value="${value}"]',
);
radio.checked = true;
radio.dispatchEvent(new Event('input', { bubbles: true }));
})()`);
await waitFor(`(() => {
const doc = document.querySelector('readium-view').renderer.getContents()[0]?.doc;
return doc && doc.defaultView.getComputedStyle(doc.body).color.replace(/\\s/g, '') === ${JSON.stringify(colour)};
})()`, 'the ' + label + ' publication theme');
const themed = JSON.parse(await evalIn(probe));
check(`the ${label} theme restyles the publication`,
themed.colour.replace(/\s/g, '') === colour, themed.colour);
check(`the ${label} theme colors the reader stage`,
themed.stageBackground.replace(/\s/g, '') === background,
themed.stageBackground);
check(`the ${label} theme updates selection tokens`,
themed.selectionTokenBackground !== '' &&
themed.selectionTokenBackground.toLowerCase() === selectionTokenBackground &&
themed.selectionTokenColour.toLowerCase() === selectionTokenColour,
JSON.stringify({
bg: themed.selectionTokenBackground,
fg: themed.selectionTokenColour,
}));
check(`the ${label} theme styles browser text selection`,
themed.selectionBackground.replace(/\s/g, '') === selectionBackground &&
themed.selectionColour.replace(/\s/g, '') === selectionColour,
JSON.stringify({
bg: themed.selectionBackground,
fg: themed.selectionColour,
}));
}
await evalIn(`document.getElementById('reader-settings-reset').click()`);
await waitFor(`(() => {
const doc = document.querySelector('readium-view').renderer.getContents()[0]?.doc;
return doc && doc.defaultView.getComputedStyle(doc.body).color.replace(/\\s/g, '') === 'rgb(17,34,51)';
})()`, 'the publisher styling');
const unthemed = JSON.parse(await evalIn(probe));
check('reset restores the publisher styling',
unthemed.colour.replace(/\s/g, '') === 'rgb(17,34,51)', unthemed.colour);
// A risk-focused slice of the Chromium chrome/tap matrix. Gecko is
// where this can differ: frameElement coordinates, PointerEvent
// delivery inside a blob chapter, `:has()` in the chrome CSS, and
// caretPositionFromPoint standing in for Blink's caretRangeFromPoint
// when the reader decides whether a click landed on a word.
at('auto-hiding chrome');
// Gesture checks need enough text for both turns to stay in one chapter.
// Chapter transitions were exercised above; use the fixture's long chapter
// here so pointer handling is measured independently of frame replacement.
await evalIn(`document.querySelector('readium-view').goTo(1)`);
const chromeState = () => evalIn(`JSON.stringify({
state: document.body.dataset.readerChrome,
bar: getComputedStyle(document.querySelector('.reader-bar')).opacity,
})`);
const defaultChrome = JSON.parse(await chromeState());
const defaultAutoHide = await evalIn(
`document.querySelector('#reader-settings-form input[name="autohide"]').checked`,
);
check('the chrome stays visible by default',
defaultAutoHide === false && defaultChrome.state === 'visible' && defaultChrome.bar === '1',
JSON.stringify({ defaultAutoHide, ...defaultChrome }));
await evalIn(`(() => {
const box = document.querySelector('#reader-settings-form input[name="autohide"]');
box.checked = true;
box.dispatchEvent(new Event('input', { bubbles: true }));
return true;
})()`);
const ffTap = (where, kind) => evalIn(`(() => {
const doc = document.querySelector('readium-view').renderer.getContents()[0].doc;
const win = doc.defaultView;
const box = win.frameElement.getBoundingClientRect();
const xs = { left: 6, right: window.innerWidth - 6 };
const x = xs['${where}'] - box.left, y = window.innerHeight / 2 - box.top;
const at = (type, extra) => doc.body.dispatchEvent(new win.PointerEvent(type, {
bubbles: true, button: 0, pointerType: '${kind}', clientX: x, clientY: y, ...extra,
}));
at('pointerdown');
at('pointerup');
doc.body.dispatchEvent(new win.MouseEvent('click', {
bubbles: true, button: 0, detail: 1, clientX: x, clientY: y,
}));
return true;
})()`);
await new Promise((r) => setTimeout(r, 2600));
const ffParked = JSON.parse(await chromeState());
check('the chrome steps aside while reading',
ffParked.state === 'hidden' && ffParked.bar === '0', JSON.stringify(ffParked));
await evalIn(`(() => {
document.dispatchEvent(new PointerEvent('pointermove', {
bubbles: true, clientX: window.innerWidth - 4, clientY: window.innerHeight / 2,
}));
return true;
})()`);
await new Promise((r) => setTimeout(r, 400));
const ffPassedSide = JSON.parse(await chromeState());
check('moving along the side leaves the chrome hidden',
ffPassedSide.state === 'hidden' && ffPassedSide.bar === '0', JSON.stringify(ffPassedSide));
await evalIn(`(() => {
document.dispatchEvent(new PointerEvent('pointermove', {
bubbles: true, clientX: window.innerWidth / 2, clientY: 4,
}));
return true;
})()`);
await new Promise((r) => setTimeout(r, 400));
const ffReached = JSON.parse(await chromeState());
check('reaching for the top of the window brings the chrome back',
ffReached.state === 'visible' && ffReached.bar === '1', JSON.stringify(ffReached));
await new Promise((r) => setTimeout(r, 2600));
const ffBefore = JSON.parse(await evalIn(probe));
await ffTap('right', 'touch');
await waitFor(`document.querySelector('readium-view').lastLocation.fraction > ${ffBefore.fraction} &&
document.querySelector('readium-view').renderer.getContents().some(({ doc }) => doc?.body)`, 'the touch page turn');
const ffForward = JSON.parse(await evalIn(probe));
check('a tap on the right of the text turns the page',
ffForward.fraction > ffBefore.fraction,
`${ffBefore.fraction} -> ${ffForward.fraction}`);
// The mouse path is the one that has to ask Gecko where the caret is.
// Let the 700ms suppression of synthetic mouse events after touch expire.
await new Promise(resolve => setTimeout(resolve, 750));
await ffTap('right', 'mouse');
await waitFor(`document.querySelector('readium-view').lastLocation.fraction > ${ffForward.fraction} &&
document.querySelector('readium-view').renderer.getContents().some(({ doc }) => doc?.body)`, 'the mouse page turn');
const ffMouse = JSON.parse(await evalIn(probe));
check('a mouse click on the right of the text turns the page',
ffMouse.fraction > ffForward.fraction,
`${ffForward.fraction} -> ${ffMouse.fraction}`);
await evalIn(`(() => {
const doc = document.querySelector('readium-view').renderer.getContents()[0].doc;
doc.getSelection().selectAllChildren(doc.body);
const box = doc.defaultView.frameElement.getBoundingClientRect();
doc.body.dispatchEvent(new MouseEvent('click', {
bubbles: true, button: 0, detail: 1,
clientX: window.innerWidth - 6 - box.left,
clientY: window.innerHeight / 2 - box.top,
}));
return true;
})()`);
await new Promise((r) => setTimeout(r, 1200));
const ffSelected = JSON.parse(await evalIn(probe));
check('a click while text is selected is not a page turn',
ffSelected.fraction === ffMouse.fraction,
`${ffMouse.fraction} -> ${ffSelected.fraction}`);
await evalIn(`(() => {
document.querySelector('readium-view').renderer.getContents()[0]
.doc.getSelection().removeAllRanges();
document.getElementById('reader-settings-form').autohide.checked = false;
document.getElementById('reader-settings-form').autohide
.dispatchEvent(new Event('input', { bubbles: true }));
return true;
})()`);
await new Promise((r) => setTimeout(r, 400));
// The same hostile battery as the Chromium harness, judged by Firefox.
at('hostile chapter');
await evalIn(`document.querySelector('readium-view').goTo(1)`);
await new Promise((r) => setTimeout(r, 900));
const hostile = JSON.parse(await evalIn(probe));
check('the inline script did not run', hostile.ran === false, String(hostile.ran));
check('the SVG script did not run', hostile.svgRan === false, String(hostile.svgRan));
check('the same-origin external script did not run',
hostile.extRan === false, String(hostile.extRan));
check('the publication could not reach the parent page',
!String(hostile.pageTitle).includes('pwned'), hostile.pageTitle);
if (process.env.SMOKE_SHOT) {
const shot = await send('browsingContext.captureScreenshot', { context });
writeFileSync(process.env.SMOKE_SHOT, Buffer.from(shot.data, 'base64'));
}
// Chromium says out loud that it refused to run the publication's
// script. Firefox reports the same refusal as a policy violation, and
// BiDi's log channel carries console calls and script errors only — a
// violation never reaches it. So state the refusal structurally: the
// chapter frame is sandboxed, the
// reader stripped the publication's script elements, and the page CSP
// — inherited by every blob chapter — refuses whatever stripping might
// miss. What can be observed from here is that the script did not run.
check('the chapter frame is sandboxed', diag.frameSandboxed, String(diag.frameSandboxed));
check('the browser refused to run the publication', diag.ran === false,
String(diag.ran));
const unexpected = consoleErrors.filter((e) => !/Content-Security-Policy|Blocked script/i.test(e));
if (unexpected.length) console.log('console errors:\n ' + unexpected.join('\n '));
check('no console errors', unexpected.length === 0);
ws.close();
proc.kill();
process.exit(fail.length ? 1 : 0);