web-snaps
Version:
Browser automation with automatic snapshotting.
132 lines (131 loc) • 5.84 kB
JavaScript
import { mkdir } from 'node:fs/promises';
import { chromium } from 'rebrowser-playwright';
/**
* Initialize a browser and browser context with scripts inserted for handling elements with closed
* Shadow DOMs.
*
* @category Internal
*/
export async function initBrowser({ userDataDirPath, storeKey, options = {}, }) {
await mkdir(userDataDirPath, { recursive: true });
/** WebKit is typically faster but `rebrowser-playwright` seems to only work with Chromium. */
const browserContext = await chromium.launchPersistentContext(userDataDirPath, options);
try {
browserContext.setDefaultTimeout(10_000);
await browserContext.addInitScript((storeKey) => {
/** https://github.com/evanw/esbuild/issues/2605#issuecomment-2146054255 */
globalThis.__name = (func) => func;
const dataStore = {
closedShadows: new Map(),
queryThroughShadow,
};
function getShadowRoot(node) {
return node instanceof HTMLElement && node.shadowRoot
? node.shadowRoot
: dataStore.closedShadows.get(node);
}
/**
* Perform
* [`.querySelector()`](https://developer.mozilla.org/docs/Web/API/Document/querySelector)
* on the given element with support for elements that contain an open Shadow Root.
*
* @category Web : Elements
* @category Package : @augment-vir/web
* @package [`@augment-vir/web`](https://www.npmjs.com/package/@augment-vir/web)
*/
function queryThroughShadow(element, rawQuery, options = {}) {
if (!rawQuery) {
if (element instanceof Element) {
return element;
}
else {
return element.host;
}
}
const query = typeof rawQuery === 'string' ? rawQuery : rawQuery.tagName;
const splitQuery = query.split(' ').filter((value) => !!value);
const shadowRoot = getShadowRoot(element);
if (splitQuery.length > 1) {
return handleNestedQueries(element, query, options, splitQuery);
}
else if (shadowRoot) {
return queryThroughShadow(shadowRoot, query, options);
}
const shadowRootChildren = getShadowRootChildren(element);
if (options.all) {
const outerResults = Array.from(element.querySelectorAll(query));
const nestedResults = shadowRootChildren.flatMap((shadowRootChild) => {
return queryThroughShadow(shadowRootChild, query, options);
});
return [
...outerResults,
...nestedResults,
];
}
else {
const basicResult = element.querySelector(query);
if (basicResult) {
return basicResult;
}
else {
for (const shadowRootChild of shadowRootChildren) {
const nestedResult = queryThroughShadow(shadowRootChild, query, options);
if (nestedResult) {
return nestedResult;
}
}
return undefined;
}
}
}
function getShadowRootChildren(element) {
return Array.from(element.querySelectorAll('*'))
.map((child) => getShadowRoot(child))
.filter((value) => !!value);
}
function handleNestedQueries(element, originalQuery, options, queries) {
const firstQuery = queries[0];
/**
* No way to intentionally trigger this edge case, we're just catching it here for
* type purposes.
*/
/* node:coverage ignore next 7 */
if (!firstQuery) {
throw new Error(`Somehow the first query was empty in '[${queries.join(',')}]' for query '${JSON.stringify(originalQuery)}'`);
}
const results = queryThroughShadow(element, firstQuery, options);
if (queries.length <= 1) {
return results;
}
if (Array.isArray(results)) {
return results
.flatMap((result) => {
return handleNestedQueries(result, originalQuery, options, queries.slice(1));
})
.filter((value) => !!value);
}
else if (results) {
return handleNestedQueries(results, originalQuery, options, queries.slice(1));
}
else {
return undefined;
}
}
globalThis[storeKey] = dataStore;
// eslint-disable-next-line @typescript-eslint/unbound-method
const original = Element.prototype.attachShadow;
Element.prototype.attachShadow = function (init) {
const shadow = original.call(this, init);
if (init.mode === 'closed') {
dataStore.closedShadows.set(this, shadow);
}
return shadow;
};
}, storeKey);
return { browserContext };
}
catch (error) {
await browserContext.close();
throw error;
}
}