chore: 整理 third-party Dark Reader 目录并移除本地说明文件跟踪

This commit is contained in:
2026-08-08 17:38:08 +08:00
parent 202ab537d1
commit 0bb552b026
1004 changed files with 115066 additions and 20081 deletions
+171
View File
@@ -0,0 +1,171 @@
// @ts-check
import path from 'node:path';
import {writeFile} from '../../tasks/utils.js';
/** @typedef {{text: string; covered: boolean}} CodePart */
/**
* @param {string} code
* @param {{start: number; end: number}[]} ranges
* @returns {CodePart[]}
*/
function splitCode(code, ranges) {
/** @type {CodePart[]} */
const parts = [];
if (ranges.length === 0) {
parts.push({text: code, covered: false});
return;
}
if (ranges[0].start > 0) {
parts.push({text: code.substring(0, ranges[0].start), covered: false});
}
const lastRange = ranges[ranges.length - 1];
for (let i = 0; i < ranges.length; i++) {
const range = ranges[i];
parts.push({text: code.substring(range.start, range.end), covered: true});
if (range !== lastRange) {
const nextRange = ranges[i + 1];
parts.push({text: code.substring(range.end, nextRange.start), covered: false});
}
}
if (lastRange.end < code.length) {
parts.push({text: code.substring(lastRange.end), covered: false});
}
return parts;
}
function red(/** @type {string} */text) {
return `\x1b[31m${text}\x1b[0m`;
}
function green(/** @type {string} */text) {
return `\x1b[32m${text}\x1b[0m`;
}
/**
* @param {string} code
* @param {{start: number; end: number}[]} ranges
*/
export function logCoverage(code, ranges) {
code = code.substring(0, code.indexOf('//# sourceMappingURL='));
const parts = splitCode(code, ranges);
const message = parts
.map(({text, covered}) => (covered ? green : red)(text))
.join('');
console.log(message);
}
function escapeHTML(/** @type {string} */html) {
return html
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/** @typedef {{name: string; length: number; coverage: number; parts: {text: string; covered: boolean}[]}} FileCoverageInfo */
/**
* @param {string} name
* @param {string} code
* @param {{start: number; end: number}[]} ranges
* @returns {FileCoverageInfo}
*/
function getCoverageInfo(name, code, ranges) {
code = code.substring(0, code.indexOf('//# sourceMappingURL='));
const parts = splitCode(code, ranges);
const length = code.length;
const coverage = parts.filter((p) => p.covered).reduce((sum, p) => sum + p.text.length, 0) / length;
return {name, length, coverage, parts};
}
/**
* @param {string} dir
* @param {FileCoverageInfo} info
* @returns {Promise<void>}
*/
async function generateHTMLCoverageReport(dir, info) {
const {name, coverage, parts} = info;
/** @type {string[]} */
const lines = [];
lines.push('<!DOCTYPE html>');
lines.push('<html>');
lines.push('<head>');
const title = `Coverage report: ${name}`;
lines.push(` <title>${title}</title>`);
lines.push(' <style>');
lines.push(' body { background: #111; color: #ccc; }');
lines.push(' code { background: #222; display: inline-block; white-space: pre-wrap; }');
lines.push(' .uncovered { background: #934; color: white; }');
lines.push(' </style>');
lines.push('</head>');
lines.push('<body>');
lines.push(` <h1>${title}</h1>`);
lines.push(` <h3>Covered ${(coverage * 100).toFixed(0)}%</h3>`);
lines.push(` <code>${parts.map((p) => `<span${p.covered ? '' : ' class="uncovered"'}>${escapeHTML(p.text)}</span>`).join('')}</code>`);
lines.push('</body>');
lines.push('</html>');
await writeFile(path.join(dir, `${name}.html`), lines.join('\n'));
}
/**
* @param {string} dir
* @param {FileCoverageInfo[]} info
* @returns {Promise<void>}
*/
async function generateIndexHTMLCoveragePage(dir, info) {
/** @type {string[]} */
const lines = [];
lines.push('<!DOCTYPE html>');
lines.push('<html>');
lines.push('<head>');
lines.push(` <title>Code coverage reports</title>`);
lines.push(' <style>');
lines.push(' body { background: #111; color: #ccc; }');
lines.push(' a { color: #7ae; }');
lines.push(' </style>');
lines.push('</head>');
lines.push('<body>');
lines.push(` <h1>Code coverage reports</h1>`);
const totalCoverage = info.reduce((sum, i) => sum + i.coverage * i.length, 0);
const totalLength = info.reduce((sum, i) => sum + i.length, 0);
lines.push(` <h3>Total coverage ${(totalCoverage / totalLength * 100).toFixed(0)}%</h3>`);
lines.push(' <table>');
info.forEach((i) => {
lines.push(' <tr>');
lines.push(` <td>${(i.coverage * 100).toFixed(0)}%</td>`);
lines.push(` <td><a href="${i.name}.html">${i.name}</a></td>`);
lines.push(' </tr>');
});
lines.push(' </table>');
lines.push('</body>');
lines.push('</html>');
await writeFile(path.join(dir, 'index.html'), lines.join('\n'));
}
/**
* @param {string} dir
* @param {import('puppeteer-core').CoverageEntry[]} coverage
*/
export async function generateHTMLCoverageReports(dir, coverage) {
const info = coverage
.filter(({url}) => url.startsWith('chrome-extension://'))
.map(({url, text, ranges}) => {
const name = url.replace(/^chrome-extension:\/\/.*?\//, '');
return getCoverageInfo(name, text, ranges);
});
await generateIndexHTMLCoveragePage(dir, info);
await Promise.all(info.map((i) => generateHTMLCoverageReport(dir, i)));
}
@@ -0,0 +1,42 @@
import {multiline} from '../../support/test-utils';
import type {StyleExpectations} from '../globals';
async function loadBasicPage() {
await loadTestPage({
'/': multiline(
'<!DOCTYPE html>',
'<html>',
'<head>',
'</head>',
'<body>',
' <span style="color: red;">Inline style override</span>',
'</body>',
'</html>',
),
});
}
async function expectStyles(styles: StyleExpectations) {
await expectPageStyles(expect, styles);
}
describe('Inline style override', () => {
it('should override inline style', async () => {
await loadBasicPage();
await expectStyles(['span', 'color', 'rgb(255, 26, 26)']);
});
it('should watch for inline style change', async () => {
await loadBasicPage();
await expectStyles(['span', 'color', 'rgb(255, 26, 26)']);
await expect(pageUtils.evaluateScript(async () => {
const span = document.querySelector('span')!;
span.style.color = 'green';
await new Promise((resolve) => setTimeout(resolve));
return getComputedStyle(span).color;
})).resolves.toBe('rgb(114, 255, 114)');
});
});
@@ -0,0 +1,121 @@
import {multiline} from '../../support/test-utils';
import type {StyleExpectations} from '../globals';
async function expectStyles(styles: StyleExpectations) {
await expectPageStyles(expect, styles);
}
describe('Link override', () => {
it('should override link style', async () => {
await loadTestPage({
'/': multiline(
'<!DOCTYPE html>',
'<html>',
'<head>',
' <link rel="stylesheet" href="style.css"/>',
'</head>',
'<body>',
' <h1>Link style <strong>override</strong>!</h1>',
'</body>',
'</html>',
),
'/style.css': multiline(
'body { background: gray; }',
'h1 strong { color: red; }',
),
});
await expectStyles([
['document', 'background-color', 'rgb(24, 26, 27)'],
['body', 'background-color', 'rgb(96, 104, 108)'],
['body', 'color', 'rgb(232, 230, 227)'],
['h1', 'color', 'rgb(232, 230, 227)'],
['h1 strong', 'color', 'rgb(255, 26, 26)'],
]);
});
it('should override CORS style', async () => {
await loadTestPage({
'/': multiline(
'<!DOCTYPE html>',
'<html>',
'<head>',
` <link rel="stylesheet" href="${corsURL}/style.css"/>`,
'</head>',
'<body>',
' <h1>CORS style <strong>override</strong>!</h1>',
'</body>',
'</html>',
),
cors: {
'/style.css': multiline(
'body { background: gray; }',
'h1 strong { color: red; }',
),
},
});
await expectStyles([
['document', 'background-color', 'rgb(24, 26, 27)'],
['body', 'background-color', 'rgb(96, 104, 108)'],
['body', 'color', 'rgb(232, 230, 227)'],
['h1', 'color', 'rgb(232, 230, 227)'],
['h1 strong', 'color', 'rgb(255, 26, 26)'],
]);
});
it('should wait till style is loading', async () => {
const styleResolvers: Array<() => any> = [];
let didStyleResolve = false;
await loadTestPage({
'/': multiline(
'<!DOCTYPE html>',
'<html>',
'<head>',
' <link rel="stylesheet" href="style.css"/>',
'</head>',
'<body>',
' <h1>Link loading <strong>delay</strong></h1>',
'</body>',
'</html>',
),
'/style.css': async (_, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/css');
// For some reason Firefox 151 performs two requests.
// Only the second has effect on page's look.
if (!didStyleResolve) {
await new Promise<void>((resolve) => styleResolvers.push(resolve));
}
res.end(multiline(
'body { background: gray; }',
'h1 strong { color: red; }',
), 'utf8');
},
}, {
waitUntil: 'domcontentloaded',
});
await expectStyles([
['document', 'background-color', 'rgb(24, 26, 27)'],
['body', 'background-color', 'rgb(24, 26, 27)'],
['body', 'color', 'rgb(232, 230, 227)'],
['h1', 'color', 'rgb(232, 230, 227)'],
['h1 strong', 'color', 'rgb(232, 230, 227)'],
]);
styleResolvers.forEach((resolve) => resolve());
didStyleResolve = true;
await expectStyles([
['document', 'background-color', 'rgb(24, 26, 27)'],
['body', 'background-color', 'rgb(96, 104, 108)'],
['body', 'color', 'rgb(232, 230, 227)'],
['h1', 'color', 'rgb(232, 230, 227)'],
['h1 strong', 'color', 'rgb(255, 26, 26)'],
]);
});
});
@@ -0,0 +1,255 @@
import {multiline} from '../../support/test-utils';
import type {StyleExpectations} from '../globals';
async function expectStyles(styles: StyleExpectations) {
await expectPageStyles(expect, styles);
}
describe('Style override', () => {
it('should override user agent style', async () => {
await loadTestPage({
'/': multiline(
'<!DOCTYPE html>',
'<html>',
'<head>',
'</head>',
'<body>',
' Text',
' <a href="#">Link</a>',
'</body>',
'</html>',
),
});
await expectStyles([
['document', 'background-color', 'rgb(24, 26, 27)'],
['document', 'color', 'rgb(232, 230, 227)'],
['body', 'background-color', 'rgb(24, 26, 27)'],
['body', 'color', 'rgb(232, 230, 227)'],
['a', 'color', 'rgb(51, 145, 255)'],
]);
});
it('should override static style', async () => {
await loadTestPage({
'/': multiline(
'<!DOCTYPE html>',
'<html>',
'<head>',
' <style>',
' body { background: gray; }',
' h1 strong { color: red; }',
' </style>',
'</head>',
'<body>',
' <h1>Style <strong>override</strong>!</h1>',
'</body>',
'</html>',
),
});
await expectStyles([
['document', 'background-color', 'rgb(24, 26, 27)'],
['body', 'background-color', 'rgb(96, 104, 108)'],
['body', 'color', 'rgb(232, 230, 227)'],
['h1', 'color', 'rgb(232, 230, 227)'],
['h1 strong', 'color', 'rgb(255, 26, 26)'],
]);
});
it('should restore override', async () => {
await loadTestPage({
'/': multiline(
'<!DOCTYPE html>',
'<html>',
'<head>',
'</head>',
'<body>',
' <h1>Style <strong>override</strong>!</h1>',
'</body>',
'</html>',
),
});
await pageUtils.evaluateScript(() => {
const styleElement = document.createElement('style');
styleElement.classList.add('testcase-style');
document.head.append(styleElement);
styleElement.sheet!.insertRule('h1 { color: gray }');
styleElement.sheet!.insertRule('strong { color: red }');
});
await expectStyles([
['h1', 'color', 'rgb(152, 143, 129)'],
['h1 strong', 'color', 'rgb(255, 26, 26)'],
]);
await expect(pageUtils.evaluateScript(async () => {
const style = document.querySelector('.testcase-style')!;
style.nextSibling!.remove();
await new Promise((resolve) => setTimeout(resolve));
return (style.nextSibling as HTMLStyleElement).classList.contains('darkreader--sync');
})).resolves.toBe(true);
});
it('should move override', async () => {
await loadTestPage({
'/': multiline(
'<!DOCTYPE html>',
'<html>',
'<head>',
'</head>',
'<body>',
' <h1>Some test foor...... <strong>Moving styles</strong>!</h1>',
'</body>',
'</html>',
),
});
await pageUtils.evaluateScript(() => {
const styleElement = document.createElement('style');
styleElement.classList.add('testcase-style');
document.head.append(styleElement);
styleElement.sheet!.insertRule('h1 { color: gray } ');
styleElement.sheet!.insertRule('strong { color: red } ');
});
await expect(pageUtils.evaluateScript(async () => {
const style = document.querySelector('.testcase-style')!;
document.body.append(style);
await new Promise((resolve) => setTimeout(resolve));
return (style.nextSibling as HTMLStyleElement).classList.contains('darkreader--sync');
})).resolves.toBe(true);
});
it('should remove override', async () => {
await loadTestPage({
'/': multiline(
'<!DOCTYPE html>',
'<html>',
'<head>',
'</head>',
'<body>',
' <h1>Some test foor...... <strong>Oh uhm removing styles :(</strong>!</h1>',
'</body>',
'</html>',
),
});
await pageUtils.evaluateScript(() => {
const styleElement = document.createElement('style');
styleElement.classList.add('testcase-style');
document.head.append(styleElement);
styleElement.sheet!.insertRule('h1 { color: gray }');
styleElement.sheet!.insertRule('strong { color: red }');
});
await expect(pageUtils.evaluateScript(async () => {
const style = document.querySelector('.testcase-style')!;
const sibling = style.nextSibling!;
style.remove();
await new Promise((resolve) => setTimeout(resolve));
return sibling.isConnected && !((sibling as HTMLStyleElement).classList.contains('darkreader--sync'));
})).resolves.toBe(false);
});
it('should react to updated style', async () => {
if (product === 'firefox') {
expect(true);
return;
}
await loadTestPage({
'/': multiline(
'<!DOCTYPE html>',
'<html>',
'<head>',
'</head>',
'<body>',
' <h1>Some test foor...... <strong>Oh uhm a pink background</strong></h1>',
'</body>',
'</html>',
),
});
await pageUtils.evaluateScript(() => {
const styleElement = document.createElement('style');
styleElement.classList.add('testcase-style');
document.head.append(styleElement);
styleElement.sheet!.insertRule('h1 { color: gray }');
styleElement.sheet!.insertRule('strong { color: red }');
});
await expect(pageUtils.evaluateScript(async () => {
const style = document.querySelector('.testcase-style')!;
(style as HTMLStyleElement).sheet!.insertRule('html { background-color: pink }');
await new Promise((resolve) => setTimeout(resolve));
return (style.nextSibling as HTMLStyleElement).sheet!.cssRules[0].cssText;
})).resolves.toBe('html { background-color: var(--darkreader-background-ffc0cb, #590010); }');
});
it('should react to a new style', async () => {
await loadTestPage({
'/': multiline(
'<!DOCTYPE html>',
'<html>',
'<head>',
'</head>',
'<body>',
' <h1>Some test foor...... <strong>Oh uhm what?</strong>!</h1>',
'</body>',
'</html>',
),
});
await expect(pageUtils.evaluateScript(async () => {
const styleElement = document.createElement('style');
styleElement.classList.add('testcase-style');
document.head.append(styleElement);
styleElement.sheet!.insertRule('h1 { color: pink }');
styleElement.sheet!.insertRule('strong { color: orange }');
await new Promise((resolve) => setTimeout(resolve));
return (styleElement.nextSibling as HTMLStyleElement).sheet!.cssRules.length === 2 && (styleElement.nextSibling as HTMLStyleElement).classList.contains('darkreader--sync');
})).resolves.toBe(true);
});
it('should handle defined custom elements', async () => {
if (product === 'firefox') {
return;
}
await loadTestPage({
'/': multiline(
'<!DOCTYPE html>',
'<html>',
'<head>',
'</head>',
'<body>',
'<custom-element>',
'</custom-element>',
'</body>',
'</html>',
),
});
pageUtils.evaluateScript(async () => {
class CustomElement extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({mode: 'open'});
const style = document.createElement('style');
style.textContent = 'p { color: pink }';
const paragraph = document.createElement('p');
paragraph.textContent = 'Some text content that should be pink.';
shadowRoot.append(style);
shadowRoot.append(paragraph);
}
}
customElements.define('custom-element', CustomElement);
});
await expectStyles([
[['custom-element', 'p'], 'color', 'rgb(255, 160, 177)'],
]);
});
});
@@ -0,0 +1,82 @@
import {multiline} from '../../support/test-utils';
import type {StyleExpectations} from '../globals';
async function loadBasicPage() {
await loadTestPage({
'/': multiline(
'<!DOCTYPE html>',
'<html>',
'<head>',
' <style>',
' h1 { color: red; }',
' </style>',
'</head>',
'<body>',
' <h1>E2E test page</h1>',
' <p>Text</p>',
' <a href="#">Link</a>',
'</body>',
'</html>',
),
});
}
async function expectStyles(styles: StyleExpectations) {
await expectPageStyles(expect, styles);
}
describe('Modifying config via Developer tools', () => {
it('Modifying config', async () => {
await loadBasicPage();
await expectStyles([
['document', 'background-color', 'rgb(24, 26, 27)'],
['document', 'color', 'rgb(232, 230, 227)'],
['body', 'background-color', 'rgb(24, 26, 27)'],
['body', 'color', 'rgb(232, 230, 227)'],
['h1', 'color', 'rgb(255, 26, 26)'],
['a', 'color', 'rgb(51, 145, 255)'],
]);
await devtoolsUtils.paste([
'*',
'',
'CSS',
'h1 {',
' background: black;',
' color: white;',
'}',
'',
'============================',
'',
'nonexistent.com',
'',
'CSS',
'h1 {',
' color: red;',
'}',
'',
].join('\n'));
await expectStyles([
['document', 'background-color', 'rgb(24, 26, 27)'],
['document', 'color', 'rgb(232, 230, 227)'],
['body', 'background-color', 'rgb(24, 26, 27)'],
['body', 'color', 'rgb(232, 230, 227)'],
['h1', 'background-color', 'rgb(0, 0, 0)'],
['h1', 'color', 'rgb(255, 255, 255)'],
['a', 'color', 'rgb(51, 145, 255)'],
]);
await devtoolsUtils.reset();
await expectStyles([
['document', 'background-color', 'rgb(24, 26, 27)'],
['document', 'color', 'rgb(232, 230, 227)'],
['body', 'background-color', 'rgb(24, 26, 27)'],
['body', 'color', 'rgb(232, 230, 227)'],
['h1', 'color', 'rgb(255, 26, 26)'],
['a', 'color', 'rgb(51, 145, 255)'],
]);
});
});
@@ -0,0 +1,41 @@
import {multiline} from '../../support/test-utils';
async function loadBasicPage(header = 'E2E test page') {
await loadTestPage({
'/': multiline(
'<!DOCTYPE html>',
'<html>',
'<head>',
' <style>',
' h1 { color: red; }',
' </style>',
'</head>',
'<body>',
` <h1>${header}</h1>`,
' <p>Text</p>',
' <a href="#">Link</a>',
'</body>',
'</html>',
),
});
}
describe('Test environment', () => {
it('should turn On/Off', async () => {
await loadBasicPage();
const initialColorScheme = await getColorScheme();
const overrideColorScheme = initialColorScheme === 'dark' ? 'light' : 'dark';
expect(initialColorScheme === 'light' || initialColorScheme === 'dark');
if (product === 'firefox') {
await expect(backgroundUtils.getColorScheme()).resolves.toBe(initialColorScheme);
}
await emulateColorScheme(overrideColorScheme);
await expect(getColorScheme()).resolves.toBe(overrideColorScheme);
if (product === 'firefox') {
await expect(backgroundUtils.getColorScheme()).resolves.toBe(overrideColorScheme);
}
});
});
@@ -0,0 +1,13 @@
import {timeout} from '../../support/test-utils';
describe('Export settings', () => {
it('Should download file', async () => {
await timeout(1000);
const p = new Promise<{ok: boolean}>((resolve) => backgroundUtils.onDownload(resolve));
await popupUtils.saveFile('example', 'content');
expect((await p).ok).toBe(true);
if (product === 'firefox') {
await timeout(1000);
}
});
});
+241
View File
@@ -0,0 +1,241 @@
import {multiline} from '../../support/test-utils';
import type {StyleExpectations} from '../globals';
async function loadBasicPage() {
await loadTestPage({
'/': multiline(
'<!DOCTYPE html>',
'<html>',
'<head>',
' <style>',
' h1 { color: red; }',
' </style>',
'</head>',
'<body>',
' <h1>E2E test page</h1>',
' <p>Text</p>',
'</body>',
'</html>',
),
});
}
async function expectStyles(styles: StyleExpectations) {
await expectPageStyles(expect, styles);
}
describe('Correct fixes are chosen', () => {
it('If no matching URL found, returns only default fix', async () => {
await loadBasicPage();
await expectStyles([
['document', 'background-color', 'rgb(24, 26, 27)'],
['document', 'color', 'rgb(232, 230, 227)'],
['body', 'background-color', 'rgb(24, 26, 27)'],
['body', 'color', 'rgb(232, 230, 227)'],
['h1', 'color', 'rgb(255, 26, 26)'],
]);
await devtoolsUtils.paste(multiline(
'*',
'',
'CSS',
'body {',
' background: navy;',
' color: white;',
'}',
'h1 {',
' color: orange;',
'}',
'',
'============================',
'',
'nonexistent.com',
'',
'CSS',
'body {',
' bachground: green;',
'}',
'',
'============================',
'',
'other.net',
'',
'CSS',
'body {',
' bachground: blue;',
'}',
));
await expectStyles([
['document', 'background-color', 'rgb(24, 26, 27)'],
['document', 'color', 'rgb(232, 230, 227)'],
['body', 'background-color', 'rgb(0, 0, 128)'],
['body', 'color', 'rgb(255, 255, 255)'],
['h1', 'color', 'rgb(255, 165, 0)'],
]);
await devtoolsUtils.reset();
});
it('If multiple matching URL patterns found, select the most specific one', async () => {
await loadBasicPage();
await expectStyles([
['document', 'background-color', 'rgb(24, 26, 27)'],
['document', 'color', 'rgb(232, 230, 227)'],
['body', 'background-color', 'rgb(24, 26, 27)'],
['body', 'color', 'rgb(232, 230, 227)'],
['h1', 'color', 'rgb(255, 26, 26)'],
]);
await devtoolsUtils.paste(multiline(
'*',
'',
'CSS',
'body {',
' background: navy;',
' color: white;',
'}',
'h1 {',
' color: orange;',
'}',
'',
'============================',
'',
'localhost:8891/',
'',
'CSS',
'body {',
' bachground: blue;',
'}',
'',
'============================',
'',
'localhost:8891',
'',
'CSS',
'body {',
' bachground: green;',
'}',
));
await expectStyles([
['document', 'background-color', 'rgb(24, 26, 27)'],
['document', 'color', 'rgb(232, 230, 227)'],
['body', 'background-color', 'rgb(0, 0, 128)'],
['body', 'color', 'rgb(255, 255, 255)'],
['h1', 'color', 'rgb(255, 165, 0)'],
]);
await devtoolsUtils.reset();
});
it('BUG COMPATIBILITY: If multiple matching URL patterns found, the most specific fix is determined by the length of first pattern', async () => {
await loadBasicPage();
await expectStyles([
['document', 'background-color', 'rgb(24, 26, 27)'],
['document', 'color', 'rgb(232, 230, 227)'],
['body', 'background-color', 'rgb(24, 26, 27)'],
['body', 'color', 'rgb(232, 230, 227)'],
['h1', 'color', 'rgb(255, 26, 26)'],
]);
await devtoolsUtils.paste(multiline(
'*',
'',
'CSS',
'body {',
' background: navy;',
' color: white;',
'}',
'h1 {',
' color: orange;',
'}',
'',
'============================',
'',
'example.com/loooooooong',
'localhost:8891',
'',
'CSS',
'body {',
' bachground: blue;',
'}',
'',
'============================',
'',
'example.com',
'localhost:8891/',
'',
'CSS',
'body {',
' bachground: green;',
'}',
));
await expectStyles([
['document', 'background-color', 'rgb(24, 26, 27)'],
['document', 'color', 'rgb(232, 230, 227)'],
['body', 'background-color', 'rgb(0, 0, 128)'],
['body', 'color', 'rgb(255, 255, 255)'],
['h1', 'color', 'rgb(255, 165, 0)'],
]);
await devtoolsUtils.reset();
});
it('BUG COMPATIBILITY: If multiple matching URL patterns of the same length are found, select the first one', async () => {
await loadBasicPage();
await expectStyles([
['document', 'background-color', 'rgb(24, 26, 27)'],
['document', 'color', 'rgb(232, 230, 227)'],
['body', 'background-color', 'rgb(24, 26, 27)'],
['body', 'color', 'rgb(232, 230, 227)'],
['h1', 'color', 'rgb(255, 26, 26)'],
]);
await devtoolsUtils.paste(multiline(
'*',
'',
'CSS',
'body {',
' background: navy;',
' color: white;',
'}',
'h1 {',
' color: orange;',
'}',
'',
'============================',
'',
'localhost:8891',
'',
'CSS',
'body {',
' bachground: blue;',
'}',
'',
'============================',
'',
'localhost:8891',
'',
'CSS',
'body {',
' bachground: green;',
'}',
));
await expectStyles([
['document', 'background-color', 'rgb(24, 26, 27)'],
['document', 'color', 'rgb(232, 230, 227)'],
['body', 'background-color', 'rgb(0, 0, 128)'],
['body', 'color', 'rgb(255, 255, 255)'],
['h1', 'color', 'rgb(255, 165, 0)'],
]);
await devtoolsUtils.reset();
});
});
+14
View File
@@ -0,0 +1,14 @@
describe('News', () => {
const newsSelector = 'div.news.news--expanded';
it('should display news', async () => {
await backgroundUtils.setNews([{
id: 'some',
date: '10',
url: '/',
headline: 'Test news',
}]);
popupUtils.exists(newsSelector);
});
});
+180
View File
@@ -0,0 +1,180 @@
import {multiline} from '../../support/test-utils';
import type {StyleExpectations} from '../globals';
async function expectStyles(styles: StyleExpectations) {
await expectPageStyles(expect, styles);
}
async function loadBasicPage() {
await loadTestPage({
'/': multiline(
'<!DOCTYPE html>',
'<html>',
'<head>',
' <style>',
' h1 { color: white; }',
' </style>',
'</head>',
'<body>',
' <h1>White title - should stay</h1>',
' <h2>White subtitle - should change</h2>',
' <iframe id="red" src="/red.html"></iframe>',
' <iframe id="blue" src="/blue.html"></iframe>',
'</body>',
'</html>',
),
'/red.html': multiline(
'<!DOCTYPE html>',
'<html>',
'<head>',
' <style>',
' h1 { color: red; }',
' h2 { color: white; }',
' </style>',
'</head>',
'<body>',
' <h1>Red title - should change</h1>',
' <h2>White subtitle - should stay</h2>',
'</body>',
'</html>',
),
'/blue.html': multiline(
'<!DOCTYPE html>',
'<html>',
'<head>',
' <style>',
' h1 { color: blue; }',
' h2 { color: white; }',
' </style>',
'</head>',
'<body>',
' <h1>Blue title - should change</h1>',
' <h2>White subtitle - should stay</h2>',
'</body>',
'</html>',
),
});
}
describe('Different paths in URL patterns', () => {
it('Different paths upon initial load', async () => {
await Promise.all([
awaitForEvent('ready-/red.html'),
awaitForEvent('ready-/blue.html'),
loadBasicPage(),
]);
await expectStyles([
['document', 'background-color', 'rgb(24, 26, 27)'],
['document', 'color', 'rgb(232, 230, 227)'],
['body', 'background-color', 'rgb(24, 26, 27)'],
['body', 'color', 'rgb(232, 230, 227)'],
['h1', 'color', 'rgb(232, 230, 227)'],
[['iframe#red', 'document'], 'background-color', 'rgba(0, 0, 0, 0)'],
[['iframe#red', 'document'], 'color', 'rgb(232, 230, 227)'],
[['iframe#red', 'body'], 'background-color', 'rgba(0, 0, 0, 0)'],
[['iframe#red', 'body'], 'color', 'rgb(232, 230, 227)'],
[['iframe#red', 'h1'], 'color', 'rgb(255, 26, 26)'],
[['iframe#blue', 'document'], 'background-color', 'rgba(0, 0, 0, 0)'],
[['iframe#blue', 'document'], 'color', 'rgb(232, 230, 227)'],
[['iframe#blue', 'body'], 'background-color', 'rgba(0, 0, 0, 0)'],
[['iframe#blue', 'body'], 'color', 'rgb(232, 230, 227)'],
[['iframe#blue', 'h1'], 'color', 'rgb(51, 125, 255)'],
]);
await devtoolsUtils.paste([
'*',
'',
'CSS',
'body {',
' background: black',
' color: white',
'}',
'',
'============================',
'',
'*/red.html',
'',
'CSS',
'body {',
' background: black',
'}',
'h1 {',
' color: indigo;',
'}',
'',
'============================',
'',
'*/blue.html',
'',
'CSS',
'body {',
' background: black',
'}',
'h1 {',
' color: navy;',
'}',
'',
'============================',
'',
'nonexistent.com',
'',
'CSS',
'body {',
' background: purple',
'}',
'',
].join('\n'));
await expectStyles([
['document', 'background-color', 'rgb(24, 26, 27)'],
['document', 'color', 'rgb(232, 230, 227)'],
['body', 'background-color', 'rgb(24, 26, 27)'],
['body', 'color', 'rgb(232, 230, 227)'],
['h1', 'color', 'rgb(232, 230, 227)'],
['h2', 'color', 'rgb(232, 230, 227)'],
[['iframe#red', 'document'], 'background-color', 'rgba(0, 0, 0, 0)'],
[['iframe#red', 'document'], 'color', 'rgb(232, 230, 227)'],
[['iframe#red', 'body'], 'background-color', 'rgb(0, 0, 0)'],
[['iframe#red', 'body'], 'color', 'rgb(232, 230, 227)'],
[['iframe#red', 'h1'], 'color', 'rgb(75, 0, 130)'],
[['iframe#blue', 'document'], 'background-color', 'rgba(0, 0, 0, 0)'],
[['iframe#blue', 'document'], 'color', 'rgb(232, 230, 227)'],
[['iframe#blue', 'body'], 'background-color', 'rgb(0, 0, 0)'],
[['iframe#blue', 'body'], 'color', 'rgb(232, 230, 227)'],
[['iframe#blue', 'h1'], 'color', 'rgb(0, 0, 128)'],
]);
const redPromise = awaitForEvent('darkreader-dynamic-theme-ready-/red.html');
const bluePromise = awaitForEvent('darkreader-dynamic-theme-ready-/blue.html');
await Promise.all([
redPromise,
bluePromise,
devtoolsUtils.reset(),
]);
await expectStyles([
['document', 'background-color', 'rgb(24, 26, 27)'],
['document', 'color', 'rgb(232, 230, 227)'],
['body', 'background-color', 'rgb(24, 26, 27)'],
['body', 'color', 'rgb(232, 230, 227)'],
['h1', 'color', 'rgb(232, 230, 227)'],
[['iframe#red', 'document'], 'background-color', 'rgba(0, 0, 0, 0)'],
[['iframe#red', 'document'], 'color', 'rgb(232, 230, 227)'],
[['iframe#red', 'body'], 'background-color', 'rgba(0, 0, 0, 0)'],
[['iframe#red', 'body'], 'color', 'rgb(232, 230, 227)'],
[['iframe#red', 'h1'], 'color', 'rgb(255, 26, 26)'],
[['iframe#blue', 'document'], 'background-color', 'rgba(0, 0, 0, 0)'],
[['iframe#blue', 'document'], 'color', 'rgb(232, 230, 227)'],
[['iframe#blue', 'body'], 'background-color', 'rgba(0, 0, 0, 0)'],
[['iframe#blue', 'body'], 'color', 'rgb(232, 230, 227)'],
[['iframe#blue', 'h1'], 'color', 'rgb(51, 125, 255)'],
]);
});
});
@@ -0,0 +1,67 @@
import {multiline} from '../../support/test-utils';
import type {StyleExpectations} from '../globals';
async function expectStyles(styles: StyleExpectations) {
await expectPageStyles(expect, styles);
}
describe('Custom HTML elements', () => {
it('Asynchronous define', async () => {
// Temporarily disable this test on Firefox
if (product === 'firefox') {
expect(true);
return;
}
await loadTestPage({
'/': multiline(
'<!DOCTYPE html>',
'<html>',
'<head>',
'</head>',
'<body></body>',
'</html>',
),
});
await pageUtils.evaluateScript(async () => {
class ElementWitAsync extends HTMLElement {
constructor() {
super();
const root = this.attachShadow({mode: 'open'});
setTimeout(() => root.innerHTML =
'<style>\
p { color: red; }\
</style>\
<p>\
Should be red initially and then change to green.\
</p>'
);
}
}
customElements.define('elem-with-async', ElementWitAsync);
const elem = document.createElement('elem-with-async');
document.body.appendChild(elem);
});
await expectStyles([
[['elem-with-async', 'p'], 'color', 'rgb(255, 26, 26)'],
]);
await devtoolsUtils.paste(multiline(
'*',
'',
'CSS',
'p {',
' color: green !important;',
'}',
));
await expectStyles([
[['elem-with-async', 'p'], 'color', 'rgb(0, 128, 0)'],
]);
await devtoolsUtils.reset();
});
});
+257
View File
@@ -0,0 +1,257 @@
import {multiline, timeout} from '../../support/test-utils';
import type {StyleExpectations} from '../globals';
async function expectStyles(styles: StyleExpectations) {
await expectPageStyles(expect, styles);
}
async function loadBasicPage(header: string) {
await loadTestPage({
'/': multiline(
'<!DOCTYPE html>',
'<html>',
'<head>',
' <style>',
' h1 { color: red; }',
' </style>',
'</head>',
'<body>',
` <h1>${header}</h1>`,
' <p>Text</p>',
' <a href="#">Link</a>',
'</body>',
'</html>',
),
});
}
describe('Toggling the extension', () => {
const automationMenuSelector = '.header__more-settings-button';
const automationSystemSelector = '.header__more-settings__system-dark-mode__checkbox .checkbox__input';
it('should turn On/Off', async () => {
await loadBasicPage('Toggle on/off');
await expectStyles([
['document', 'background-color', 'rgb(24, 26, 27)'],
['document', 'color', 'rgb(232, 230, 227)'],
['body', 'background-color', 'rgb(24, 26, 27)'],
['body', 'color', 'rgb(232, 230, 227)'],
['h1', 'color', 'rgb(255, 26, 26)'],
['a', 'color', 'rgb(51, 145, 255)'],
]);
await popupUtils.click('.toggle__off');
await timeout(500);
await expectStyles([
['document', 'background-color', 'rgba(0, 0, 0, 0)'],
['document', 'color', 'rgb(0, 0, 0)'],
['body', 'background-color', 'rgba(0, 0, 0, 0)'],
['body', 'color', 'rgb(0, 0, 0)'],
['h1', 'color', 'rgb(255, 0, 0)'],
['a', 'color', 'rgb(0, 0, 238)'],
]);
await popupUtils.click('.toggle__on');
await timeout(500);
await expectStyles([
['document', 'background-color', 'rgb(24, 26, 27)'],
['document', 'color', 'rgb(232, 230, 227)'],
['body', 'background-color', 'rgb(24, 26, 27)'],
['body', 'color', 'rgb(232, 230, 227)'],
['h1', 'color', 'rgb(255, 26, 26)'],
['a', 'color', 'rgb(51, 145, 255)'],
]);
});
it('should follow system color scheme', async () => {
await loadBasicPage('Automation (color scheme)');
await emulateColorScheme('light');
await expectStyles([
['document', 'background-color', 'rgb(24, 26, 27)'],
['document', 'color', 'rgb(232, 230, 227)'],
['body', 'background-color', 'rgb(24, 26, 27)'],
['body', 'color', 'rgb(232, 230, 227)'],
['h1', 'color', 'rgb(255, 26, 26)'],
['a', 'color', 'rgb(51, 145, 255)'],
]);
await popupUtils.click(automationMenuSelector);
await popupUtils.click(automationSystemSelector);
await expectStyles([
['document', 'background-color', 'rgba(0, 0, 0, 0)'],
['document', 'color', 'rgb(0, 0, 0)'],
['body', 'background-color', 'rgba(0, 0, 0, 0)'],
['body', 'color', 'rgb(0, 0, 0)'],
['h1', 'color', 'rgb(255, 0, 0)'],
['a', 'color', 'rgb(0, 0, 238)'],
]);
if ((await backgroundUtils.getManifest()).manifest_version === 3) {
expect(await backgroundUtils.getChromeStorage('local', ['system-color-state'])).toEqual({
'system-color-state': {wasLastColorSchemeDark: false},
});
}
await emulateColorScheme('dark');
await expectStyles([
['document', 'background-color', 'rgb(24, 26, 27)'],
['document', 'color', 'rgb(232, 230, 227)'],
['body', 'background-color', 'rgb(24, 26, 27)'],
['body', 'color', 'rgb(232, 230, 227)'],
['h1', 'color', 'rgb(255, 26, 26)'],
['a', 'color', 'rgb(51, 145, 255)'],
]);
await emulateColorScheme('light');
await expectStyles([
['document', 'background-color', 'rgba(0, 0, 0, 0)'],
['document', 'color', 'rgb(0, 0, 0)'],
['body', 'background-color', 'rgba(0, 0, 0, 0)'],
['body', 'color', 'rgb(0, 0, 0)'],
['h1', 'color', 'rgb(255, 0, 0)'],
['a', 'color', 'rgb(0, 0, 238)'],
]);
await popupUtils.click(automationSystemSelector);
await expectStyles([
['document', 'background-color', 'rgb(24, 26, 27)'],
['document', 'color', 'rgb(232, 230, 227)'],
['body', 'background-color', 'rgb(24, 26, 27)'],
['body', 'color', 'rgb(232, 230, 227)'],
['h1', 'color', 'rgb(255, 26, 26)'],
['a', 'color', 'rgb(51, 145, 255)'],
]);
await emulateColorScheme('dark');
});
// Note: this test is relevant only to Firefox and Thunderbird
it('should ignore color watcher messages from subframes', async () => {
const darkPageExpectations: StyleExpectations = [
['document', 'background-color', 'rgb(24, 26, 27)'],
['document', 'color', 'rgb(232, 230, 227)'],
['body', 'background-color', 'rgb(24, 26, 27)'],
['body', 'color', 'rgb(232, 230, 227)'],
['h1', 'color', 'rgb(255, 26, 26)'],
['a', 'color', 'rgb(51, 145, 255)'],
];
const lightPageExpectations: StyleExpectations = [
['document', 'background-color', 'rgba(0, 0, 0, 0)'],
['document', 'color', 'rgb(0, 0, 0)'],
['body', 'background-color', 'rgba(0, 0, 0, 0)'],
['body', 'color', 'rgb(0, 0, 0)'],
['h1', 'color', 'rgb(255, 0, 0)'],
['a', 'color', 'rgb(0, 0, 238)'],
];
const darkSubframePageExpectations: StyleExpectations = [
[['iframe', 'h1'], 'color', 'rgb(255, 26, 26)'],
[['iframe', 'a'], 'color', 'rgb(51, 145, 255)'],
];
const lightSubframePageExpectations: StyleExpectations = [
[['iframe', 'h1'], 'color', 'rgb(255, 0, 0)'],
[['iframe', 'a'], 'color', 'rgb(0, 0, 238)'],
];
let loadSubframe: () => void = () => void(0);
const loadCompleted = loadTestPage({
'/': multiline(
'<!DOCTYPE html>',
'<html>',
'<head>',
' <style>',
' h1 { color: red; }',
' </style>',
'</head>',
'<body>',
` <h1>Color scheme detector</h1>`,
' <p>Text</p>',
' <a href="#">Link</a>',
' <iframe src="/subframe.html" style="color-scheme: light"></iframe>',
'</body>',
'</html>',
),
'/subframe.html': async (_, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html');
await new Promise<void>((resolve) => loadSubframe = resolve);
res.end(
multiline(
'<!DOCTYPE html>',
'<html>',
'<head>',
' <style>',
' h1 { color: red; }',
' </style>',
'</head>',
'<body>',
' <h1>Header</h1>',
' <p>Text</p>',
' <a href="#">Link</a>',
'</body>',
'</html>',
),
'utf8'
);
},
}, {
waitUntil: 'domcontentloaded',
});
await awaitForEvent('ready-/');
await emulateColorScheme('dark');
await expectStyles(darkPageExpectations);
await popupUtils.click(automationMenuSelector);
await popupUtils.click(automationSystemSelector);
await expectStyles(darkPageExpectations);
// Finalize page load
loadSubframe();
// Top-level page may finish loading only after the subframe has loaded
await loadCompleted;
// Ensure that the subframe received its styles
await expectStyles(darkSubframePageExpectations);
// Ensure that the parent frame retained its styles
await expectStyles(darkPageExpectations);
await emulateColorScheme('light');
await expectStyles(lightPageExpectations);
await expectStyles(lightSubframePageExpectations);
await popupUtils.click(automationSystemSelector);
await expectStyles(darkPageExpectations);
await emulateColorScheme('dark');
});
it('should have new design button on desktop', async () => {
await devtoolsUtils.click('.settings-tab-panel__button:nth-child(4)');
expect(await devtoolsUtils.exists('.preview-design-button'));
});
it('dynamic themes', async () => {
await loadBasicPage('Dynamic styles');
const numStyles = await pageUtils.evaluateScript(() => document.styleSheets.length);
expect(numStyles).toBe(1);
});
});
+466
View File
@@ -0,0 +1,466 @@
import {TestEnvironment} from 'jest-environment-node';
import {launch} from 'puppeteer-core';
import {WebSocketServer} from 'ws';
import {generateHTMLCoverageReports} from './coverage.js';
import {getChromePath, getFirefoxPath, chromeMV3ExtensionDebugDir, chromePlusExtensionDebugDir, firefoxExtensionDebugDir, getEdgePath} from './paths.js';
import {createTestServer, generateRandomId} from './server.js';
const TEST_SERVER_PORT = 8891;
const CORS_SERVER_PORT = 8892;
const FIREFOX_DEVTOOLS_PORT = 8893;
const POPUP_TEST_PORT = 8894;
export default class CustomJestEnvironment extends TestEnvironment {
/** @type {() => void} */
extensionStartListeners = [];
pageEventListeners = new Map();
/** @type {Browser} */
browser;
/** @type {WebSocketServer} */
messageServer;
async setup() {
await super.setup();
const promises1 = [
this.createMessageServer(),
this.launchBrowser(),
];
const promises2 = [
createTestServer(TEST_SERVER_PORT),
createTestServer(CORS_SERVER_PORT),
];
const results1 = await Promise.all(promises1);
this.messageServer = results1[0];
this.browser = results1[1];
promises2.push(
this.createTestPage(),
);
const results2 = await Promise.all(promises2);
this.testServer = results2[0];
this.corsServer = results2[1];
this.page = results2[2];
// Wait for tabs to load?
this.assignTestGlobals(this.global, this.testServer, this.corsServer, this.page);
}
/**
* @returns {Promise<void>}
*/
async waitForStartup() {
if (!this.extensionOrigin) {
return new Promise((ready) => this.extensionStartListeners.push(ready));
}
}
/**
* @returns {Promise<Browser>}
*/
async launchBrowser() {
let browser;
if (this.global.product === 'edge') {
browser = await this.launchEdge();
} else if (this.global.product === 'chrome-mv3') {
browser = await this.launchChrome();
} else if (this.global.product === 'firefox') {
browser = await this.launchFirefox();
}
// Wait for the extension to start
await this.waitForStartup();
return browser;
}
/**
* @returns {Promise<Browser>}
*/
async launchChrome() {
const extensionDir = chromeMV3ExtensionDebugDir;
let executablePath;
try {
executablePath = await getChromePath();
} catch (e) {
console.error(e);
}
// Explanation of these options:
// https://pptr.dev/guides/chrome-extensions
return await launch({
args: [
'--show-component-extension-options',
],
enableExtensions: [extensionDir],
executablePath,
headless: false,
pipe: true,
});
}
/**
* @returns {Promise<Browser>}
*/
async launchEdge() {
const extensionDir = chromePlusExtensionDebugDir;
let executablePath;
try {
executablePath = await getEdgePath();
} catch (e) {
console.error(e);
}
return await launch({
args: [
'--show-component-extension-options',
],
enableExtensions: [extensionDir],
executablePath,
headless: false,
pipe: true,
});
}
/**
* @returns {Promise<Browser>}
*/
async launchFirefox() {
// We need to manually launch Firefox via cmd.run() to install extension
// because Firefox does not support installing via CLI arguments
process.setMaxListeners(process.getMaxListeners() + 1);
const firefox = await getFirefoxPath();
const browser = await launch({
browser: 'firefox',
executablePath: firefox,
protocol: 'webDriverBiDi',
headless: false,
args: [`--remote-debugging-port=${FIREFOX_DEVTOOLS_PORT}`],
});
await browser.installExtension(firefoxExtensionDebugDir);
return browser;
}
async createTestPage() {
const page = await this.browser.newPage();
page.on('pageerror', (err) => process.emit('uncaughtException', err));
if (this.global.product !== 'firefox') {
await page.coverage.startJSCoverage();
}
return page;
}
async getURL(path) {
// By this point browser should be loaded and extension should be started, but
// let's wait anuway
await this.waitForStartup();
const url = new URL(path, this.extensionOrigin);
return url.href;
}
async getChromiumMV2BackgroundPage() {
const targets = this.browser.targets();
const backgroundTarget = targets.find((t) => t.type() === 'background_page');
return await backgroundTarget.page();
}
async awaitForEvent(uuid) {
return new Promise((resolve) => {
if (this.pageEventListeners.has(uuid)) {
this.pageEventListeners.get(uuid).push(resolve);
} else {
this.pageEventListeners.set(uuid, [resolve]);
}
});
}
/**
* @param {Page} page
* @param {string} url
* @param {WaitForOptions} gotoOptions
* @returns Promise which resolves when page loads
*/
async pageGoto(url, gotoOptions) {
// Normalize URL
const pathname = new URL(url).pathname;
// Depending on external circumstances, page may connect to server before page.goto() reolves
const promise = this.awaitForEvent(`ready-${pathname}`);
await this.page.goto(url, gotoOptions);
await promise;
}
async openTestPage(url, gotoOptions) {
await this.page.bringToFront();
await this.pageGoto(url, gotoOptions);
}
onPageEventResponse(eventUUID) {
const resolves = this.pageEventListeners.get(eventUUID);
this.pageEventListeners.delete(eventUUID);
resolves && resolves.forEach((r) => r());
}
/**
* This function is evaluated within browser's page context
* after being passed to page.evaluate()
* It can use methods which will be defined in the page context,
* but can not use variables defined in this file besides those passed into it.
*/
async checkPageStylesInBrowserContext(expectations) {
const checkOne = (expectation) => {
const [selector, cssAttributeName, expectedValue] = expectation;
const selector_ = Array.isArray(selector) ? selector : [selector];
let element = document;
for (const part of selector_) {
if (element instanceof HTMLIFrameElement) {
element = element.contentDocument;
}
if (element.shadowRoot instanceof ShadowRoot) {
element = element.shadowRoot;
}
if (part === 'document') {
element = element.documentElement;
} else {
element = element.querySelector(part);
}
if (!element) {
return `Could not find element ${part}`;
}
}
const style = getComputedStyle(element);
if (style[cssAttributeName] !== expectedValue) {
return `Expected ${selector_.join(' ')} '${cssAttributeName}' to be '${expectedValue}', but got '${style[cssAttributeName]}'`;
}
};
const checkAll = () => {
/** @type{Array<[number, string]>} */
const errors = [];
for (let i = 0; i < expectations.length; i++) {
const error = checkOne(expectations[i]);
if (error) {
errors.push(error);
}
}
return errors;
};
let timeout = 10;
let errors = checkAll();
for (let i = 0; (errors.length !== 0) && (i < 10); i++) {
timeout *= 2;
await new Promise((r) => requestIdleCallback(r, {timeout}));
errors = checkAll();
}
return errors;
}
assignTestGlobals(global, testServer, corsServer, page) {
global.getColorScheme = async () => {
if (global.product === 'firefox') {
return await global.backgroundUtils.getColorScheme();
}
const isDark = await page.evaluate(() => matchMedia('(prefers-color-scheme: dark)').matches);
return isDark ? 'dark' : 'light';
};
global.pageUtils.evaluateScript = async (script) => await page.evaluate(script);
global.expectPageStyles = async (expect, expectations) => {
if (!Array.isArray(expectations[0])) {
expectations = [expectations];
}
const errors = await page.evaluate(this.checkPageStylesInBrowserContext, expectations);
expect(errors.join('\n')).toBe('');
};
global.emulateColorScheme = async (colorScheme) => {
if (global.product === 'firefox') {
await global.pageUtils.emulateColorScheme(colorScheme);
await global.backgroundUtils.emulateColorScheme(colorScheme);
const newPageColorScheme = await global.backgroundUtils.getColorScheme();
const newBGColorScheme = await global.pageUtils.getColorScheme();
if (newPageColorScheme !== colorScheme || newBGColorScheme !== colorScheme) {
throw new Error('Failed to apply new color scheme');
}
return;
}
await page.emulateMediaFeatures([{name: 'prefers-color-scheme', value: colorScheme}]);
if (global.product === 'edge') {
const page = await this.getChromiumMV2BackgroundPage();
await page.emulateMediaFeatures([{name: 'prefers-color-scheme', value: colorScheme}]);
}
};
global.loadTestPage = async (paths, gotoOptions) => {
const {cors, ...testPaths} = paths;
testServer.setPaths(testPaths);
cors && corsServer.setPaths(cors);
await this.openTestPage(`http://localhost:${TEST_SERVER_PORT}`, gotoOptions);
};
global.corsURL = corsServer.url;
}
/**
* Creates a server and returns once extension connects to it
* @returns {Promise<WebSocketServer>} server
*/
async createMessageServer() {
const awaitForEvent = this.awaitForEvent.bind(this);
return new Promise((resolve) => {
const wsServer = new WebSocketServer({port: POPUP_TEST_PORT});
let backgroundSocket = null;
let devToolsSocket = null;
const popupSockets = new Set();
const pageSockets = new Set();
const resolvers = new Map();
const rejectors = new Map();
let onDownloadCallback = null;
wsServer.on('connection', async (ws) => {
ws.on('message', (data) => {
const message = JSON.parse(data);
if (message.id === null && message.data && message.data.type === 'background' && message.data.extensionOrigin) {
// This is the initial message which contains extension's URL origin
// and signals that extenstion is ready
this.extensionOrigin = message.data.extensionOrigin;
this.extensionStartListeners.forEach((ready) => ready());
ws.on('close', () => backgroundSocket = null);
backgroundSocket = ws;
resolve(wsServer);
} else if (message.id === null && message.data && message.data.type === 'devtools') {
ws.on('close', () => devToolsSocket = null);
devToolsSocket = ws;
this.onPageEventResponse(message.data.uuid);
} else if (message.id === null && message.data && message.data.type === 'popup') {
ws.on('close', () => popupSockets.delete(ws));
popupSockets.add(ws);
this.onPageEventResponse(message.data.uuid);
} else if (message.id === null && message.data && message.data.type === 'page') {
if (message.data.message === 'page-ready' && message.data.uuid === 'ready-/') {
ws.on('close', () => pageSockets.delete(ws));
pageSockets.add(ws);
}
this.onPageEventResponse(message.data.uuid);
} else if (message.id === null && message.data && message.data.type === 'download') {
if (onDownloadCallback) {
onDownloadCallback(message.data);
}
} else if (message.error) {
const reject = rejectors.get(message.id);
reject(message.error);
} else {
const resolve = resolvers.get(message.id);
resolve(message.data);
}
resolvers.delete(message.id);
rejectors.delete(message.id);
});
});
function sendToContext(sockets, type, data) {
return new Promise((resolve, reject) => {
const id = generateRandomId();
resolvers.set(id, resolve);
rejectors.set(id, reject);
const json = JSON.stringify({type, data, id});
for (const ws of sockets) {
ws.send(json);
}
});
}
function sendToPopup(type, data) {
return sendToContext(Array.from(popupSockets), type, data);
}
function sendToDevTools(type, data) {
return sendToContext([devToolsSocket], type, data);
}
function sendToBackground(type, data) {
return sendToContext([backgroundSocket], type, data);
}
function sendToPage(type, data) {
return sendToContext(Array.from(pageSockets), type, data);
}
async function applyDevtoolsConfig(type, fixes) {
const promise = awaitForEvent('darkreader-dynamic-theme-ready');
await Promise.all([
sendToDevTools(type, fixes),
promise,
]);
}
this.global.popupUtils = {
saveFile: async (name, content) => sendToPopup('popup-saveFile', {name, content}),
click: async (selector) => await sendToPopup('popup-click', selector),
exists: async (selector) => await sendToPopup('popup-exists', selector),
};
this.global.devtoolsUtils = {
click: async (selector) => await sendToDevTools('devtools-click', selector),
exists: async (selector) => await sendToDevTools('devtools-exists', selector),
paste: async (fixes) => await applyDevtoolsConfig('devtools-paste', fixes),
reset: async () => await applyDevtoolsConfig('devtools-reset'),
};
this.global.backgroundUtils = {
changeSettings: async (settings) => await sendToBackground('changeSettings', settings),
collectData: async () => await sendToBackground('collectData'),
changeChromeStorage: async (region, data) => await sendToBackground('changeChromeStorage', {region, data}),
getChromeStorage: async (region, keys) => await sendToBackground('getChromeStorage', {region, keys}),
getManifest: async () => await sendToBackground('getManifest'),
getColorScheme: async () => {
return await sendToBackground('firefox-getColorScheme');
},
emulateColorScheme: async (colorScheme) => {
await sendToBackground('firefox-emulateColorScheme', colorScheme);
},
setNews: async (news) => await sendToBackground('setNews', news),
onDownload: (callback) => onDownloadCallback = callback,
};
this.global.pageUtils = {
emulateColorScheme: async (colorScheme) => await sendToPage('firefox-emulateColorScheme', colorScheme),
getColorScheme: async () => {
return await sendToPage('firefox-getColorScheme');
},
};
this.global.awaitForEvent = awaitForEvent;
});
}
/**
* @returns {Promise<void>}
*/
async teardown() {
await super.teardown();
const promises = [];
if (this.global.product !== 'firefox' && this.page?.coverage) {
const coverage = await this.page.coverage.stopJSCoverage();
const dir = './tests/browser/coverage/';
const promise = generateHTMLCoverageReports(dir, coverage);
promise.then(() => console.info('Coverage reports generated in', dir));
promises.push(promise);
}
// Note: this.browser.close() will close all tabs, so no need to close them
// explicitly
promises.push([
this.testServer?.close(),
this.corsServer?.close(),
this.messageServer?.close(),
this.browser?.close(),
]);
await Promise.all(promises);
}
}
+44
View File
@@ -0,0 +1,44 @@
import type {RequestListener} from 'http';
import type {WaitForOptions} from 'puppeteer-core';
import type {ColorScheme, ExtensionData, News, UserSettings} from '../../src/definitions';
type PathsObject = {[path: string]: string | RequestListener | PathsObject};
type OneStyleExpectation = [selector: string | string[], cssAttributeName: string, expectedValue: string];
type StyleExpectations = OneStyleExpectation[] | OneStyleExpectation;
declare global {
const loadTestPage: (paths: PathsObject & {cors?: PathsObject}, gotoOptions?: WaitForOptions) => Promise<void>;
const corsURL: string;
const popupUtils: {
click: (selector: string) => Promise<void>;
exists: (selector: string) => Promise<void>;
saveFile: (name: string, content: string) => Promise<void>;
};
const devtoolsUtils: {
click: (selector: string) => Promise<void>;
exists: (selector: string) => Promise<void>;
paste: (fixes: string) => Promise<void>;
reset: () => Promise<void>;
};
const backgroundUtils: {
changeSettings: (settings: Partial<UserSettings>) => Promise<void>;
collectData: () => Promise<ExtensionData>;
changeChromeStorage: (region: 'local' | 'sync', data: {[key: string]: any}) => Promise<void>;
getColorScheme: () => Promise<ColorScheme>;
getChromeStorage: (region: 'local' | 'sync', keys: string[]) => Promise<{[key: string]: any}>;
getManifest: () => Promise<chrome.runtime.Manifest>;
setNews: (news: News[] | null) => Promise<void>;
onDownload: (callback: (p: {ok: boolean}) => void) => void;
emulateColorScheme: (colorScheme: ColorScheme) => Promise<void>;
};
const pageUtils: {
evaluateScript: (script: () => any) => Promise<any>;
};
const emulateColorScheme: (value: ColorScheme) => Promise<void>;
const awaitForEvent: (uuid: string) => Promise<void>;
const expectPageStyles: (expect: jest.Expect, expectations: StyleExpectations) => Promise<void>;
const getColorScheme: () => Promise<ColorScheme>;
const product: 'firefox';
}
@@ -0,0 +1,5 @@
import config from './jest.config.mjs';
config.globals.product = 'chrome-mv3';
config.globals.__CHROMIUM_MV2__ = false;
config.globals.__CHROMIUM_MV3__ = true;
export default config;
@@ -0,0 +1,4 @@
import config from './jest.config.mjs';
config.globals.product = 'firefox';
config.globals.__CHROMIUM_MV2__ = false;
export default config;
+31
View File
@@ -0,0 +1,31 @@
// @ts-check
import {dirname} from 'node:path';
import {createRequire} from 'node:module';
const rootDir = dirname(createRequire(import.meta.url).resolve('../../package.json'));
/** @type {import('@jest/types').Config.InitialOptions} */
const config = {
rootDir,
testMatch: ['<rootDir>/tests/browser/**/*.tests.ts'],
moduleFileExtensions: ['ts', 'tsx', 'js'],
testEnvironment: '<rootDir>/tests/browser/environment.js',
verbose: true,
transform: {'^.+\\.ts(x?)$': ['ts-jest', {tsconfig: '<rootDir>/tests/browser/tsconfig.json'}]},
globals: {
__DEBUG__: false,
__CHROMIUM_MV2__: true,
__CHROMIUM_MV3__: false,
__FIREFOX_MV2__: false,
__THUNDERBIRD__: false,
__TEST__: true,
product: 'edge',
},
setupFilesAfterEnv: ['jest-extended/all'],
collectCoverage: false,
coverageDirectory: 'coverage',
collectCoverageFrom: ['<rootDir>/src/**/*.{ts,tsx}'],
coveragePathIgnorePatterns: ['^.+\\.d\\.ts$'],
};
export default config;
+117
View File
@@ -0,0 +1,117 @@
// @ts-check
import {exec} from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import * as url from 'url';
const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
/**
* @param {string} relPath
* @returns {string}
*/
function winProgramFiles(relPath) {
const x64Path = path.join(process.env.PROGRAMFILES, relPath);
if (fs.existsSync(x64Path)) {
return x64Path;
}
return path.join(process.env['ProgramFiles(x86)'], relPath);
}
/**
* @param {string} app
* @returns {Promise<string>}
*/
function linuxAppPath(app) {
return new Promise((resolve, reject) => {
exec(`which ${app}`, (err, result) => {
if (err) {
reject(err);
} else {
resolve(result.trim());
}
});
});
}
/**
* @returns {Promise<string>}
*/
export async function getChromePath() {
if (process.platform === 'darwin') {
return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
}
if (process.platform === 'win32') {
return winProgramFiles('Google\\Chrome\\Application\\chrome.exe');
}
const possibleLinuxPaths = ['google-chrome', 'google-chrome-stable', 'chromium'];
for (const possiblePath of possibleLinuxPaths) {
try {
return await linuxAppPath(possiblePath);
} catch (e) {
// ignore
}
}
throw new Error('Could not find Chrome');
}
/**
* @returns {Promise<string>}
*/
export async function getEdgePath() {
if (process.platform === 'darwin') {
return '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge';
}
if (process.platform === 'win32') {
return winProgramFiles('Microsoft\\Edge\\Application\\msedge.exe');
}
const possibleLinuxPaths = ['microsoft-edge', 'microsoft-edge-stable'];
for (const possiblePath of possibleLinuxPaths) {
try {
return await linuxAppPath(possiblePath);
} catch (e) {
// ignore
}
}
throw new Error('Could not find Edge');
}
/**
* @returns {Promise<string>}
*/
export async function getFirefoxPath() {
if (process.platform === 'darwin') {
return '/Applications/Firefox Nightly.app/Contents/MacOS/firefox';
}
if (process.platform === 'win32') {
return await winProgramFiles('Firefox Nightly\\firefox.exe');
}
const possibleLinuxPaths = ['firefox-nightly', 'firefox'];
for (const possiblePath of possibleLinuxPaths) {
try {
// snap profile folders do not get loaded
const option = await linuxAppPath(possiblePath);
// Firefox snap can not access the regular system-wide temporary directory,
// so we create a separate one within build folder
// See also: https://github.com/mozilla/web-ext/issues/1696
if (!option.includes('/snap/')) {
return option;
}
const firefoxProfile = './build/firefox-profile-for-testing';
process.env.TMPDIR = firefoxProfile;
try {
fs.mkdirSync(firefoxProfile);
} catch (e) {
// Do nothing
}
return option;
} catch (e) {
// ignore
}
}
throw new Error('Could not find firefox-nightly');
}
export const chromeExtensionDebugDir = path.join(__dirname, '../../build/debug/chrome');
export const chromePlusExtensionDebugDir = path.join(__dirname, '../../build/debug/chrome-plus');
export const chromeMV3ExtensionDebugDir = path.join(__dirname, '../../build/debug/chrome-mv3');
export const firefoxExtensionDebugDir = path.join(__dirname, '../../build/debug/firefox');
+127
View File
@@ -0,0 +1,127 @@
// @ts-check
import http from 'node:http';
import path from 'node:path';
const mimeTypes = new Map(
Object.entries({
'.css': 'text/css',
'.html': 'text/html',
'.jpg': 'image/jpeg',
'.js': 'text/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.svg': 'image/svg+xml',
}),
);
/**
* We reuse a single listener for each of exit and SIGINT events to
* avoid warnings about possible event listener leaks.
* @type {Array<() => Promise<void>>}
*/
const terminationListeners = [];
const terminationListener = () => {
terminationListeners.forEach((listener) => listener());
};
export function generateRandomId() {
return Math.floor(Math.random() * 2 ** 55).toString();
}
/**
* @param {number} port
*/
export async function createTestServer(port) {
/** @type {import('http').Server} */
let server;
/** @type {{[path: string]: string | import('http').RequestListener}} */
const paths = {};
/** @type {Set<import('net').Socket>} */
const sockets = new Set();
/** @type {import('http').RequestListener} */
function handleRequest(req, res) {
const parsedURL = new URL(req.url, 'https://localhost');
const pathName = parsedURL.pathname;
if (!paths.hasOwnProperty(pathName)) {
res.statusCode = 404;
res.end('Not found');
return;
}
const contentOrListener = paths[pathName];
if (typeof contentOrListener === 'function') {
const listener = contentOrListener;
return listener(req, res);
}
const content = contentOrListener;
const ext = pathName === '/' ? '.html' : path.extname(pathName);
const contentType = mimeTypes.get(ext) || 'text/plain';
res.statusCode = 200;
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'no-cache');
res.end(content, 'utf8');
}
/**
* @returns {Promise<void>}
*/
function start() {
return new Promise((resolve) => {
server = http
.createServer(handleRequest)
.listen(port, () => resolve());
server.on('connection', (socket) => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
});
});
}
/**
* @param {{[path: string]: string | import('http').RequestListener}} newPaths
*/
function setPaths(newPaths) {
Object.assign(paths, newPaths);
}
/**
* @returns {Promise<void>}
*/
function close() {
if (!server) {
return;
}
return new Promise((resolve) => {
server.close((err) => {
if (err) {
console.error(err);
}
server = null;
resolve();
});
sockets.forEach((socket) => {
socket.destroy();
});
});
}
if (terminationListeners.length === 0) {
process.on('exit', terminationListener);
process.on('SIGINT', terminationListener);
}
terminationListeners.push(close);
await start();
return {
setPaths,
close,
url: `http://localhost:${port}`,
};
}
@@ -0,0 +1,20 @@
describe('Modifying settings', () => {
it('Modifying sync settings to contain long list', async () => {
const newSettings = {
enabledFor: [] as string[],
disabledFor: [] as string[],
syncSettings: true,
};
// Cumulative length should be over the browser limit on record size
for (let i = 0; i < 1000; i ++) {
newSettings.disabledFor.push(`example${i}.com`);
}
await backgroundUtils.changeSettings(newSettings);
const extensionData = await backgroundUtils.collectData();
expect(extensionData.settings.syncSettings).toBe(true);
expect(extensionData.settings.disabledFor.length).toBe(1000);
});
});
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2019",
"module": "CommonJS",
"lib": [
"ES2015",
"Dom"
],
"types": [
"chrome",
"jest",
"puppeteer-core"
],
"allowJs": true,
"downlevelIteration": true,
"esModuleInterop": true,
"jsx": "react",
"jsxFactory": "m",
"moduleResolution": "Node",
"noEmit": true,
"noImplicitAny": true,
"ignoreDeprecations": "6.0",
}
}