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
@@ -0,0 +1,125 @@
import '../support/polyfills';
import {DEFAULT_THEME} from '../../../src/defaults';
import {createOrUpdateDynamicTheme, removeDynamicTheme} from '../../../src/inject/dynamic-theme';
import {multiline} from '../support/test-utils';
const theme = {
...DEFAULT_THEME,
};
let container: HTMLElement;
beforeEach(() => {
container = document.body;
container.innerHTML = '';
});
afterEach(() => {
removeDynamicTheme();
container.innerHTML = '';
});
describe('COLOR PARSING', () => {
it('should modify RGBA', async () => {
container.innerHTML = multiline(
'<style>',
' h1 { background: rgb(245, 185, 124); }',
' h1 strong { color: rgb(34, 52, 34); }',
' body { background-color: rgba(51, 170, 51, .4); }',
'</style>',
'<h1>RGB <strong>Power</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
expect(getComputedStyle(container.querySelector('h1')!).backgroundColor).toBe('rgb(126, 68, 10)');
expect(getComputedStyle(container.querySelector('h1 strong')!).color).toBe('rgb(205, 200, 194)');
expect(getComputedStyle(container).backgroundColor).toBe('rgba(41, 136, 41, 0.4)');
});
it('should modify HSL', async () => {
container.innerHTML = multiline(
'<style>',
' h1 { background: hsl(270,60%,70%); }',
' h1 strong { color: hsl(.75turn, 60%, 70%); }',
' body { background-color: hsl(4.71239rad, 60%, 70%); }',
'</style>',
'<h1>HSL <strong>Power</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
expect(getComputedStyle(container.querySelector('h1')!).backgroundColor).toBe('rgb(72, 29, 114)');
expect(getComputedStyle(container.querySelector('h1 strong')!).color).toBe('rgb(176, 129, 223)');
expect(getComputedStyle(container).backgroundColor).toBe('rgb(72, 29, 114)');
});
it('should modify HSLA', async () => {
container.innerHTML = multiline(
'<style>',
' h1 { background: hsla(240, 100%, 50%, .7); }',
' h1 strong { color: hsla(240, 100%, 50%, 1); }',
' body { background-color: hsla(240, 100%, 50%, .05); }',
'</style>',
'<h1>HSLA <strong>Power</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
expect(getComputedStyle(container.querySelector('h1')!).backgroundColor).toBe('rgba(0, 0, 204, 0.7)');
expect(getComputedStyle(container.querySelector('h1 strong')!).color).toBe('rgb(51, 125, 255)');
expect(getComputedStyle(container).backgroundColor).toBe('rgba(0, 0, 204, 0.05)');
});
it('should modify knownColors', async () => {
container.innerHTML = multiline(
'<style>',
' h1 { background: rebeccapurple; }',
' h1 strong { color: InfoBackground; }',
' body { background-color: transparent; }',
'</style>',
'<h1>Weird color <strong>Power</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
expect(getComputedStyle(container.querySelector('h1')!).backgroundColor).toBe('rgb(82, 41, 122)');
expect(getComputedStyle(container.querySelector('h1 strong')!).color).toBe('rgb(249, 250, 166)');
expect(getComputedStyle(container).backgroundColor).toBe('rgba(0, 0, 0, 0)');
});
it('should handle calc(...) cases', () => {
container.innerHTML = multiline(
'<style>',
' h1 { background-color: hsl(0, 0%, calc(95% - 3%)) }',
'</style>',
'<h1>Weird color <strong>Power</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
expect(getComputedStyle(container.querySelector('h1')!).backgroundColor).toBe('rgb(35, 38, 40)');
});
it('should handle gradient\'s cases with rgb(...) xx%', async () => {
container.innerHTML = multiline(
'<style>',
' h1 { background-image: -webkit-linear-gradient(bottom, rgb(255, 255, 255) 15%, rgb(246, 246, 245) 85%); }',
'</style>',
'<h1>Weird color <strong>Power</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
expect(getComputedStyle(container.querySelector('h1')!).backgroundImage).toBe('-webkit-linear-gradient(bottom, rgb(24, 26, 27) 15%, rgb(29, 32, 33) 85%)');
});
it('should handle complex calc(...) cases', () => {
container.innerHTML = multiline(
'<style>',
' h1 { background-color: rgb(calc(216.75 + 153 * .15), calc(216.75 + 205 * .15), calc(216.75 + 255 * .15)) }',
'</style>',
'<h1>Weird color <strong>Power</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
expect(getComputedStyle(container.querySelector('h1')!).backgroundColor).toBe('rgb(28, 31, 32)');
});
it('should handle gradients with calc(...) cases', () => {
container.innerHTML = multiline(
'<style>',
' h1 { background-image: linear-gradient(rgb(249, 249, 251) calc(100% - 3px), transparent), linear-gradient(-90deg, rgb(255, 145, 0), rgb(241, 3, 102) 50%, rgb(97, 115, 255)) }',
'</style>',
'<h1>Weird color <strong>Power</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
expect(getComputedStyle(container.querySelector('h1')!).backgroundImage).toBe('linear-gradient(rgb(27, 29, 30), calc(100% - 3px), rgba(0, 0, 0, 0)), linear-gradient(-90deg, rgb(204, 116, 0), rgb(193, 2, 82) 50%, rgb(0, 17, 146))');
});
});
@@ -0,0 +1,118 @@
import '../support/polyfills';
import {DEFAULT_THEME} from '../../../src/defaults';
import type {DynamicThemeFix} from '../../../src/definitions';
import {FilterMode} from '../../../src/generators/css-filter';
import {createOrUpdateDynamicTheme, removeDynamicTheme} from '../../../src/inject/dynamic-theme';
import {removeNode} from '../../../src/inject/utils/dom';
import {multiline, timeout} from '../support/test-utils';
let container: HTMLElement;
beforeEach(() => {
container = document.body;
container.innerHTML = '';
});
afterEach(() => {
removeDynamicTheme();
container.innerHTML = '';
removeNode(document.head.querySelector('meta[name="darkreader-lock"]'));
});
describe('FIXES', () => {
it('should add custom attributes to root element', () => {
createOrUpdateDynamicTheme(DEFAULT_THEME, null, false);
expect(document.documentElement.getAttribute(`data-darkreader-mode`)).toBe('dynamic');
expect(document.documentElement.getAttribute('data-darkreader-scheme')).toBe('dark');
createOrUpdateDynamicTheme({...DEFAULT_THEME, mode: FilterMode.light}, null, false);
expect(document.documentElement.getAttribute('data-darkreader-scheme')).toBe('dimmed');
});
it('should invert selectors', async () => {
container.innerHTML = multiline(
'<div class="logo">Some logo</div>',
);
const fixes: DynamicThemeFix[] = [{
url: ['*'],
invert: ['.logo'],
css: '',
ignoreInlineStyle: [],
ignoreImageAnalysis: [],
ignoreCSSUrl: [],
disableStyleSheetsProxy: false,
disableCustomElementRegistryProxy: false,
}];
createOrUpdateDynamicTheme(DEFAULT_THEME, fixes, false);
expect(getComputedStyle(container.querySelector('.logo')!).filter).toBe('invert(1) hue-rotate(180deg) contrast(0.9)');
});
it('should insert CSS', async () => {
container.innerHTML = multiline(
'<p class="text">Some text need to be red</p>',
);
const fixes: DynamicThemeFix[] = [{
url: ['*'],
invert: [''],
css: '.text { color: red }',
ignoreInlineStyle: [],
ignoreImageAnalysis: [],
ignoreCSSUrl: [],
disableStyleSheetsProxy: false,
disableCustomElementRegistryProxy: false,
}];
createOrUpdateDynamicTheme(DEFAULT_THEME, fixes, false);
expect(getComputedStyle(container.querySelector('.text')!).color).toBe('rgb(255, 0, 0)');
});
it('should ignore inline style', async () => {
container.innerHTML = multiline(
'<p class="text" style="background-color: purple">Some text need to be red</p>',
);
const fixes: DynamicThemeFix[] = [{
url: ['*'],
invert: [''],
css: '',
ignoreInlineStyle: ['.text'],
ignoreImageAnalysis: [],
ignoreCSSUrl: [],
disableStyleSheetsProxy: false,
disableCustomElementRegistryProxy: false,
}];
createOrUpdateDynamicTheme(DEFAULT_THEME, fixes, false);
expect(getComputedStyle(container.querySelector('.text')!).backgroundColor).toBe('rgb(128, 0, 128)');
});
it('should ignore styling when darkreader-lock detected', async () => {
document.head.innerHTML = '<meta name="darkreader-lock">',
container.innerHTML = multiline(
'<style>',
' body {',
' background-color: pink !important;',
' }',
'</style>',
);
createOrUpdateDynamicTheme(DEFAULT_THEME, null, false);
expect(getComputedStyle(document.body).backgroundColor).toBe('rgb(255, 192, 203)');
});
it('should ignore styling when delayed darkreader-lock detected', async () => {
container.innerHTML = multiline(
'<style>',
' body {',
' background-color: pink !important;',
' }',
'</style>',
);
createOrUpdateDynamicTheme(DEFAULT_THEME, null, false);
expect(getComputedStyle(container).backgroundColor).toBe('rgb(89, 0, 16)');
const metaElement: HTMLMetaElement = document.createElement('meta');
metaElement.name = 'darkreader-lock';
document.head.appendChild(metaElement);
await timeout(100);
expect(getComputedStyle(container).backgroundColor).toBe('rgb(255, 192, 203)');
});
});
@@ -0,0 +1,290 @@
import '../support/polyfills';
import {DEFAULT_THEME} from '../../../src/defaults';
import type {DynamicThemeFix} from '../../../src/definitions';
import {createOrUpdateDynamicTheme, removeDynamicTheme} from '../../../src/inject/dynamic-theme';
import {getImageDetails} from '../../../src/inject/dynamic-theme/image';
import {multiline, timeout, waitForEvent} from '../support/test-utils';
const theme = {
...DEFAULT_THEME,
darkSchemeBackgroundColor: 'black',
darkSchemeTextColor: 'white',
};
let container: HTMLElement;
beforeEach(() => {
container = document.body;
container.innerHTML = '';
});
afterEach(() => {
removeDynamicTheme();
container.innerHTML = '';
});
function svgToDataURL(svg: string) {
return `data:image/svg+xml;base64,${btoa(svg)}`;
}
function getSVGImageCSS(svg: string, width: number, height: number, selector: string) {
return multiline(
`${selector} {`,
` background-image: url("${svgToDataURL(svg)}");`,
' background-position: center;',
' background-repeat: no-repeat;',
' background-size: cover;',
' display: inline-block;',
` height: ${height}px;`,
` width: ${width}px;`,
'}',
);
}
const images = {
darkIcon: multiline(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="8" height="8">',
' <rect fill="black" width="100%" height="100%" />',
'</svg>',
),
lightIcon: multiline(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="8" height="8">',
' <rect fill="white" width="100%" height="100%" />',
'</svg>',
),
darkTransparentIcon: multiline(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="8" height="8">',
' <circle fill="black" cx="4" cy="4" r="3" />',
'</svg>',
),
lightTransparentIcon: multiline(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="8" height="8">',
' <circle fill="white" cx="4" cy="4" r="3" />',
'</svg>',
),
largeDarkImage: multiline(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="1024" height="1024">',
' <rect fill="black" width="100%" height="100%" />',
'</svg>',
),
largeLightImage: multiline(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="1024" height="1024">',
' <rect fill="white" width="100%" height="100%" />',
'</svg>',
),
};
async function urlToImage(url: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(`Unable to load image ${url}`);
image.src = url;
});
}
async function getBgImageInfo(bgImageValue: string) {
const bgImageURL = bgImageValue.match(/^url\("(.*)"\)$/)![1];
const image = await urlToImage(bgImageURL);
const width = 8;
const height = 8;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d')!;
context.drawImage(image, 0, 0, width, height);
const d = context.getImageData(0, 0, width, height).data;
let lightPixels = 0;
let darkPixels = 0;
let opaquePixels = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = 4 * (x + width * y);
const r = d[i + 0];
const g = d[i + 1];
const b = d[i + 2];
const a = d[i + 3];
if (a > 127) {
opaquePixels++;
const lightness = (r + g + b) / 3 / 255;
if (lightness > 0.7) {
lightPixels++;
} else if (lightness < 0.3) {
darkPixels++;
}
}
}
}
return {
lightness: lightPixels / opaquePixels,
darkness: darkPixels / opaquePixels,
};
}
describe('IMAGE ANALYSIS', () => {
it('should analyze dark icon', async () => {
const details = await getImageDetails(svgToDataURL(images.darkIcon));
expect(details.width).toBe(8);
expect(details.height).toBe(8);
expect(details.isDark).toBe(true);
expect(details.isLight).toBe(false);
expect(details.isTransparent).toBe(false);
expect(details.isLarge).toBe(false);
});
it('should analyze light icon', async () => {
const details = await getImageDetails(svgToDataURL(images.lightIcon));
expect(details.width).toBe(8);
expect(details.height).toBe(8);
expect(details.isDark).toBe(false);
expect(details.isLight).toBe(true);
expect(details.isTransparent).toBe(false);
expect(details.isLarge).toBe(false);
});
it('should analyze dark transparent icon', async () => {
const details = await getImageDetails(svgToDataURL(images.darkTransparentIcon));
expect(details.width).toBe(8);
expect(details.height).toBe(8);
expect(details.isDark).toBe(true);
expect(details.isLight).toBe(false);
expect(details.isTransparent).toBe(true);
expect(details.isLarge).toBe(false);
});
it('should not analyze large image', async () => {
const details = await getImageDetails(svgToDataURL(images.largeDarkImage));
expect(details.width).toBe(1024);
expect(details.height).toBe(1024);
expect(details.isLarge).toBe(true);
});
it('should invert dark icons', async () => {
container.innerHTML = multiline(
'<style>',
getSVGImageCSS(images.darkTransparentIcon, 16, 16, 'i'),
'</style>',
'<h1>Dark icon <i></i></h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
await waitForEvent('__darkreader__test__asyncQueueComplete');
await timeout(500);
const bgImageValue = getComputedStyle(container.querySelector('i')!).backgroundImage;
const info = await getBgImageInfo(bgImageValue);
expect(info.darkness).toBe(0);
expect(info.lightness).toBe(1);
});
it('should not invert light icons', async () => {
container.innerHTML = multiline(
'<style>',
getSVGImageCSS(images.lightTransparentIcon, 16, 16, 'i'),
'</style>',
'<h1>Light icon <i></i></h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
const bgImageValue = getComputedStyle(container.querySelector('i')!).backgroundImage;
const info = await getBgImageInfo(bgImageValue);
expect(info.darkness).toBe(0);
expect(info.lightness).toBe(1);
});
it('should not invert dark backgrounds', async () => {
container.innerHTML = multiline(
'<style>',
getSVGImageCSS(images.largeDarkImage, 320, 320, 'h1'),
'</style>',
'<h1>Dark background</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
const bgImageValue = getComputedStyle(container.querySelector('h1')!).backgroundImage;
const info = await getBgImageInfo(bgImageValue);
expect(info.darkness).toBe(1);
expect(info.lightness).toBe(0);
});
it('should hide light backgrounds', async () => {
container.innerHTML = multiline(
'<style>',
getSVGImageCSS(images.largeLightImage, 320, 320, 'h1'),
'</style>',
'<h1>Light background</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
await waitForEvent('__darkreader__test__asyncQueueComplete');
const bgImageValue = getComputedStyle(container.querySelector('h1')!).backgroundImage;
expect(bgImageValue).toBe('none');
});
it('should ignore image analysis', async () => {
container.innerHTML = multiline(
'<style>',
getSVGImageCSS(images.darkTransparentIcon, 16, 16, 'i'),
'</style>',
'<h1>Dark icon <i></i></h1>',
);
const fixes: DynamicThemeFix[] = [{
url: ['*'],
invert: [''],
css: '',
ignoreInlineStyle: ['.'],
ignoreImageAnalysis: ['*'],
ignoreCSSUrl: [],
disableStyleSheetsProxy: false,
disableCustomElementRegistryProxy: false,
}];
createOrUpdateDynamicTheme(theme, fixes, false);
const backgroundImage = getComputedStyle(container.querySelector('i')!).backgroundImage;
expect(backgroundImage).toContain('data:');
});
it('should handle background-image with URL and gradient', async () => {
container.innerHTML = multiline(
'<style>',
` h1 { background-image: url("${svgToDataURL(images.lightIcon)}"), linear-gradient(red, white);`,
'</style>',
'<h1>Weird color <strong>Power</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
await waitForEvent('__darkreader__test__asyncQueueComplete');
expect(getComputedStyle(container.querySelector('h1')!).backgroundImage).toMatch(/^url\("blob:.*"\), linear-gradient\(rgb\(204, 0, 0\), rgb\(0, 0, 0\)\)$/);
});
it('should handle background-image with non-base64 data URL', async () => {
container.innerHTML = multiline(
'<style>',
` h1 { background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="6" height="3">%3Cpath%20d%3D%22m0%202.5%20l2%20-1.5%20l1%200%20l2%201.5%20l1%200%22%20stroke%3D%22%23d11%22%20fill%3D%22none%22%20stroke-width%3D%22.7%22%2F%3E</svg>');`,
'</style>',
'<h1>Weird color <strong>Power</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
await waitForEvent('__darkreader__test__asyncQueueComplete');
expect(getComputedStyle(container.querySelector('h1')!).backgroundImage).toMatch(/^url\("blob:.*"\)$/);
});
it('should handle background-image with URL and gradient (revered)', async () => {
container.innerHTML = multiline(
'<style>',
` h1 { background-image: linear-gradient(red, white), url("${svgToDataURL(images.lightIcon)}");`,
'</style>',
'<h1>Weird color <strong>Power</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
await waitForEvent('__darkreader__test__asyncQueueComplete');
await timeout(500);
expect(getComputedStyle(container.querySelector('h1')!).backgroundImage).toMatch(/^linear-gradient\(rgb\(204, 0, 0\), rgb\(0, 0, 0\)\), url\("blob:.*"\)$/);
});
it('should handle background-image with empty URLs', async () => {
container.innerHTML = multiline(
'<style>',
` h1 { background-image: url(''), url(''), url("${svgToDataURL(images.lightIcon)}");`,
'</style>',
'<h1>Weird color <strong>Power</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
await waitForEvent('__darkreader__test__asyncQueueComplete');
expect(getComputedStyle(container.querySelector('h1')!).backgroundImage).toMatch(/^url\(""\), url\(""\), url\("blob:.*"\)$/);
});
});
@@ -0,0 +1,58 @@
import {injectStyleAway, removeStyleContainer} from '../../../src/inject/dynamic-theme/injection';
import {timeout} from '../support/test-utils';
describe('STYLE INJECTION', () => {
let originalBody: HTMLElement;
beforeEach(() => {
removeStyleContainer();
originalBody = document.body;
originalBody.remove();
});
afterEach(() => {
removeStyleContainer();
document.body?.remove();
document.documentElement.append(originalBody);
});
it('should inject a queued style when the body becomes available', async () => {
const style = document.createElement('style');
style.textContent = 'body { color: white; }';
injectStyleAway(style);
expect(style.isConnected).toBe(false);
const body = document.createElement('body');
document.documentElement.append(body);
await timeout(0);
const container = body.querySelector('.darkreader-style-container');
expect(container).not.toBeNull();
expect(container!.lastElementChild).toBe(style);
expect(style.sheet!.cssRules.length).toBe(1);
});
it('should clear queued styles and allow observing a new body', async () => {
const discardedStyle = document.createElement('style');
injectStyleAway(discardedStyle);
removeStyleContainer();
const firstBody = document.createElement('body');
document.documentElement.append(firstBody);
await timeout(0);
expect(firstBody.querySelector('.darkreader-style-container')).toBeNull();
expect(discardedStyle.isConnected).toBe(false);
firstBody.remove();
const injectedStyle = document.createElement('style');
injectStyleAway(injectedStyle);
const secondBody = document.createElement('body');
document.documentElement.append(secondBody);
await timeout(0);
expect(secondBody.querySelector('.darkreader-style-container')?.lastElementChild).toBe(injectedStyle);
});
});
@@ -0,0 +1,85 @@
import '../support/polyfills';
import {DEFAULT_THEME} from '../../../src/defaults';
import {createOrUpdateDynamicTheme, removeDynamicTheme} from '../../../src/inject/dynamic-theme';
import {multiline, timeout} from '../support/test-utils';
const theme = {
...DEFAULT_THEME,
darkSchemeBackgroundColor: 'black',
darkSchemeTextColor: 'white',
};
let container: HTMLElement;
beforeEach(() => {
container = document.body;
container.innerHTML = '';
});
afterEach(() => {
removeDynamicTheme();
container.innerHTML = '';
});
describe('INLINE STYLES', () => {
it('should override inline style', () => {
container.innerHTML = '<span style="color: red;">Inline style override</span>';
createOrUpdateDynamicTheme(theme, null, false);
expect(getComputedStyle(document.querySelector('span')!).color).toBe('rgb(255, 26, 26)');
});
it('should watch for inline style change', async () => {
container.innerHTML = '<span style="color: red;">Watch inline style</span>';
createOrUpdateDynamicTheme(theme, null, false);
const span = document.querySelector('span')!;
expect(getComputedStyle(span).color).toBe('rgb(255, 26, 26)');
span.style.color = 'green';
await timeout(0);
expect(getComputedStyle(span).color).toBe('rgb(140, 255, 140)');
});
it('should override only a single inline style property', async () => {
container.innerHTML = multiline(
'<style>.bg-gray { background: gray; }</style>',
'<span class="bg-gray" style="color: red;">Inline style override</span>',
);
createOrUpdateDynamicTheme(theme, null, false);
const span = container.querySelector('span')!;
expect(getComputedStyle(span).backgroundColor).toBe('rgb(102, 102, 102)');
expect(getComputedStyle(span).color).toBe('rgb(255, 26, 26)');
span.style.color = 'green';
await timeout(0);
expect(getComputedStyle(span).color).toBe('rgb(140, 255, 140)');
expect(getComputedStyle(span).backgroundColor).toBe('rgb(102, 102, 102)');
});
it('should clean up the customProp after originial is gone', async () => {
container.innerHTML = '<span style="color: red;">Watch inline style</span>';
createOrUpdateDynamicTheme(theme, null, false);
const span = document.querySelector('span')!;
expect(span.getAttribute('style')!.startsWith('color: red; --darkreader-inline-color:')).toBeTrue();
expect(span.getAttribute('style')!.includes('--darkreader-inline-color: var(--darkreader-text-ff0000, #ff1a1a);')).toBe(true);
span.style.color = '';
await timeout(0);
expect(span.getAttribute('style')).toBe('');
});
it(`shouldn't touch rel="mask-icon"`, async () => {
container.innerHTML = '<link rel="mask-icon" color="red">';
createOrUpdateDynamicTheme(theme, null, false);
const maskIcon = document.querySelector('link[rel="mask-icon"]')!;
expect(maskIcon.getAttribute('style')).toBe(null);
});
it(`shouldn't touch a "none" value for fill`, async () => {
container.innerHTML = `<svg> <rect width="100" height="100" fill="none" /></svg>`;
container.innerHTML += `<style> rect[width][height] { fill: red }</style>`;
createOrUpdateDynamicTheme(theme, null, false);
const rect = container.querySelector('rect')!;
expect(getComputedStyle(rect).fill).toBe('rgb(255, 26, 26)');
});
});
@@ -0,0 +1,225 @@
import {DEFAULT_THEME} from '../../../src/defaults';
import {createOrUpdateDynamicTheme, removeDynamicTheme} from '../../../src/inject/dynamic-theme';
import {resetChromeRuntimeMessageStub, stubBackgroundFetchResponse, stubChromeRuntimeMessage} from '../support/background-stub';
import {getCSSEchoURL} from '../support/echo-client';
import {multiline, timeout, waitForEvent} from '../support/test-utils';
const theme = {
...DEFAULT_THEME,
darkSchemeBackgroundColor: 'black',
darkSchemeTextColor: 'white',
};
let container: HTMLElement;
const links: HTMLLinkElement[] = [];
function createStyleLink(href: string | null) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.classList.add('testcase--link');
if (href) {
link.href = href;
}
document.head.append(link);
links.push(link);
return link;
}
function selectTestStyleLink() {
return document.querySelector('.testcase--link')! as HTMLLinkElement;
}
function createCorsLink(content: string) {
const url = getCSSEchoURL(content);
stubBackgroundFetchResponse(url, content);
return createStyleLink(url);
}
async function waitForLinkLoading(link: HTMLLinkElement) {
return new Promise((resolve, reject) => {
link.addEventListener('load', resolve, {once: true});
link.addEventListener('error', reject, {once: true});
});
}
beforeEach(() => {
container = document.body;
container.innerHTML = '';
stubChromeRuntimeMessage();
});
afterEach(() => {
removeDynamicTheme();
container.innerHTML = '';
links.forEach((l) => l.remove());
links.splice(0);
resetChromeRuntimeMessageStub();
});
describe('LINK STYLES', () => {
it('should override same-origin link', async () => {
createStyleLink(`data:text/css;utf8,${encodeURIComponent(multiline(
'h1 { background: gray; }',
'h1 strong { color: red; }',
))}`);
container.innerHTML = multiline(
'<h1>Link <strong>override</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
await timeout(50);
expect(getComputedStyle(container.querySelector('h1')!).backgroundColor).toBe('rgb(102, 102, 102)');
expect(getComputedStyle(container.querySelector('h1')!).color).toBe('rgb(255, 255, 255)');
expect(getComputedStyle(container.querySelector('h1 strong')!).color).toBe('rgb(255, 26, 26)');
});
it('should override cross-origin link', async () => {
createCorsLink(multiline(
'h1 { background: gray; }',
'h1 strong { color: red; }',
));
container.innerHTML = multiline(
'<h1><strong>Cross-origin</strong> link override</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
await waitForEvent('__darkreader__test__dynamicUpdateComplete');
expect(getComputedStyle(container.querySelector('h1')!).backgroundColor).toBe('rgb(102, 102, 102)');
expect(getComputedStyle(container.querySelector('h1')!).color).toBe('rgb(255, 255, 255)');
expect(getComputedStyle(container.querySelector('h1 strong')!).color).toBe('rgb(255, 26, 26)');
});
it('should override cross-origin imports in linked CSS', async () => {
const importedCSS = 'h1 { background: gray; }';
const importedURL = getCSSEchoURL(importedCSS);
stubBackgroundFetchResponse(importedURL, importedCSS);
createCorsLink(multiline(
`@import "${importedURL}";`,
'h1 strong { color: red; }',
));
container.innerHTML = multiline(
'<h1><strong>Cross-origin import</strong> link override</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
await waitForEvent('__darkreader__test__dynamicUpdateComplete');
expect(getComputedStyle(container.querySelector('h1')!).backgroundColor).toBe('rgb(102, 102, 102)');
expect(getComputedStyle(container.querySelector('h1')!).color).toBe('rgb(255, 255, 255)');
expect(getComputedStyle(container.querySelector('h1 strong')!).color).toBe('rgb(255, 26, 26)');
});
it('should override cross-origin imports in linked CSS with capital @import', async () => {
const importedCSS = 'h1 { background: gray; }';
const importedURL = getCSSEchoURL(importedCSS);
stubBackgroundFetchResponse(importedURL, importedCSS);
createCorsLink(multiline(
`@IMPORT "${importedURL}";`,
'h1 strong { color: red; }',
));
container.innerHTML = multiline(
'<h1><strong>Cross-origin import</strong> link override</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
await waitForEvent('__darkreader__test__dynamicUpdateComplete');
expect(getComputedStyle(container.querySelector('h1')!).backgroundColor).toBe('rgb(102, 102, 102)');
expect(getComputedStyle(container.querySelector('h1')!).color).toBe('rgb(255, 255, 255)');
expect(getComputedStyle(container.querySelector('h1 strong')!).color).toBe('rgb(255, 26, 26)');
});
it('should override cross-origin link that has already been loaded', async () => {
createCorsLink(multiline(
'h1 { background: gray; }',
'h1 strong { color: red; }',
));
container.innerHTML = multiline(
'<h1>Loaded <strong>cross-origin</strong> link override</h1>',
);
await waitForLinkLoading(selectTestStyleLink());
expect(getComputedStyle(container.querySelector('h1')!).backgroundColor).toBe('rgb(128, 128, 128)');
expect(getComputedStyle(container.querySelector('h1')!).color).toBe('rgb(0, 0, 0)');
expect(getComputedStyle(container.querySelector('h1 strong')!).color).toBe('rgb(255, 0, 0)');
createOrUpdateDynamicTheme(theme, null, false);
await waitForEvent('__darkreader__test__dynamicUpdateComplete');
expect(getComputedStyle(container.querySelector('h1')!).backgroundColor).toBe('rgb(102, 102, 102)');
expect(getComputedStyle(container.querySelector('h1')!).color).toBe('rgb(255, 255, 255)');
expect(getComputedStyle(container.querySelector('h1 strong')!).color).toBe('rgb(255, 26, 26)');
});
it('should remove manager from disabled link', async () => {
const link = createStyleLink(`data:text/css;utf8,${encodeURIComponent(multiline(
'h1 { background: gray; }',
'h1 strong { color: red; }',
))}`);
container.innerHTML = multiline(
'<h1>Link <strong>disabled</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
await timeout(50);
expect(selectTestStyleLink().nextElementSibling!.classList.contains('darkreader--sync')).toBe(true);
link.disabled = true;
await timeout(0);
expect(selectTestStyleLink().nextElementSibling!.classList.contains('darkreader--sync')).toBe(false);
});
it("Shouldn't wait on link that won't be loaded", async () => {
const link = createStyleLink(null);
link.setAttribute('data-href', `data:text/css;utf8,${encodeURIComponent(multiline(
'h1 { background: green !important; }',
'h1 strong { color: orange !important; }',
))}`);
container.innerHTML = multiline(
'<style>',
' h1 { background: gray; }',
'</style>',
'<h1>Link <strong>loading with non-href attribute</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
await timeout(50);
const h1 = document.querySelector('h1')!;
expect(getComputedStyle(h1).backgroundColor).toBe('rgb(102, 102, 102)');
expect(document.querySelector('.darkreader--fallback')!.textContent).toBe('');
});
it('should handle styles with @import "..." screen;', async () => {
const importedCSS = 'h1 { background: gray; }';
const importedURL = getCSSEchoURL(importedCSS);
stubBackgroundFetchResponse(importedURL, importedCSS);
createCorsLink(multiline(
`@import "${importedURL}" screen;`,
'h1 strong { color: red; }',
));
container.innerHTML = multiline(
'<h1><strong>Cross-origin import</strong> link override</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
await waitForEvent('__darkreader__test__dynamicUpdateComplete');
expect(getComputedStyle(container.querySelector('h1')!).backgroundColor).toBe('rgb(102, 102, 102)');
expect(getComputedStyle(container.querySelector('h1')!).color).toBe('rgb(255, 255, 255)');
expect(getComputedStyle(container.querySelector('h1 strong')!).color).toBe('rgb(255, 26, 26)');
});
it('should handle styles with invalid url(...)', async () => {
const importedCSS = 'h1 { background-image: url("freecookies:3https://example.com"); background-color: gray; }';
const importedURL = getCSSEchoURL(importedCSS);
stubBackgroundFetchResponse(importedURL, importedCSS);
stubBackgroundFetchResponse('freecookies:3https://example.com', '');
createCorsLink(multiline(
`@import "${importedURL}" screen;`,
'h1 strong { color: red; }',
));
container.innerHTML = multiline(
'<h1><strong>Cross-origin import</strong> link override</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
await waitForEvent('__darkreader__test__dynamicUpdateComplete');
await timeout(1000);
expect(getComputedStyle(container.querySelector('h1')!).backgroundColor).toBe('rgb(102, 102, 102)');
expect(getComputedStyle(container.querySelector('h1')!).color).toBe('rgb(255, 255, 255)');
expect(getComputedStyle(container.querySelector('h1 strong')!).color).toBe('rgb(255, 26, 26)');
});
});
@@ -0,0 +1,188 @@
import '../support/polyfills';
import {DEFAULT_THEME} from '../../../src/defaults';
import {createOrUpdateDynamicTheme, removeDynamicTheme} from '../../../src/inject/dynamic-theme';
import {multiline, timeout} from '../support/test-utils';
const theme = {
...DEFAULT_THEME,
darkSchemeBackgroundColor: 'black',
darkSchemeTextColor: 'white',
};
let container: HTMLElement;
beforeEach(() => {
container = document.body;
container.innerHTML = '';
});
afterEach(() => {
removeDynamicTheme();
container.innerHTML = '';
});
describe('MEDIA QUERIES', () => {
it('should not style blacklisted media', async () => {
container.innerHTML = multiline(
'<style class="testcase-style">',
' h1 { background: green; }',
' h1 strong { color: orange; }',
'</style>',
'<style class="testcase-style-2" media="print">',
' h1 { background: gray; }',
' h1 strong { color: red; }',
'</style>',
'<h1>Some test foor...... <strong>Oh uhm removing styles :(</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
expect(getComputedStyle(document.querySelector('h1')!).backgroundColor).toBe('rgb(0, 102, 0)');
expect(getComputedStyle(document.querySelector('h1 strong')!).color).toBe('rgb(255, 174, 26)');
expect(document.querySelector('.testcase-style-2')!.nextElementSibling!.classList.contains('darkreader--sync')).toBe(false);
});
it('should style lazyloaded media', async () => {
container.innerHTML = multiline(
'<style class="testcase-style" media="print">',
' h1 { background: green; }',
' h1 strong { color: orange; }',
'</style>',
'<h1>Some test foor...... <strong>Oh uhm removing styles :(</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
(document.querySelector('.testcase-style') as HTMLStyleElement).media = 'screen';
await timeout(0);
expect((document.querySelector('.testcase-style') as HTMLStyleElement).media).toBe('screen');
expect(getComputedStyle(document.querySelector('h1')!).backgroundColor).toBe('rgb(0, 102, 0)');
expect(getComputedStyle(document.querySelector('h1 strong')!).color).toBe('rgb(255, 174, 26)');
expect(document.querySelector('.testcase-style')!.nextElementSibling!.classList.contains('darkreader--sync')).toBe(true);
});
it('should check for CSS support', async () => {
container.innerHTML = multiline(
'<style class="testcase-style">',
' @supports (background: green) {',
' h1 { background: green; }',
' }',
' @supports (color: orange) {',
' h1 strong { color: orange; }',
' }',
' @supports (some-non-existing-prop: some-value) {',
' body { background: pink; }',
' }',
'</style>',
'<h1>Some test foor...... <strong>Oh uhm removing styles :(</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
expect(getComputedStyle(document.querySelector('h1')!).backgroundColor).toBe('rgb(0, 102, 0)');
expect(getComputedStyle(document.querySelector('h1 strong')!).color).toBe('rgb(255, 174, 26)');
expect(getComputedStyle(document.body).backgroundColor).toBe('rgb(0, 0, 0)');
});
it('should check for CSS @media', async () => {
container.innerHTML = multiline(
'<style class="testcase-style">',
' @media screen and (min-width: 2px) {',
' h1 { background: green; }',
' }',
' @media screen and (min-width: 200000px) {',
' h1 { background: orange; }',
' }',
'</style>',
'<h1>Some test media query i guess</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
expect(getComputedStyle(document.querySelector('h1')!).backgroundColor).toBe('rgb(0, 102, 0)');
expect((document.querySelector('.testcase-style')!.nextElementSibling as HTMLStyleElement).sheet!.cssRules.length).toBe(2);
});
it('should check for nested CSS @media', async () => {
container.innerHTML = multiline(
'<style class="testcase-style">',
' @media screen and (min-width: 2px) {',
' @media screen and (min-width: 2px) {',
' h1 { background: green; }',
' }',
' }',
' @media screen and (min-width: 200000px) {',
' h1 { background: orange; }',
' }',
'</style>',
'<h1>Some test media query i guess</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
expect(getComputedStyle(document.querySelector('h1')!).backgroundColor).toBe('rgb(0, 102, 0)');
expect((document.querySelector('.testcase-style')!.nextElementSibling as HTMLStyleElement).sheet!.cssRules.length).toBe(2);
});
it('should style print/media query', () => {
container.innerHTML = multiline(
'<style class="testcase-style">',
' h1 { background: green; }',
' h1 strong { color: orange; }',
'</style>',
'<style class="testcase-style-2" media="print, screen and (max-width: 9999999px)">',
' h1 { background: gray; }',
' h1 strong { color: red; }',
'</style>',
'<h1>Some test foor...... <strong>Oh uh media query :D</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
expect(getComputedStyle(document.querySelector('h1')!).backgroundColor).toBe('rgb(102, 102, 102)');
expect(getComputedStyle(document.querySelector('h1 strong')!).color).toBe('rgb(255, 26, 26)');
expect(document.querySelector('.testcase-style-2')!.nextElementSibling!.classList.contains('darkreader--sync')).toBe(true);
});
it('should handle same cssText but different media rule', () => {
container.innerHTML = multiline(
'<style class="testcase-style">',
' @media screen and (min-width: 200000000000000000000px) {',
' h1 { background: green; }',
' }',
' @media screen and (min-width: 4px) {',
' h1 { background: green; }',
' }',
'</style>',
'<h1>Some test foor...... <strong>Oh uhm removing styles :(</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
expect(getComputedStyle(document.querySelector('h1')!).backgroundColor).toBe('rgb(0, 102, 0)');
});
it('should not style blacklisted media and handle uppercase media', () => {
container.innerHTML = multiline(
'<style class="testcase-style">',
' h1 { background: green; }',
' h1 strong { color: orange; }',
'</style>',
'<style class="testcase-style-2" media="Print">',
' h1 { background: gray; }',
' h1 strong { color: red; }',
'</style>',
'<h1>Some test foor...... <strong>Oh uhm removing styles :(</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
expect(getComputedStyle(document.querySelector('h1')!).backgroundColor).toBe('rgb(0, 102, 0)');
expect(getComputedStyle(document.querySelector('h1 strong')!).color).toBe('rgb(255, 174, 26)');
expect(document.querySelector('.testcase-style-2')!.nextElementSibling!.classList.contains('darkreader--sync')).toBe(false);
});
it('should handle media query and print', () => {
container.innerHTML = multiline(
'<style class="testcase-style">',
' @media (min-width: 2px), print {',
' h1 { background: green; }',
' }',
'</style>',
'<h1>Some test foor...... <strong>Oh uhm removing styles :(</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
expect(getComputedStyle(document.querySelector('h1')!).backgroundColor).toBe('rgb(0, 102, 0)');
});
});
@@ -0,0 +1,178 @@
import '../support/polyfills';
import {DEFAULT_THEME} from '../../../src/defaults';
import {createOrUpdateDynamicTheme, removeDynamicTheme} from '../../../src/inject/dynamic-theme';
import {isFirefox, isSafari} from '../../../src/utils/platform';
import {multiline, timeout} from '../support/test-utils';
const theme = {
...DEFAULT_THEME,
darkSchemeBackgroundColor: 'black',
darkSchemeTextColor: 'white',
};
let container: HTMLElement;
beforeEach(() => {
container = document.body;
container.innerHTML = '';
});
afterEach(() => {
removeDynamicTheme();
container.innerHTML = '';
});
describe('SHADOW DOM', () => {
it('should add static overrides', async () => {
container.innerHTML = multiline(
'<div class="shadow-dom-wrapper"></div>',
);
document.querySelector('.shadow-dom-wrapper')!.attachShadow({mode: 'open'});
createOrUpdateDynamicTheme(theme, null, false);
const shadowRoot = document.querySelector('.shadow-dom-wrapper')!.shadowRoot!;
expect(shadowRoot.firstElementChild!.classList.contains('darkreader--inline')).toBe(true);
expect(shadowRoot.firstElementChild!.nextElementSibling!.classList.contains('darkreader--override')).toBe(true);
expect(shadowRoot.firstElementChild!.nextElementSibling!.nextElementSibling!.classList.contains('darkreader--invert')).toBe(true);
});
it('should override styles', async () => {
container.innerHTML = multiline(
'<div class="shadow-dom-wrapper"></div>',
);
const shadow = document.querySelector('.shadow-dom-wrapper')!.attachShadow({mode: 'open'});
const style = document.createElement('style');
style.classList.add('test-case-style');
shadow.appendChild(style);
style.sheet!.insertRule('h1 { color: gray }');
style.sheet!.insertRule('strong { color: red }');
createOrUpdateDynamicTheme(theme, null, false);
const shadowRoot = document.querySelector('.shadow-dom-wrapper')!.shadowRoot!;
const testCase = shadowRoot.querySelector('.test-case-style')!;
expect(testCase.nextElementSibling!.classList.contains('darkreader--sync')).toBe(true);
expect((testCase.nextElementSibling as HTMLStyleElement).sheet!.cssRules.length).toBe(2);
});
it('should react to DOM changes', async () => {
container.innerHTML = multiline(
'<div class="shadow-dom-wrapper"></div>',
);
const shadow = document.querySelector('.shadow-dom-wrapper')!.attachShadow({mode: 'open'});
createOrUpdateDynamicTheme(theme, null, false);
const style = document.createElement('style');
style.classList.add('test-case-style');
shadow.appendChild(style);
style.sheet!.insertRule('h1 { color: gray }');
style.sheet!.insertRule('strong { color: red }');
await timeout(0);
const shadowRoot = document.querySelector('.shadow-dom-wrapper')!.shadowRoot!;
const testCase = shadowRoot.querySelector('.test-case-style')!;
expect(shadowRoot.firstElementChild!.classList.contains('darkreader--inline')).toBe(true);
expect(shadowRoot.firstElementChild!.nextElementSibling!.classList.contains('darkreader--override')).toBe(true);
expect(testCase.nextElementSibling!.classList.contains('darkreader--sync')).toBe(true);
expect((testCase.nextElementSibling as HTMLStyleElement).sheet!.cssRules.length).toBe(2);
});
it('should override inline styles', async () => {
container.innerHTML = multiline(
'<div class="shadow-dom-wrapper"></div>',
);
const shadow = document.querySelector('.shadow-dom-wrapper')!.attachShadow({mode: 'open'});
createOrUpdateDynamicTheme(theme, null, false);
const standardElement = document.createElement('p');
standardElement.style.color = 'red';
shadow.appendChild(standardElement);
await timeout(0);
const shadowRoot = document.querySelector('.shadow-dom-wrapper')!.shadowRoot!;
expect(getComputedStyle(shadowRoot.querySelector('p')!).color).toBe('rgb(255, 26, 26)');
});
it('should handle defined custom elements', async () => {
container.innerHTML = multiline(
'<custom-element>',
'</custom-element>',
);
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);
createOrUpdateDynamicTheme(theme, null, false);
const shadowRoot = document.querySelector('custom-element')!.shadowRoot!;
expect(getComputedStyle(shadowRoot.querySelector('p')!).color).toBe('rgb(255, 198, 208)');
});
it('should react to defined custom elements', async () => {
container.innerHTML = multiline(
'<delayed-custom-element>',
'</delayed-custom-element>',
);
class DelayedCustomElement 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);
}
}
createOrUpdateDynamicTheme(theme, null, false);
customElements.define('delayed-custom-element', DelayedCustomElement);
await timeout(0);
const shadowRoot = document.querySelector('delayed-custom-element')!.shadowRoot!;
expect(getComputedStyle(shadowRoot.querySelector('p')!).color).toBe('rgb(255, 198, 208)');
});
it('should override styles', async () => {
// Firefox by default doesn't enable the CSSStyleSheet constructor.
if (isFirefox || isSafari) {
return;
}
container.innerHTML = multiline(
'<div class="shadow-dom-wrapper"></div>',
);
const newRule = new CSSStyleSheet();
newRule.insertRule(':host { --red: red }');
const shadow = document.querySelector('.shadow-dom-wrapper')!.attachShadow({mode: 'open'});
shadow.adoptedStyleSheets = [newRule];
const style = document.createElement('style');
style.classList.add('test-case-style');
shadow.appendChild(style);
style.sheet!.insertRule('h1 { color: var(--red) }');
const h1 = document.createElement('h1');
shadow.appendChild(h1);
createOrUpdateDynamicTheme(theme, null, false);
const shadowRoot = document.querySelector('.shadow-dom-wrapper')!.shadowRoot!;
const testCase = shadowRoot.querySelector('.test-case-style')!;
const darkendH1 = shadowRoot.querySelector('h1')!;
expect(testCase.nextElementSibling!.classList.contains('darkreader--sync')).toBe(true);
expect((testCase.nextElementSibling as HTMLStyleElement).sheet!.cssRules.length).toBe(1);
expect(shadowRoot.adoptedStyleSheets.length).toBe(2);
expect(getComputedStyle(darkendH1).color).toBe('rgb(255, 26, 26)');
});
});
@@ -0,0 +1,248 @@
import '../support/polyfills';
import {DEFAULT_THEME} from '../../../src/defaults';
import {createOrUpdateDynamicTheme, removeDynamicTheme} from '../../../src/inject/dynamic-theme';
import {createStyleSheetModifier} from '../../../src/inject/dynamic-theme/stylesheet-modifier';
import {multiline, timeout} from '../support/test-utils';
const theme = {
...DEFAULT_THEME,
darkSchemeBackgroundColor: 'black',
darkSchemeTextColor: 'white',
};
let container: HTMLElement;
beforeEach(() => {
container = document.body;
container.innerHTML = '';
});
afterEach(() => {
removeDynamicTheme();
container.innerHTML = '';
});
describe('STYLE ELEMENTS', () => {
it('should fill CSSStyleSheet with overridden rules', () => {
const style = document.createElement('style');
style.textContent = 'body { background-color: white; } h1 { color: black; }';
container.append(style);
const modifier = createStyleSheetModifier();
const overrideStyle = document.createElement('style');
container.append(overrideStyle);
const override = overrideStyle.sheet!;
modifier.modifySheet({
theme,
sourceCSSRules: style.sheet!.cssRules,
ignoreImageAnalysis: [],
force: false,
prepareSheet: () => override,
isAsyncCancelled: () => false,
});
expect(override.cssRules.length).toBe(2);
expect((override.cssRules[0] as CSSStyleRule).selectorText).toBe('body');
expect((override.cssRules[0] as CSSStyleRule).style.getPropertyValue('background-color')).toBe('var(--darkreader-background-ffffff, #000000)');
expect((override.cssRules[1] as CSSStyleRule).selectorText).toBe('h1');
expect((override.cssRules[1] as CSSStyleRule).style.getPropertyValue('color')).toBe('var(--darkreader-text-000000, #ffffff)');
});
it('should override User Agent style', async () => {
container.innerHTML = multiline(
'<span>Text</span>',
'<a href="#">Link</a>',
);
createOrUpdateDynamicTheme(theme, null, false);
expect(getComputedStyle(container).backgroundColor).toBe('rgb(0, 0, 0)');
expect(getComputedStyle(container).color).toBe('rgb(255, 255, 255)');
expect(getComputedStyle(container.querySelector('span')!).color).toBe('rgb(255, 255, 255)');
expect(getComputedStyle(container.querySelector('a')!).color).toBe('rgb(51, 145, 255)');
});
it('should override static style', async () => {
container.innerHTML = multiline(
'<style>',
' h1 { background: gray; }',
' h1 strong { color: red; }',
'</style>',
'<h1>Style <strong>override</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
expect(getComputedStyle(container).backgroundColor).toBe('rgb(0, 0, 0)');
expect(getComputedStyle(container.querySelector('h1')!).backgroundColor).toBe('rgb(102, 102, 102)');
expect(getComputedStyle(container.querySelector('h1')!).color).toBe('rgb(255, 255, 255)');
expect(getComputedStyle(container.querySelector('h1 strong')!).color).toBe('rgb(255, 26, 26)');
});
it('should override style with @import', async () => {
container.innerHTML = multiline(
'<style>',
` @import "data:text/css;utf8,${encodeURIComponent('h1 { background: gray; }')}";`,
' h1 strong { color: red; }',
'</style>',
'<h1>Style <strong>with @import</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
await timeout(50);
expect(getComputedStyle(container).backgroundColor).toBe('rgb(0, 0, 0)');
expect(getComputedStyle(container.querySelector('h1')!).backgroundColor).toBe('rgb(102, 102, 102)');
expect(getComputedStyle(container.querySelector('h1')!).color).toBe('rgb(255, 255, 255)');
expect(getComputedStyle(container.querySelector('h1 strong')!).color).toBe('rgb(255, 26, 26)');
});
it('should override style with @import"..."', async () => {
container.innerHTML = multiline(
'<style>',
` @import"data:text/css;utf8,${encodeURIComponent('h1 { background: gray; }')}";`,
' h1 strong { color: red; }',
'</style>',
'<h1>Style <strong>with @import"..."</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
await timeout(50);
expect(getComputedStyle(container).backgroundColor).toBe('rgb(0, 0, 0)');
expect(getComputedStyle(container.querySelector('h1')!).backgroundColor).toBe('rgb(102, 102, 102)');
expect(getComputedStyle(container.querySelector('h1')!).color).toBe('rgb(255, 255, 255)');
expect(getComputedStyle(container.querySelector('h1 strong')!).color).toBe('rgb(255, 26, 26)');
});
it('should restore override', async () => {
container.innerHTML = multiline(
'<style class="testcase-style">',
' h1 { color: gray; }',
' h1 strong { color: red; }',
'</style>',
'<h1>Style <strong>override</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
expect(getComputedStyle(container.querySelector('h1')!).color).toBe('rgb(141, 141, 141)');
expect(getComputedStyle(container.querySelector('h1 strong')!).color).toBe('rgb(255, 26, 26)');
const style = document.querySelector('.testcase-style')!;
style.nextSibling!.remove();
await timeout(0);
expect((style.nextSibling as HTMLStyleElement).classList.contains('darkreader--sync')).toBe(true);
expect(getComputedStyle(container.querySelector('h1')!).color).toBe('rgb(141, 141, 141)');
expect(getComputedStyle(container.querySelector('h1 strong')!).color).toBe('rgb(255, 26, 26)');
});
it('should move override', async () => {
container.innerHTML = multiline(
'<style class="testcase-style">',
' h1 { background: gray; }',
' h1 strong { color: red; }',
'</style>',
'<h1>Some test foor...... <strong>Moving styles</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
expect(getComputedStyle(container.querySelector('h1')!).backgroundColor).toBe('rgb(102, 102, 102)');
expect(getComputedStyle(container.querySelector('h1 strong')!).color).toBe('rgb(255, 26, 26)');
const style = document.querySelector('.testcase-style')!;
container.append(style);
await timeout(0);
expect((style.nextSibling as HTMLStyleElement).classList.contains('darkreader--sync')).toBe(true);
expect(getComputedStyle(container.querySelector('h1')!).backgroundColor).toBe('rgb(102, 102, 102)');
expect(getComputedStyle(container.querySelector('h1 strong')!).color).toBe('rgb(255, 26, 26)');
});
it('should remove override', async () => {
container.innerHTML = multiline(
'<style class="testcase-style">',
' h1 { background: gray; }',
' h1 strong { color: red; }',
'</style>',
'<h1>Some test foor...... <strong>Oh uhm removing styles :(</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
const style = document.querySelector('.testcase-style')!;
const sibling = style.nextSibling!;
style.remove();
await timeout(0);
expect(sibling.isConnected).toBe(false);
expect(document.querySelector('.darkreader--sync')).toBe(null);
expect(getComputedStyle(container.querySelector('h1')!).color).toBe('rgb(255, 255, 255)');
expect(getComputedStyle(container.querySelector('h1 strong')!).color).toBe('rgb(255, 255, 255)');
});
it('should react to updated style', async () => {
container.innerHTML = multiline(
'<style class="testcase-style"></style>',
'<h1>Some test foor...... <strong>Oh uhm a pink background</strong></h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
const style: HTMLStyleElement = document.querySelector('.testcase-style')!;
style.sheet!.insertRule('h1 { color: gray }');
style.sheet!.insertRule('strong { color: red }');
style.sheet!.insertRule('body { background-color: pink }');
await timeout(0);
expect((style.nextSibling as HTMLStyleElement).sheet!.cssRules[0].cssText).toBe('body { background-color: var(--darkreader-background-ffc0cb, #320009); }');
expect(getComputedStyle(container.querySelector('h1')!).color).toBe('rgb(141, 141, 141)');
expect(getComputedStyle(container.querySelector('h1 strong')!).color).toBe('rgb(255, 26, 26)');
expect(getComputedStyle(document.body).backgroundColor).toBe('rgb(50, 0, 9)');
});
it('should react on style text change', async () => {
container.innerHTML = multiline(
'<style class="testcase-style">',
' h1 strong { color: red; }',
'</style>',
'<h1>Style <strong>text change</strong></h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
expect(getComputedStyle(document.querySelector('h1 strong')!).color).toBe('rgb(255, 26, 26)');
document.querySelector('.testcase-style')!.textContent = 'h1 strong { color: green; }';
await timeout(0);
expect(getComputedStyle(document.querySelector('h1 strong')!).color).toBe('rgb(140, 255, 140)');
});
it('should react to a new style', async () => {
container.innerHTML = multiline(
'<h1>Some test foor...... <strong>Oh uhm what?</strong>!</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
const style: HTMLStyleElement = document.createElement('style');
style.classList.add('testcase-style');
container.append(style);
style.sheet!.insertRule('h1 { color: pink }');
style.sheet!.insertRule('strong { color: orange }');
await timeout(0);
const newStyle: HTMLStyleElement = document.querySelector('.testcase-style')!;
const nextSibling = newStyle.nextSibling as HTMLStyleElement;
expect(nextSibling.sheet!.cssRules.length).toBe(2);
expect(nextSibling.classList.contains('darkreader--sync')).toBe(true);
expect(getComputedStyle(document.querySelector('h1')!).color).toBe('rgb(255, 198, 208)');
expect(getComputedStyle(document.querySelector('h1 strong')!).color).toBe('rgb(255, 174, 26)');
});
it('should restore override after container move', async () => {
container.innerHTML = multiline(
'<div class="style-container">',
' <style class="testcase-style">',
' h1 { background: gray; }',
' h1 { color: green; }',
' </style>',
'</div>',
'<h1>Moving container</h1>',
);
createOrUpdateDynamicTheme(theme, null, false);
expect(getComputedStyle(container.querySelector('h1')!).backgroundColor).toBe('rgb(102, 102, 102)');
expect(getComputedStyle(container.querySelector('h1')!).color).toBe('rgb(140, 255, 140)');
const styleContainer = document.querySelector('.style-container')!;
const style = styleContainer.querySelector('.testcase-style')!;
const override = style.nextElementSibling as HTMLStyleElement;
container.append(styleContainer);
await timeout(0);
expect(style.nextElementSibling).toBe(override);
expect(override.sheet!.cssRules.length).toBe(2);
expect(getComputedStyle(container.querySelector('h1')!).backgroundColor).toBe('rgb(102, 102, 102)');
expect(getComputedStyle(container.querySelector('h1')!).color).toBe('rgb(140, 255, 140)');
});
});
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
/**
* This file is necessary because Karma does not support configutation
* via ES modules. This file is a CommonJS module which wraps a regular
* ES module.
*/
'use strict';
const realConfigureKarma = import('./karma.conf.js');
async function configureKarma(config, env) {
return ((await realConfigureKarma).configureKarma)(config, env);
}
/**
* @param {LocalConfig} config
* @returns {void}
*/
module.exports = async (config) =>
config.set(await configureKarma(config, process.env));
if (process.env.NODE_ENV === 'test') {
module.exports.configureKarma = async (config, env) => configureKarma(config, env);
}
+144
View File
@@ -0,0 +1,144 @@
/** @typedef {import('karma').Config & {headless: boolean, debug: boolean, ci: boolean, coverage: boolean}} LocalConfig */
/** @typedef {import('karma').ConfigOptions} ConfigOptions */
import fs from 'node:fs';
import os from 'node:os';
import rollupPluginReplace from '@rollup/plugin-replace';
import rollupPluginTypescript from '@rollup/plugin-typescript';
import rollupPluginIstanbul from 'rollup-plugin-istanbul';
import typescript from 'typescript';
import {absolutePath} from '../../tasks/paths.js';
import {createEchoServer} from './support/echo-server.js';
/**
* @param {Partial<LocalConfig>} config
* @param {Record<string, string>} env
* @returns {ConfigOptions}
*/
export function configureKarma(config, env) {
const headless = config.headless || Boolean(env.KARMA_HEADLESS) || false;
/** @type {ConfigOptions} */
const options = {
failOnFailingTestSuite: true,
failOnEmptyTestSuite: true,
basePath: '../..',
frameworks: ['jasmine'],
files: [
'tests/inject/support/customize.ts',
'tests/inject/support/polyfills.ts',
{pattern: 'tests/inject/**/*.tests.ts', watched: false},
],
plugins: [
'karma-chrome-launcher',
'karma-firefox-launcher',
process.platform === 'darwin' ? 'karma-safari-launcher' : null,
'karma-rollup-preprocessor',
'karma-jasmine',
'karma-spec-reporter',
].filter(Boolean),
preprocessors: {
'**/*.+(ts|tsx)': ['rollup'],
},
rollupPreprocessor: {
plugins: [
rollupPluginTypescript({
rootDir: absolutePath('.'),
typescript,
outDir: 'build/tests',
tsconfig: absolutePath('tests/inject/tsconfig.json'),
cacheDir: `${fs.realpathSync(os.tmpdir())}/darkreader_typescript_test_cache`,
}),
rollupPluginReplace({
preventAssignment: true,
__DEBUG__: false,
__FIREFOX_MV2__: false,
__CHROMIUM_MV2__: false,
__CHROMIUM_MV3__: false,
__THUNDERBIRD__: false,
__PLUS__: false,
__PORT__: '-1',
__TEST__: true,
__WATCH__: false,
}),
],
output: {
dir: 'build/tests',
strict: true,
format: 'iife',
sourcemap: 'inline',
},
},
reporters: ['spec'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: headless
? ['ChromeHeadless', 'FirefoxHeadless']
: ['Chrome', 'Firefox', process.platform === 'darwin' ? 'Safari' : null].filter(Boolean),
singleRun: true,
concurrency: 1,
};
if (config.debug) {
options.browsers = ['Chrome'];
options.singleRun = false;
options.concurrency = Infinity;
options.logLevel = config.LOG_DEBUG;
}
if (config.ci) {
options.customLaunchers = {};
options.browsers = [];
// CHROME_TEST and FIREFOX_TEST are used in CI
const chrome = env.CHROME_TEST;
const firefox = env.FIREFOX_TEST;
const all = !chrome && !firefox;
// Chrome
if (chrome || all) {
options.customLaunchers['CIChromeHeadless'] = {
base: 'ChromeHeadless',
flags: ['--no-sandbox', '--disable-setuid-sandbox'],
};
options.browsers.push('CIChromeHeadless');
}
// Firefox
if (firefox || all) {
options.customLaunchers['CIFirefoxHeadless'] = {
base: 'FirefoxHeadless',
};
options.browsers.push('CIFirefoxHeadless');
}
options.autoWatch = false;
options.singleRun = true;
options.concurrency = 1;
options.logLevel = config.LOG_DEBUG;
}
if (config.coverage) {
options.plugins.push('karma-coverage');
const plugin = rollupPluginIstanbul({
exclude: ['tests/**/*.*', 'src/inject/dynamic-theme/stylesheet-proxy.ts'],
});
options.rollupPreprocessor.plugins.push(plugin);
options.reporters.push('coverage');
options.coverageReporter = {
type: 'html',
dir: 'tests/inject/coverage/',
};
}
// HACK: Create CORS server here
// Previously a separate Karma runner file was used
const corsServerPort = 9966;
createEchoServer(corsServerPort).then(() => console.log(`CORS echo server running on port ${corsServerPort}`));
return options;
}
@@ -0,0 +1,42 @@
import type {MessageBGtoCS, MessageCStoBG} from '../../../src/definitions';
import {MessageTypeBGtoCS, MessageTypeCStoBG} from '../../../src/utils/message';
let nativeSendMessage: typeof chrome.runtime.sendMessage;
const bgResponses = new Map<string, string>();
export function stubChromeRuntimeMessage(): void {
nativeSendMessage = chrome.runtime.sendMessage;
const listeners: Array<(message: MessageBGtoCS) => void> = (chrome.runtime.onMessage as any)['__listeners__'];
(chrome.runtime as any).sendMessage = (message: MessageCStoBG) => {
if (message.type === MessageTypeCStoBG.FETCH) {
const {id, data: {url}} = message;
setTimeout(() => {
listeners.forEach((listener) => {
if (!bgResponses.has(url)) {
throw new Error('Response is missing, use `stubBackgroundFetchResponse()`');
}
const data = bgResponses.get(url);
listener({type: MessageTypeBGtoCS.FETCH_RESPONSE, data, error: null, id});
});
});
}
};
}
export function resetChromeRuntimeMessageStub(): void {
chrome.runtime.sendMessage = nativeSendMessage;
bgResponses.clear();
}
export function stubBackgroundFetchResponse(url: string, content: string): void {
bgResponses.set(url, content);
}
const urlResponses = new Map<string, string>();
export function stubChromeRuntimeGetURL(path: string, url: string): void {
urlResponses.set(path, url);
(chrome.runtime as any).getURL = (path: string) => {
return urlResponses.get(path);
};
}
@@ -0,0 +1,13 @@
(() => {
if (window.top === window.self) {
return;
}
const topDoc = window.top!.document;
const style = topDoc.createElement('style');
style.textContent = [
'body { background-color: #222222; color: #dddddd; }',
'#banner { background-color: #226644; }',
'.executing { background-color: #662233; }',
].join('\n');
topDoc.head.append(style);
})();
@@ -0,0 +1,11 @@
export function getEchoURL(content: string, type = 'text/plain'): string {
return `http://localhost:9966/echo?${new URLSearchParams({type, content})}`;
}
export function getCSSEchoURL(content: string): string {
return getEchoURL(content, 'text/css');
}
export function getJSEchoURL(script: string): string {
return getEchoURL(script, 'application/javascript');
}
@@ -0,0 +1,72 @@
// @ts-check
import http from 'http';
export async function createEchoServer(/** @type {number} */port) {
/** @type {import('http').Server | null} */
let server;
/** @type {import('http').RequestListener} */
function handleRequest(req, res) {
const parsedURL = new URL(req.url ?? '', `http://${req.headers.host}`);
const pathName = parsedURL.pathname;
if (pathName !== '/echo') {
res.statusCode = 500;
res.end('The URL path must be /echo');
return;
}
const {searchParams} = parsedURL;
const content = searchParams.get('content');
if (!content) {
res.statusCode = 500;
res.end('Send content like /echo?type=text%2Fplain&content=XYZ');
return;
}
const contentType = searchParams.get('type') || 'text/plain';
res.statusCode = 200;
res.setHeader('Content-Type', contentType);
res.end(content, 'utf8');
}
/**
* @returns {Promise<void>}
*/
function start() {
return new Promise((resolve) => {
server = http
.createServer(handleRequest)
.listen(port, () => resolve());
});
}
/**
* @returns {Promise<void> | undefined}
*/
function close() {
if (!server) {
return;
}
return new Promise((resolve) => {
server?.close((err) => {
if (err) {
console.error(err);
}
server = null;
resolve();
});
});
}
process.on('exit', close);
process.on('SIGINT', close);
await start();
return {
close,
url: `http://localhost:${port}`,
};
}
@@ -0,0 +1,19 @@
if (!window.hasOwnProperty('chrome')) {
window.chrome = {} as any;
}
if (!chrome.hasOwnProperty('runtime')) {
chrome.runtime = {} as any;
}
if (!chrome.runtime.hasOwnProperty('onMessage')) {
type AnyFunction = () => void;
const listeners = new Set<AnyFunction>();
(chrome.runtime as any).onMessage = {
addListener: (listener: AnyFunction) => {
listeners.add(listener);
},
removeListener: (listener: AnyFunction) => {
listeners.delete(listener);
},
} as any;
(chrome.runtime.onMessage as any)['__listeners__'] = listeners;
}
@@ -0,0 +1,21 @@
// Loaded with HTML/DOM only
export function multiline(...lines: string[]): string {
if (lines.length < 1) {
return '\n';
}
if (lines[lines.length - 1] !== '') {
lines.push('');
}
return lines.join('\n');
}
export function timeout(delay: number): Promise<void> {
return new Promise<void>((resolve) => setTimeout(resolve, delay));
}
export function waitForEvent(eventName: string): Promise<void> {
return new Promise<void>((resolve) => {
document.addEventListener(eventName, () => resolve(), {once: true});
});
}
+38
View File
@@ -0,0 +1,38 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2019",
"module": "ES2015",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"types": [
"chrome",
"jasmine"
],
"allowJs": true,
"downlevelIteration": true,
"esModuleInterop": true,
"jsx": "react",
"jsxFactory": "m",
"moduleResolution": "Node",
"noEmit": true,
"noImplicitAny": true,
"removeComments": false,
"sourceMap": true,
"inlineSources": true,
"noEmitOnError": true,
"strictPropertyInitialization": false,
"paths": {
"@plus/*": ["../../src/stubs/*"],
"definitions": ["../../src/definitions"],
"utils/*": ["../../src/utils/*"]
},
"ignoreDeprecations": "6.0",
},
"exclude": [
"./coverage"
]
}
+395
View File
@@ -0,0 +1,395 @@
import type {UserSettings} from '../../../src/definitions';
import {isURLEnabled, isURLMatched, isPDF, getURLHostOrProtocol, getAbsoluteURL} from '../../../src/utils/url';
it('URL is enabled', () => {
function fillUserSettings(settings: Partial<UserSettings>): UserSettings {
return {
schemeVersion: 2,
enabled: false,
fetchNews: false,
theme: null,
presets: [],
customThemes: [],
disabledFor: [],
enabledFor: [],
enabledByDefault: true,
changeBrowserTheme: false,
syncSettings: false,
syncSitesFixes: false,
automation: null,
time: null,
location: null,
previewNewDesign: false,
enableForPDF: false,
enableForProtectedPages: false,
enableContextMenus: false,
detectDarkTheme: false,
...settings,
} as UserSettings;
}
// Not invert listed
expect(isURLEnabled(
'https://www.google.com/',
fillUserSettings({disabledFor: [], enabledFor: [], enabledByDefault: true}),
{isProtected: false, isInDarkList: false},
)).toBe(true);
expect(isURLEnabled(
'https://www.google.com/',
fillUserSettings({disabledFor: ['google.com'], enabledFor: [], enabledByDefault: true}),
{isProtected: false, isInDarkList: false},
)).toBe(false);
expect(isURLEnabled(
'https://mail.google.com/mail/u/0/',
fillUserSettings({disabledFor: ['google.com'], enabledFor: [], enabledByDefault: true}),
{isProtected: false, isInDarkList: false},
)).toBe(true);
expect(isURLEnabled(
'https://mail.google.com/mail/u/0/',
fillUserSettings({disabledFor: ['mail.google.com'], enabledFor: [], enabledByDefault: true}),
{isProtected: false, isInDarkList: false},
)).toBe(false);
expect(isURLEnabled(
'https://mail.google.com/mail/u/0/',
fillUserSettings({disabledFor: ['mail.google.*'], enabledFor: [], enabledByDefault: true}),
{isProtected: false, isInDarkList: false},
)).toBe(false);
expect(isURLEnabled(
'https://mail.google.com/mail/u/0/',
fillUserSettings({disabledFor: ['mail.google.*/mail'], enabledFor: [], enabledByDefault: true}),
{isProtected: false, isInDarkList: false},
)).toBe(false);
expect(isURLEnabled(
'https://mail.google.com/mail/u/0/',
fillUserSettings({disabledFor: [], enabledFor: [], enabledByDefault: true}),
{isProtected: false, isInDarkList: false},
)).toBe(true);
expect(isURLEnabled(
'https://mail.google.com/mail/u/0/',
fillUserSettings({disabledFor: ['google.com/maps'], enabledFor: [], enabledByDefault: true}),
{isProtected: false, isInDarkList: false},
)).toBe(true);
// Invert listed only
expect(isURLEnabled(
'https://mail.google.com/mail/u/0/',
fillUserSettings({disabledFor: [], enabledFor: ['google.com'], enabledByDefault: false}),
{isProtected: false, isInDarkList: false},
)).toBe(false);
expect(isURLEnabled(
'https://mail.google.com/mail/u/0/',
fillUserSettings({disabledFor: [], enabledFor: ['mail.google.*/mail'], enabledByDefault: false}),
{isProtected: false, isInDarkList: false},
)).toBe(true);
expect(isURLEnabled(
'https://mail.google.com/mail/u/0/',
fillUserSettings({disabledFor: [], enabledFor: [], enabledByDefault: false}),
{isProtected: false, isInDarkList: false},
)).toBe(false);
expect(isURLEnabled(
'https://mail.google.com/mail/u/0/',
fillUserSettings({disabledFor: [], enabledFor: ['*.google.com/maps'], enabledByDefault: false}),
{isProtected: false, isInDarkList: false},
)).toBe(false);
// Special URLs
expect(isURLEnabled(
'https://chrome.google.com/webstore',
fillUserSettings({disabledFor: ['chrome.google.com'], enabledFor: [], enabledByDefault: true, enableForProtectedPages: true}),
{isProtected: true, isInDarkList: false},
)).toBe(false);
expect(isURLEnabled(
'https://chrome.google.com/webstore',
fillUserSettings({disabledFor: [], enabledFor: ['chrome.google.com'], enabledByDefault: false, enableForProtectedPages: true}),
{isProtected: true, isInDarkList: false},
)).toBe(true);
expect(isURLEnabled(
'https://chrome.google.com/webstore',
fillUserSettings({disabledFor: [], enabledFor: [], enabledByDefault: true, enableForProtectedPages: false}),
{isProtected: true, isInDarkList: false},
)).toBe(false);
expect(isURLEnabled(
'https://chrome.google.com/webstore',
fillUserSettings({disabledFor: ['chrome.google.com'], enabledFor: [], enabledByDefault: true, enableForProtectedPages: true}),
{isProtected: true, isInDarkList: false},
)).toBe(false);
expect(isURLEnabled(
'https://microsoftedge.microsoft.com/addons',
fillUserSettings({disabledFor: ['microsoftedge.microsoft.com'], enabledFor: [], enabledByDefault: true, enableForProtectedPages: true}),
{isProtected: true, isInDarkList: false},
)).toBe(false);
expect(isURLEnabled(
'https://microsoftedge.microsoft.com/addons',
fillUserSettings({disabledFor: [], enabledFor: ['microsoftedge.microsoft.com'], enabledByDefault: false, enableForProtectedPages: true}),
{isProtected: true, isInDarkList: false},
)).toBe(true);
expect(isURLEnabled(
'https://duckduckgo.com',
fillUserSettings({disabledFor: [], enabledFor: [], enabledByDefault: true, enableForProtectedPages: true}),
{isProtected: false, isInDarkList: false},
)).toBe(true);
expect(isURLEnabled(
'https://darkreader.org/',
fillUserSettings({disabledFor: [], enabledFor: [], enabledByDefault: true}),
{isProtected: false, isInDarkList: true},
)).toBe(false);
expect(isURLEnabled(
'https://darkreader.org/',
fillUserSettings({disabledFor: [], enabledFor: ['darkreader.org'], enabledByDefault: false}),
{isProtected: false, isInDarkList: true},
)).toBe(true);
expect(isURLEnabled(
'https://www.google.com/file.pdf',
fillUserSettings({enableForPDF: true, disabledFor: [], enabledFor: [], enabledByDefault: true}),
{isProtected: false, isInDarkList: false},
)).toBe(true);
expect(isURLEnabled(
'https://www.google.com/file.pdf',
fillUserSettings({enableForPDF: true, disabledFor: [], enabledFor: [], enabledByDefault: false}),
{isProtected: false, isInDarkList: false},
)).toBe(true);
expect(isURLEnabled(
'https://www.google.com/file.pdf',
fillUserSettings({enableForPDF: false, disabledFor: [], enabledFor: [], enabledByDefault: true}),
{isProtected: false, isInDarkList: false},
)).toBe(false);
expect(isURLEnabled(
'https://www.google.com/file.pdf/resource',
fillUserSettings({enableForPDF: true, disabledFor: [], enabledFor: [], enabledByDefault: false}),
{isProtected: false, isInDarkList: false},
)).toBe(false);
expect(isURLEnabled(
'https://www.google.com/file.pdf/resource',
fillUserSettings({enableForPDF: true, disabledFor: [], enabledFor: [], enabledByDefault: true}),
{isProtected: false, isInDarkList: false},
)).toBe(true);
expect(isURLEnabled(
'https://www.google.com/very/good/hidden/folder/pdf#file.pdf',
fillUserSettings({enableForPDF: true, disabledFor: ['https://www.google.com/very/good/hidden/folder/pdf'], enabledFor: [], enabledByDefault: true}),
{isProtected: false, isInDarkList: false},
)).toBe(false);
expect(isURLEnabled(
'https://leetcode.com/problems/two-sum/',
fillUserSettings({enableForPDF: false, disabledFor: ['leetcode.com/problems/'], enabledFor: [], enabledByDefault: true}),
{isProtected: false, isInDarkList: false},
)).toBe(false);
expect(isURLEnabled(
'https://leetcode.com/problemset/all/',
fillUserSettings({enableForPDF: false, disabledFor: ['leetcode.com/problems/'], enabledFor: [], enabledByDefault: true}),
{isProtected: false, isInDarkList: false},
)).toBe(true);
// Dark theme detection
expect(isURLEnabled(
'https://github.com/',
fillUserSettings({disabledFor: [], enabledFor: [], enabledByDefault: true, detectDarkTheme: true}),
{isProtected: false, isInDarkList: false, isDarkThemeDetected: true},
)).toBe(false);
expect(isURLEnabled(
'https://github.com/',
fillUserSettings({disabledFor: [], enabledFor: [], enabledByDefault: true, detectDarkTheme: false}),
{isProtected: false, isInDarkList: false, isDarkThemeDetected: true},
)).toBe(true);
expect(isURLEnabled(
'https://github.com/',
fillUserSettings({disabledFor: [], enabledFor: [], enabledByDefault: true, detectDarkTheme: true}),
{isProtected: false, isInDarkList: false, isDarkThemeDetected: false},
)).toBe(true);
expect(isURLEnabled(
'https://github.com/',
fillUserSettings({disabledFor: [], enabledFor: ['github.com'], enabledByDefault: true, detectDarkTheme: true}),
{isProtected: false, isInDarkList: false, isDarkThemeDetected: true},
)).toBe(true);
// Test for PDF enabling
expect(isPDF(
'https://www.google.com/file.pdf'
)).toBe(true);
expect(isPDF(
'https://www.google.com/file.pdf?id=2'
)).toBe(true);
expect(isPDF(
'https://www.google.com/file.pdf/resource'
)).toBe(false);
expect(isPDF(
'https://www.google.com/resource?file=file.pdf'
)).toBe(false);
expect(isPDF(
'https://www.google.com/very/good/hidden/folder/pdf#file.pdf'
)).toBe(false);
expect(isPDF(
'https://fi.wikipedia.org/wiki/Tiedosto:ExtIPA_chart_(2015).pdf?uselang=en'
)).toBe(false);
expect(isPDF(
'https://commons.wikimedia.org/wiki/File:ExtIPA_chart_(2015).pdf'
)).toBe(false);
expect(isPDF(
'https://upload.wikimedia.org/wikipedia/commons/5/56/ExtIPA_chart_(2015).pdf'
)).toBe(true);
// IPV6 Testing
expect(isURLEnabled(
'https://[::1]:1337',
fillUserSettings({disabledFor: ['google.com'], enabledFor: [], enabledByDefault: true}),
{isProtected: false, isInDarkList: false},
)).toBe(true);
expect(isURLEnabled(
'https://[::1]:8080',
fillUserSettings({disabledFor: ['[::1]:8080'], enabledFor: [], enabledByDefault: true}),
{isProtected: false, isInDarkList: false},
)).toEqual(false);
expect(isURLEnabled(
'https://[::1]:8080',
fillUserSettings({disabledFor: ['[::1]:8081'], enabledFor: [], enabledByDefault: false}),
{isProtected: false, isInDarkList: false},
)).toEqual(false);
expect(isURLEnabled(
'https://[::1]:8080',
fillUserSettings({disabledFor: ['[::1]:8081'], enabledFor: [], enabledByDefault: true}),
{isProtected: false, isInDarkList: false},
)).toEqual(true);
expect(isURLEnabled(
'https://[::1]:17',
fillUserSettings({disabledFor: ['[::1]'], enabledFor: [], enabledByDefault: false}),
{isProtected: false, isInDarkList: false},
)).toEqual(false);
expect(isURLEnabled(
'https://[2001:4860:4860::8888]',
fillUserSettings({disabledFor: [], enabledFor: ['[2001:4860:4860::8888]'], enabledByDefault: false}),
{isProtected: false, isInDarkList: false},
)).toEqual(true);
expect(isURLEnabled(
'https://[2001:4860:4860::8844]',
fillUserSettings({disabledFor: [], enabledFor: ['[2001:4860:4860::8844]'], enabledByDefault: false}),
{isProtected: false, isInDarkList: true},
)).toEqual(true);
expect(isURLEnabled(
'https://[2001:4860:4860::8844]',
fillUserSettings({disabledFor: [], enabledFor: [], enabledByDefault: false}),
{isProtected: false, isInDarkList: true},
)).toEqual(false);
// Default URL matches everything
expect(isURLMatched('http://example.com', '*')).toEqual(true);
expect(isURLMatched('https://example.com', '*')).toEqual(true);
expect(isURLMatched('file:///c/some_file.pdf', '*')).toEqual(true);
expect(isURLMatched('chrome://settings', '*')).toEqual(true);
expect(isURLMatched('chrome-extension://settings', '*')).toEqual(true);
expect(isURLMatched('edge://settings', '*')).toEqual(true);
expect(isURLMatched('brave://settings', '*')).toEqual(true);
expect(isURLMatched('kiwi://settings', '*')).toEqual(true);
expect(isURLMatched('about:blank', '*')).toEqual(true);
expect(isURLMatched('about:preferences', '*')).toEqual(true);
expect(isURLMatched('http://[::1]', '*')).toEqual(true);
expect(isURLMatched('http://[::1]:80', '*')).toEqual(true);
expect(isURLMatched('http://127.0.0.1', '*')).toEqual(true);
expect(isURLMatched('http://127.0.0.1:80', '*')).toEqual(true);
expect(isURLMatched('http://localhost', '*')).toEqual(true);
// No wildcard
expect(isURLMatched('https://example.com/abc', 'example.com')).toEqual(true);
expect(isURLMatched('https://www.example.com/abc', 'example.com')).toEqual(true);
expect(isURLMatched('https://a.example.com/abc', 'example.com')).toEqual(false);
expect(isURLMatched('https://example.com/abc', 'a.example.com')).toEqual(false);
expect(isURLMatched('https://a.example.com/abc', 'b.example.com')).toEqual(false);
// Single wildcard with unbound non-extended left math
expect(isURLMatched('https://example.com/abc', 'example.com/abc/*')).toEqual(false);
expect(isURLMatched('https://example.com/abcd', 'example.com/abc/*')).toEqual(false);
expect(isURLMatched('https://example.com/abc/def', 'example.com/*/def')).toEqual(true);
expect(isURLMatched('https://example.com/abcd/ef', 'example.com/*/def')).toEqual(false);
expect(isURLMatched('https://example.com/abc', 'example.com/*')).toEqual(true);
expect(isURLMatched('https://example.com/abc', 'example.*/abc')).toEqual(true);
expect(isURLMatched('https://example.com/abc', 'example.*')).toEqual(true);
expect(isURLMatched('https://example.com/abc', 'example.*abc')).toEqual(false);
// Single wildcard with unbound extended left math
expect(isURLMatched('https://a.example.com/abc', '*.example.com')).toEqual(true);
expect(isURLMatched('https://a.example.com/abc', '*.example.com/abc/*')).toEqual(false);
// Multiple wildcards with unbound extended left math
expect(isURLMatched('https://a.example.com/abc/def/ghi', 'a.example.com/*/def/*')).toEqual(true);
expect(isURLMatched('https://a.example.com/abc/def/ghi', 'a.example.com/*/abc/*')).toEqual(false);
// Escapes
expect(isURLMatched('https://example.com/*', '/example\\.com\\/\\*/')).toEqual(true);
expect(isURLMatched('https://example.com/?q=*', '/example\\.com\\/\\?q\\=\\*/')).toEqual(true);
expect(isURLMatched('https://example.com/abc?q=*', '/example\\.com\\/abc\\?q\\=\\*/')).toEqual(true);
// Some URLs can have unescaped [] in query
expect(isURLMatched(
'https://google.co.uk/order.php?bar=[foo]',
'google.co.uk',
)).toEqual(true);
expect(isURLMatched(
'https://[2001:4860:4860::8844]/order.php?bar=foo',
'[2001:4860:4860::8844]',
)).toEqual(true);
expect(isURLMatched(
'https://[2001:4860:4860::8844]/order.php?bar=[foo]',
'[2001:4860:4860::8844]',
)).toEqual(true);
expect(isURLMatched(
'https://google.co.uk/order.php?bar=[foo]',
'[2001:4860:4860::8844]',
)).toEqual(false);
// Temporary Dark Sites list fix
expect(isURLEnabled(
'https://darkreader.org/',
fillUserSettings({disabledFor: [], enabledFor: ['darkreader.org'], enabledByDefault: true}),
{isProtected: false, isInDarkList: true},
)).toBe(true);
expect(isURLEnabled(
'https://darkreader.org/',
fillUserSettings({disabledFor: [], enabledFor: [], enabledByDefault: true}),
{isProtected: false, isInDarkList: true},
)).toBe(false);
expect(isURLEnabled(
'https://google.com/',
fillUserSettings({disabledFor: [], enabledFor: ['darkreader.org'], enabledByDefault: true}),
{isProtected: false, isInDarkList: false},
)).toBe(true);
expect(isURLEnabled(
'https://netflix.com',
fillUserSettings({enableForPDF: true, disabledFor: [''], enabledFor: ['netflix.com'], enabledByDefault: false}),
{isProtected: false, isInDarkList: true},
)).toBe(true);
expect(isURLEnabled(
'https://netflix.com',
fillUserSettings({enableForPDF: true, disabledFor: [''], enabledFor: ['netflix.com'], enabledByDefault: true}),
{isProtected: false, isInDarkList: true},
)).toBe(true);
});
it('Get URL host or protocol', () => {
expect(getURLHostOrProtocol('https://www.google.com')).toBe('www.google.com');
expect(getURLHostOrProtocol('https://www.google.com/maps')).toBe('www.google.com');
expect(getURLHostOrProtocol('http://localhost:8080')).toBe('localhost:8080');
expect(getURLHostOrProtocol('about:blank')).toBe('about:');
expect(getURLHostOrProtocol('http://user:pass@www.example.org')).toBe('www.example.org');
expect(getURLHostOrProtocol('data:text/html,<html>Hello</html>')).toBe('data:');
expect(getURLHostOrProtocol('file:///Users/index.html')).toBe('/Users/index.html');
});
it('Absolute URL', () => {
expect(getAbsoluteURL('https://www.google.com', 'image.jpg')).toBe('https://www.google.com/image.jpg');
expect(getAbsoluteURL('https://www.google.com', '/image.jpg')).toBe('https://www.google.com/image.jpg');
expect(getAbsoluteURL('https://www.google.com/path', '/image.jpg')).toBe('https://www.google.com/image.jpg');
expect(getAbsoluteURL('//www.google.com', '/image.jpg')).toBe(`${location.protocol}//www.google.com/image.jpg`);
expect(getAbsoluteURL('https://www.google.com', 'image.jpg?size=128')).toBe('https://www.google.com/image.jpg?size=128');
expect(getAbsoluteURL('https://www.google.com/path', 'image.jpg')).toBe('https://www.google.com/image.jpg');
expect(getAbsoluteURL('https://www.google.com/path/', 'image.jpg')).toBe('https://www.google.com/path/image.jpg');
expect(getAbsoluteURL('https://www.google.com/long/path', '../image.jpg')).toBe('https://www.google.com/image.jpg');
expect(getAbsoluteURL('https://www.google.com/long/path/', '../image.jpg')).toBe('https://www.google.com/long/image.jpg');
expect(getAbsoluteURL('https://www.google.com/long/path/', '../another/image.jpg')).toBe('https://www.google.com/long/another/image.jpg');
expect(getAbsoluteURL('https://www.google.com/path/page.html', 'image.jpg')).toBe('https://www.google.com/path/image.jpg');
expect(getAbsoluteURL('https://www.google.com/path/page.html', '/image.jpg')).toBe('https://www.google.com/image.jpg');
expect(getAbsoluteURL('https://www.google.com', '//www.google.com/path/image.jpg')).toBe(`${location.protocol}//www.google.com/path/image.jpg`);
expect(getAbsoluteURL('https://www.google.com', 'https://www.google.com/path/image.jpg')).toBe('https://www.google.com/path/image.jpg');
expect(getAbsoluteURL('https://www.google.com', 'https://www.google.com/path/../another/image.jpg')).toBe('https://www.google.com/another/image.jpg');
expect(getAbsoluteURL('https://www.google.com/path/page.html', 'image.jpg')).toBe('https://www.google.com/path/image.jpg');
expect(getAbsoluteURL('https://www.google.com/path/page.html', '../image.jpg')).toBe('https://www.google.com/image.jpg');
expect(getAbsoluteURL('path/index.html', 'image.jpg')).toBe(`${location.origin}/path/image.jpg`);
expect(getAbsoluteURL('path/index.html', '/image.jpg?size=128')).toBe(`${location.origin}/image.jpg?size=128`);
});