UNPKG

computed-styles-regression-test

Version:

DOM & CSSOM based regression testing utility

258 lines (214 loc) 7.52 kB
import { createErr, createOk, unwrapOk, isErr, type Result, unwrapErr } from 'option-t/plain_result' import type { ObjectModelTraverser } from './object-model-traverser/object-model-traverser.js' import type { Protocol } from 'playwright-core/types/protocol' import { isNotNull } from 'option-t/nullable/nullable' export type CSSOMStyleValue = Record<string, string> export interface CSSOMElementNode { nodeName: string uniqueSelector: string computedStyles: CSSOMStyleValue pseudoStates?: Record<string, CSSOMStyleValue> children: CSSOMElementNode[] attributes: Record<string, string> textContent?: string } export async function getComputedStyles( traverser: ObjectModelTraverser, nodeId: number ): Promise<Result<CSSOMStyleValue, Error>> { const computedStyleResult = await traverser.getComputedStyleForNode(nodeId) if (isErr(computedStyleResult)) { return computedStyleResult } const { computedStyle } = unwrapOk(computedStyleResult) const styles = computedStyle.reduce((acc, style) => { acc[style.name] = style.value return acc }, {} as CSSOMStyleValue) return createOk(styles) } export async function getMatchedStyles( traverser: ObjectModelTraverser, nodeId: number ): Promise<Result<CSSOMStyleValue, Error>> { const matchedStylesResult = await traverser.getMatchedStylesForNode(nodeId) if (isErr(matchedStylesResult)) { return matchedStylesResult } const { matchedCSSRules, inherited } = unwrapOk(matchedStylesResult) const styles: CSSOMStyleValue = {} // Process matched CSS rules if (matchedCSSRules) { for (const ruleMatch of matchedCSSRules) { const rule = ruleMatch.rule if (rule.style && rule.style.cssProperties) { for (const cssProperty of rule.style.cssProperties) { if (cssProperty.name && cssProperty.value) { styles[cssProperty.name] = cssProperty.value } } } } } // Process inherited styles if (inherited) { for (const inheritedEntry of inherited) { if (inheritedEntry.matchedCSSRules) { for (const ruleMatch of inheritedEntry.matchedCSSRules) { const rule = ruleMatch.rule if (rule.style && rule.style.cssProperties) { for (const cssProperty of rule.style.cssProperties) { if (cssProperty.name && cssProperty.value) { // Only add if not already set by direct rules if (!(cssProperty.name in styles)) { styles[cssProperty.name] = cssProperty.value } } } } } } } } return createOk(styles) } export async function getComputedStylesWithPseudoState( traverser: ObjectModelTraverser, nodeId: number, pseudoState?: string ): Promise<Result<CSSOMStyleValue, Error>> { if (!pseudoState) { return getMatchedStyles(traverser, nodeId) } try { // Force the pseudo-state using CDP CSS.forcePseudoState await forcePseudoState(traverser, nodeId, pseudoState) // Get matched styles with the pseudo-state active // This will include the pseudo-class rules that are now active const stylesResult = await getMatchedStyles(traverser, nodeId) // Clear the forced pseudo-state await clearForcedPseudoState(traverser, nodeId) return stylesResult } catch (error) { // If forcing pseudo-state fails, fall back to normal matched styles console.warn(`Failed to force pseudo-state ${pseudoState}:`, error) return getMatchedStyles(traverser, nodeId) } } async function forcePseudoState( traverser: ObjectModelTraverser, nodeId: number, pseudoState: string ): Promise<void> { const pseudoClasses = [pseudoState] const result = await traverser.forcePseudoState(nodeId, pseudoClasses) if (isErr(result)) { throw unwrapErr(result) } } async function clearForcedPseudoState( traverser: ObjectModelTraverser, nodeId: number ): Promise<void> { const result = await traverser.forcePseudoState(nodeId, []) if (isErr(result)) { console.warn('Failed to clear forced pseudo-state:', unwrapErr(result)) } } export async function traverseElement( traverser: ObjectModelTraverser, nodeId: number, siblingIndex: number, options: { includeChildren: boolean pseudoStatesMap?: Record<string, string[]> } ): Promise<Result<CSSOMElementNode, Error>> { const describeNodeResult = await traverser.describeNode(nodeId) if (isErr(describeNodeResult)) { return describeNodeResult } const { node } = unwrapOk(describeNodeResult) if (node.nodeType !== 1) { return createErr(new Error('Node is not an element')) } const attributes: Record<string, string> = {} if (node.attributes) { for (let i = 0; i < node.attributes.length; i += 2) { const name = node.attributes[i] const value = node.attributes[i + 1] || '' attributes[name] = value } } const computedStylesResult = await getMatchedStyles(traverser, nodeId) // Generate unique selector for this element const uniqueSelector = generateUniqueSelector(node, siblingIndex) // Check if this element has pseudo-states defined const pseudoStates: Record<string, CSSOMStyleValue> = {} if (options.pseudoStatesMap && options.pseudoStatesMap[uniqueSelector]) { for (const pseudoState of options.pseudoStatesMap[uniqueSelector]) { const pseudoStylesResult = await getComputedStylesWithPseudoState( traverser, nodeId, pseudoState ) if (!isErr(pseudoStylesResult)) { pseudoStates[pseudoState] = unwrapOk(pseudoStylesResult) } } } const childrenResults = options.includeChildren && node.children ? ( await Promise.all( node.children.map(async (child, index) => { if (child.nodeType !== 1) { return null } return traverseElement(traverser, child.nodeId, index, { includeChildren: true, pseudoStatesMap: options.pseudoStatesMap, }) }) ) ).filter(isNotNull) : [] const errors = childrenResults.filter(isErr) if (errors.length > 0) { return createErr( new Error('Failed to traverse children', { cause: new AggregateError(errors.map(unwrapErr)), }) ) } const children = childrenResults.map(unwrapOk) const elementNode: CSSOMElementNode = { nodeName: node.nodeName, uniqueSelector, computedStyles: unwrapOk(computedStylesResult), children, attributes, textContent: node.nodeValue || undefined, } // Add pseudo-states if any were found if (Object.keys(pseudoStates).length > 0) { elementNode.pseudoStates = pseudoStates } return createOk(elementNode) } function generateUniqueSelector(element: Protocol.DOM.Node, siblingIndex: number): string { const selectorParts = [] if (element.nodeName) { selectorParts.push(element.nodeName.toLowerCase()) } const idIndex = element.attributes?.findIndex((attribute) => attribute === 'id') const classNameIndex = element.attributes?.findIndex((attribute) => attribute === 'class') if (classNameIndex !== -1 && classNameIndex != null) { selectorParts.push(`.${element.attributes?.[classNameIndex + 1]}`) } if (idIndex !== -1 && idIndex != null) { selectorParts.push(`#${element.attributes?.[idIndex + 1]}`) } selectorParts.push(`@${siblingIndex}`) return selectorParts.join('') }