chore: 整理 third-party Dark Reader 目录并移除本地说明文件跟踪
This commit is contained in:
+466
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user