shadow-dom-testing-library
Version:
An extension of DOM-testing-library to provide hooks into the shadow dom
329 lines (289 loc) • 8.39 kB
text/typescript
import { prettyDOM, getConfig } from "@testing-library/dom";
import type { Config, NewPlugin, Printer, Refs } from "pretty-format";
import { patchWrap } from "./trick-dom-testing-library";
// This regexp combo took way too long to figure out...
const findWhiteSpace = /([^\S(\r\n|\r|\n)]*[\f\n\r\t\v]+)/.source;
function removeDuplicateNewLines(str: string) {
let final = str.replace(
new RegExp(`${findWhiteSpace}.*${findWhiteSpace}{2,}`, "g"),
"",
);
return final;
}
export function prettyShadowDOM(
...args: Parameters<typeof prettyDOM>
): ReturnType<typeof prettyDOM> {
let [dom, maxLength, options] = args;
const plugin: NewPlugin = createDOMElementFilter(
options?.filterNode || filterCommentsAndDefaultIgnoreTagsTags,
);
if (options == null) options = {};
if (options.plugins == null) options.plugins = [];
options.plugins.push(plugin);
return patchWrap(() =>
prettyDOM(dom, maxLength, {
...options,
plugins: [plugin],
}),
);
}
function escapeHTML(str: string): string {
return str.replace(/</g, "<").replace(/>/g, ">");
}
export function filterCommentsAndDefaultIgnoreTagsTags(value: Node) {
return (
value.nodeType !== COMMENT_NODE &&
(value.nodeType !== ELEMENT_NODE ||
// @ts-expect-error
!value.matches(getConfig().defaultIgnore))
);
}
// Return empty string if keys is empty.
const printProps = (
keys: Array<string>,
props: Record<string, unknown>,
config: Config,
indentation: string,
depth: number,
refs: Refs,
printer: Printer,
): string => {
const indentationNext = indentation + config.indent;
const colors = config.colors;
return keys
.map((key) => {
const value = props[key];
let printed = printer(value, config, indentationNext, depth, refs);
if (typeof value !== "string") {
if (printed.indexOf("\n") !== -1) {
printed =
config.spacingOuter +
indentationNext +
printed +
config.spacingOuter +
indentation;
}
printed = "{" + printed + "}";
}
return (
config.spacingInner +
indentation +
colors.prop.open +
key +
colors.prop.close +
"=" +
colors.value.open +
printed +
colors.value.close
);
})
.join("");
};
// https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType#node_type_constants
const NodeTypeTextNode = 3;
// Return empty string if children is empty.
const printChildren = (
children: Array<unknown>,
config: Config,
indentation: string,
depth: number,
refs: Refs,
printer: Printer,
): string =>
removeDuplicateNewLines(
children
.map((child) => {
const printedChild =
typeof child === "string"
? printText(child, config)
: printer(child, config, indentation, depth, refs);
if (
printedChild === "" &&
typeof child === "object" &&
child != null &&
(child as Node).nodeType !== NodeTypeTextNode
) {
// A plugin serialized this Node to '' meaning we should ignore it.
return "";
}
return config.spacingOuter + indentation + printedChild;
})
.join(""),
);
const printText = (text: string, config: Config): string => {
const contentColor = config.colors.content;
return contentColor.open + escapeHTML(text) + contentColor.close;
};
const printComment = (comment: string, config: Config): string => {
const commentColor = config.colors.comment;
return (
commentColor.open +
"<!--" +
escapeHTML(comment) +
"-->" +
commentColor.close
);
};
// Separate the functions to format props, children, and element,
// so a plugin could override a particular function, if needed.
// Too bad, so sad: the traditional (but unnecessary) space
// in a self-closing tagColor requires a second test of printedProps.
const printElement = (
type: string,
printedProps: string,
printedChildren: string,
config: Config,
indentation: string,
): string => {
const tagColor = config.colors.tag;
return (
tagColor.open +
"<" +
type +
(printedProps &&
tagColor.close +
printedProps +
config.spacingOuter +
indentation +
tagColor.open) +
(printedChildren
? ">" +
tagColor.close +
printedChildren +
config.spacingOuter +
indentation +
tagColor.open +
"</" +
type
: (printedProps && !config.min ? "" : " ") + "/") +
">" +
tagColor.close
);
};
const printElementAsLeaf = (type: string, config: Config): string => {
const tagColor = config.colors.tag;
return (
tagColor.open +
"<" +
type +
tagColor.close +
" …" +
tagColor.open +
" />" +
tagColor.close
);
};
const ELEMENT_NODE = 1;
const TEXT_NODE = 3;
const COMMENT_NODE = 8;
const FRAGMENT_NODE = 11;
const ELEMENT_REGEXP = /^((HTML|SVG)\w*)?Element$/;
const testNode = (val: any) => {
const constructorName = val?.constructor?.name || "";
const { nodeType, tagName } = val;
const isCustomElement =
(typeof tagName === "string" && tagName.includes("-")) ||
(typeof val.hasAttribute === "function" && val.hasAttribute("is")) ||
val instanceof HTMLElement;
return (
(nodeType === ELEMENT_NODE &&
(ELEMENT_REGEXP.test(constructorName) || isCustomElement)) ||
(nodeType === TEXT_NODE && constructorName === "Text") ||
(nodeType === COMMENT_NODE && constructorName === "Comment") ||
// Don't check constructorName === "DocumentFragment" because it excludes ShadowRoot.
nodeType === FRAGMENT_NODE
);
};
export const test: NewPlugin["test"] = (val: any) =>
val?.constructor?.name && testNode(val);
type HandledType = Element | Text | Comment | DocumentFragment;
function nodeIsText(node: HandledType): node is Text {
return node.nodeType === TEXT_NODE;
}
function nodeIsComment(node: HandledType): node is Comment {
return node.nodeType === COMMENT_NODE;
}
function nodeIsFragment(
node: HandledType,
): node is DocumentFragment | ShadowRoot {
return node.nodeType === FRAGMENT_NODE;
}
export function createDOMElementFilter(
filterNode: (node: Node) => boolean,
): NewPlugin {
function getChildren(
node: Element | DocumentFragment | ShadowRoot,
): (Node | Element | ShadowRoot)[] {
const children: (Node | Element | ShadowRoot)[] =
Array.prototype.slice.call(node.childNodes || node.children);
if (
"shadowRoot" in node &&
node.shadowRoot != null &&
node.shadowRoot.mode !== "closed"
) {
children.unshift(node.shadowRoot);
}
return children.filter(filterNode);
}
return {
test: (val: any) => val?.constructor && testNode(val),
serialize: (
node: HandledType,
config: Config,
indentation: string,
depth: number,
refs: Refs,
printer: Printer,
) => {
if (nodeIsText(node)) {
return printText(node.data, config);
}
if (nodeIsComment(node)) {
return printComment(node.data, config);
}
let type = "DocumentFragment";
if ("tagName" in node && node.tagName) {
type = node.tagName.toLowerCase();
} else if (node instanceof ShadowRoot) {
type = "ShadowRoot";
}
if (++depth > config.maxDepth) {
return printElementAsLeaf(type, config);
}
return printElement(
type,
printProps(
nodeIsFragment(node)
? []
: Array.from(node.attributes)
.map((attr) => attr.name)
.sort(),
nodeIsFragment(node)
? {}
: Array.from(node.attributes).reduce<Record<string, string>>(
(props, attribute) => {
props[attribute.name] = attribute.value;
return props;
},
{},
),
config,
indentation + config.indent,
depth,
refs,
printer,
),
printChildren(
getChildren(node) as unknown[],
config,
indentation + config.indent,
depth,
refs,
printer,
),
config,
indentation,
);
},
};
}