UNPKG

codeceptjs

Version:

Supercharged End 2 End Testing Framework for NodeJS

261 lines (231 loc) 9.89 kB
import yaml from 'js-yaml' // ───────────────────────────────────────────────────────────────── // Roles // ───────────────────────────────────────────────────────────────── const INTERACTIVE_ROLES = new Set([ 'button', 'link', 'textbox', 'searchbox', 'checkbox', 'radio', 'switch', 'combobox', 'listbox', 'listitem', 'menu', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'tab', 'tabpanel', 'slider', 'spinbutton', 'treeitem', 'gridcell', ]) // Long groups of same-role siblings get summarised as: first N + "...M omitted..." + last N const SIBLING_COLLAPSE_THRESHOLD = 50 const SIBLING_COLLAPSE_KEEP_EACH_SIDE = 5 // ───────────────────────────────────────────────────────────────── // STEP 1 · Parse: YAML text → AriaNode[] // ───────────────────────────────────────────────────────────────── function unquote(value) { const v = value.trim() if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) { return v.slice(1, -1) } return v } // Parse one YAML node label like: `button "Save"`, `textbox "Email" [focused]`, `heading "Title" [level=2]` function parseLabel(label) { if (!label) return null const trimmed = label.trim() const roleMatch = trimmed.match(/^(\w+)/) if (!roleMatch) return null const role = roleMatch[1].toLowerCase() let rest = trimmed.slice(roleMatch[0].length) let name const nameMatch = rest.match(/^\s*"((?:[^"\\]|\\.)*)"/) || rest.match(/^\s*'((?:[^'\\]|\\.)*)'/) if (nameMatch) { name = nameMatch[1] rest = rest.slice(nameMatch[0].length) } const attributes = {} const attrMatch = rest.match(/\[([^\]]*)\]/) if (attrMatch) { for (const tok of attrMatch[1].split(/[\s,]+/).filter(Boolean)) { const eq = tok.indexOf('=') if (eq === -1) { attributes[tok.toLowerCase()] = true continue } attributes[tok.slice(0, eq).trim().toLowerCase()] = unquote(tok.slice(eq + 1)) } } return { role, name, attributes } } function yamlItemToNode(item) { if (typeof item === 'string') { const label = parseLabel(item) if (!label) return null const node = { role: label.role, name: label.name, attributes: label.attributes, children: [] } return node } if (!item || typeof item !== 'object' || Array.isArray(item)) return null const entries = Object.entries(item) if (entries.length === 0) return null const [key, value] = entries[0] const label = parseLabel(key) if (!label) return null const node = { role: label.role, name: label.name, attributes: label.attributes, children: [] } if (Array.isArray(value)) { node.children = value.map(yamlItemToNode).filter(n => n !== null) return node } if (value !== null && value !== undefined) node.value = String(value) return node } function parseSnapshot(snapshot) { if (!snapshot) return [] let parsed try { parsed = yaml.load(snapshot) } catch { return [] } if (!Array.isArray(parsed)) return [] return parsed.map(yamlItemToNode).filter(n => n !== null) } // ───────────────────────────────────────────────────────────────── // STEP 2 · Transform: drop containers that contribute nothing. // ───────────────────────────────────────────────────────────────── function dropEmpty(nodes) { return nodes.flatMap(node => { const children = dropEmpty(node.children) if (INTERACTIVE_ROLES.has(node.role)) return [{ ...node, children }] if (children.length > 0) return [{ ...node, children }] return [] }) } // ───────────────────────────────────────────────────────────────── // STEP 3 · Render: AriaNode[] → indented YAML text // ───────────────────────────────────────────────────────────────── // One-line representation of a node. Stable attr order so diff comparisons are deterministic. function formatNode(node) { let line = node.role if (node.name && node.name.trim()) line += ` "${node.name.trim()}"` const attrParts = [] for (const k of Object.keys(node.attributes).sort()) { const v = node.attributes[k] if (v === undefined || v === null || v === '') continue if (v === true) attrParts.push(k) else attrParts.push(`${k}=${v}`) } if (attrParts.length > 0) line += ` [${attrParts.join(' ')}]` if (node.value !== undefined && node.value !== null) { const text = String(node.value).trim() if (text) line += `: ${text}` } return line } // Group consecutive same-role siblings. [a,a,b,a,a,a] → [[a,a],[b],[a,a,a]] function groupByConsecutiveRole(nodes) { return nodes.reduce((groups, node) => { const last = groups[groups.length - 1] if (last && last[0].role === node.role) { last.push(node) return groups } groups.push([node]) return groups }, []) } // Large group of same-role siblings → first N + placeholder line + last N. // Returns mix of AriaNode (to render) and pre-rendered placeholder strings. function collapseGroup(group, depth) { if (group.length <= SIBLING_COLLAPSE_THRESHOLD) return group const keep = SIBLING_COLLAPSE_KEEP_EACH_SIDE const omitted = group.length - keep * 2 const placeholder = `${' '.repeat(depth)}- ...${omitted} similar "${group[0].role}" items omitted...` return [...group.slice(0, keep), placeholder, ...group.slice(-keep)] } function renderTree(nodes, depth = 0) { const items = groupByConsecutiveRole(nodes).flatMap(group => collapseGroup(group, depth)) return items .map(item => { if (typeof item === 'string') return item const indent = ' '.repeat(depth) const head = `${indent}- ${formatNode(item)}` if (item.children.length === 0) return head return `${head}:\n${renderTree(item.children, depth + 1)}` }) .join('\n') } // ───────────────────────────────────────────────────────────────── // STEP 4 · Diff: collect interactive summaries → bag diff → text // ───────────────────────────────────────────────────────────────── // Walk tree, emit one summary string per meaningful interactive node. function collectSummaries(nodes) { return nodes.flatMap(node => { const fromChildren = collectSummaries(node.children) if (!INTERACTIVE_ROLES.has(node.role)) return fromChildren const summary = formatNode(node) if (summary === node.role) return fromChildren // skip empty unnamed interactive nodes return [summary, ...fromChildren] }) } function countBy(items) { return items.reduce((map, item) => { map.set(item, (map.get(item) ?? 0) + 1) return map }, new Map()) } // Bag diff: any summary appearing more in one bag than the other becomes added/removed. function diffSummaries(prev, curr) { const before = countBy(prev) const after = countBy(curr) const added = [] const removed = [] for (const summary of new Set([...before.keys(), ...after.keys()])) { const b = before.get(summary) ?? 0 const a = after.get(summary) ?? 0 for (let i = 0; i < a - b; i += 1) added.push(summary) for (let i = 0; i < b - a; i += 1) removed.push(summary) } return { added, removed } } function formatDiff(added, removed) { if (added.length === 0 && removed.length === 0) return null const lines = ['ariaDiff:'] if (added.length === 0) { lines.push(' added: []') } else { lines.push(' added:') for (const [item, count] of [...countBy(added).entries()].sort(([a], [b]) => a.localeCompare(b))) { const suffix = count > 1 ? ` (x${count})` : '' lines.push(` - ${item}${suffix}`) } } if (removed.length === 0) { lines.push(' removed: []') } else { lines.push(` removed: ${removed.length} interactive elements`) } return lines.join('\n') } // ───────────────────────────────────────────────────────────────── // Public API — pipelines composed visibly, top-to-bottom // ───────────────────────────────────────────────────────────────── function compactAriaSnapshot(snapshot) { if (!snapshot) return '' const tree = dropEmpty(parseSnapshot(snapshot)) return renderTree(tree) } function diffAriaSnapshots(previous, current) { const summariesOf = snap => collectSummaries(dropEmpty(parseSnapshot(snap))) const { added, removed } = diffSummaries(summariesOf(previous), summariesOf(current)) return formatDiff(added, removed) } export { diffAriaSnapshots, compactAriaSnapshot }