Skip to content

Commit 88bc57f

Browse files
WilcoFiersCopilot
authored andcommitted
fix(DqElement): avoid calling constructors with cloneNode (#5013)
Closes: #4996 - **Avoid calling cloneNode when retrieving source** - Add a new utils.getElementSource (which works even if the tree is not constructed - Made sure getElementSource works with namespaces and non-node elements - Check each attribute if it fits in the truncated source, instead of stopping at the first that doesn't fit --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 91b2c28 commit 88bc57f

6 files changed

Lines changed: 340 additions & 181 deletions

File tree

axe.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,10 @@ declare namespace axe {
467467
}
468468

469469
interface Utils {
470+
getElementSource: (
471+
element: Node | null | undefined,
472+
options?: { maxLength?: number; attrLimit?: number }
473+
) => string;
470474
getFrameContexts: (
471475
context?: ElementContext,
472476
options?: RunOptions

lib/core/utils/dq-element.js

Lines changed: 1 addition & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -5,100 +5,10 @@ import getNodeFromTree from './get-node-from-tree';
55
import AbstractVirtualNode from '../base/virtual-node/abstract-virtual-node';
66
import cache from '../base/cache';
77
import memoize from './memoize';
8-
import getNodeAttributes from './get-node-attributes';
9-
import VirtualNode from '../../core/base/virtual-node/virtual-node';
8+
import getSource from './get-element-source';
109

1110
const CACHE_KEY = 'DqElm.RunOptions';
1211

13-
function getOuterHtml(element) {
14-
let source = element.outerHTML;
15-
16-
if (!source && typeof window.XMLSerializer === 'function') {
17-
source = new window.XMLSerializer().serializeToString(element);
18-
}
19-
20-
return source || '';
21-
}
22-
23-
/**
24-
* Truncates the outerHTML property of an element
25-
* @param {Node} element the element node which needs to be truncated
26-
*/
27-
28-
export function truncateElement(element) {
29-
const maxLen = 300;
30-
const maxAttrNameOrValueLen = 20;
31-
32-
const deepStr = getOuterHtml(element);
33-
let vNode = getNodeFromTree(element);
34-
if (!vNode) {
35-
vNode = new VirtualNode(element);
36-
}
37-
const { nodeName } = vNode.props;
38-
39-
if (deepStr.length < maxLen) {
40-
return deepStr;
41-
}
42-
43-
const attributeStrList = [];
44-
const shallowNode = element.cloneNode(false);
45-
const elementNodeMap = getNodeAttributes(shallowNode);
46-
47-
let str = getOuterHtml(shallowNode);
48-
49-
if (str.length < maxLen) {
50-
let attrString = '';
51-
for (const { name, value } of elementNodeMap) {
52-
const attr = { name, value };
53-
attrString += ` ${attr.name}="${attr.value}"`;
54-
}
55-
56-
str = `<${nodeName}${attrString}>`;
57-
return str;
58-
}
59-
let strLen = `<${nodeName}>`.length;
60-
61-
for (const { name, value } of elementNodeMap) {
62-
if (strLen > maxLen) {
63-
break;
64-
}
65-
66-
const attr = { name, value };
67-
let attrName = attr.name;
68-
let attrValue = attr.value;
69-
70-
attrName =
71-
attrName.length > maxAttrNameOrValueLen
72-
? attrName.substring(0, maxAttrNameOrValueLen) + '...'
73-
: attrName;
74-
attrValue =
75-
attrValue.length > maxAttrNameOrValueLen
76-
? attrValue.substring(0, maxAttrNameOrValueLen) + '...'
77-
: attrValue;
78-
79-
const strAttr = `${attrName}="${attrValue}"`;
80-
strLen += (' ' + strAttr).length;
81-
attributeStrList.push(strAttr);
82-
}
83-
84-
str = `<${nodeName} ${attributeStrList.join(' ')}>`;
85-
if (str.length > maxLen) {
86-
str = str.substring(0, maxLen) + ' ...>';
87-
} else if (attributeStrList.length < elementNodeMap.length) {
88-
str = str.substring(0, str.length - 1) + ' ...>';
89-
}
90-
91-
return str;
92-
}
93-
94-
function getSource(element) {
95-
if (!element) {
96-
return '';
97-
}
98-
99-
return truncateElement(element);
100-
}
101-
10212
/**
10313
* "Serialized" `HTMLElement`. It will calculate the CSS selector,
10414
* grab the source (outerHTML) and offer an array for storing frame paths
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import getNodeAttributes from './get-node-attributes';
2+
import isXHTML from './is-xhtml';
3+
4+
/**
5+
* Gets the truncated HTML source of an element, or nodeValue for non-element nodes
6+
* @param {Node} node the DOM node (element, text, comment, etc.)
7+
* @param {Object} options truncation options
8+
* @param {Number} [options.maxLength=300] maximum length of the output
9+
* @param {Number} [options.attrLimit=20] maximum length for attribute names and values
10+
* @returns {String} The outerHTML, truncated representation, or nodeValue for non-elements
11+
*/
12+
export default function getElementSource(
13+
node,
14+
{ maxLength = 300, attrLimit = 20 } = {}
15+
) {
16+
if (!node) {
17+
return '';
18+
}
19+
// non-element nodes
20+
if (node.nodeType !== 1) {
21+
const value = node.nodeValue ?? '';
22+
return truncate(value, maxLength);
23+
}
24+
25+
const deepStr = getOuterHtml(node);
26+
if (deepStr.length > maxLength) {
27+
return getTruncatedElementSource(node, { maxLength, attrLimit });
28+
}
29+
30+
return deepStr;
31+
}
32+
33+
/**
34+
* Gets the outerHTML of an element, using XMLSerializer as fallback for SVG/MathML
35+
* @param {Element} element the DOM element
36+
* @returns {String} The serialized HTML or empty string
37+
*/
38+
function getOuterHtml(element) {
39+
let source = element.outerHTML;
40+
if (!source && typeof window.XMLSerializer === 'function') {
41+
source = new window.XMLSerializer().serializeToString(element);
42+
}
43+
return source || '';
44+
}
45+
46+
/**
47+
* Builds a truncated HTML representation of an element when outerHTML exceeds maxLength.
48+
* Note: attribute order may differ from the original source as node.attributes order is not guaranteed.
49+
* @param {Element} elm the DOM element
50+
* @param {Object} options truncation options
51+
* @param {Number} options.maxLength maximum length of the output
52+
* @param {Number} options.attrLimit maximum length for attribute names and values
53+
* @returns {String} Truncated opening tag (e.g. '<div id="foo" ...>')
54+
*/
55+
function getTruncatedElementSource(elm, { maxLength, attrLimit }) {
56+
const nodeName = isXHTML(elm.ownerDocument || document)
57+
? elm.nodeName
58+
: elm.nodeName.toLowerCase();
59+
60+
// Get a mutable attribute map, and work out their rendered length
61+
const nodeAttrs = Array.from(getNodeAttributes(elm)).map(
62+
({ name, value }) => ({ name, value })
63+
);
64+
const attrsLength = nodeAttrs.reduce((acc, { name, value }) => {
65+
// 4 = space before name + equals sign + opening quote + closing quote
66+
return acc + name.length + value.length + 4;
67+
}, 0);
68+
69+
// 2 = opening "<" + space before first attribute
70+
if (2 + nodeName.length + attrsLength > maxLength) {
71+
nodeAttrs.forEach(attr => {
72+
attr.name = truncate(attr.name, attrLimit);
73+
attr.value = truncate(attr.value, attrLimit);
74+
});
75+
}
76+
77+
let source = `<${nodeName}`;
78+
let tagEnd = '>';
79+
const truncateEnd = ' ...>';
80+
// Must check every attribute: an attr that doesn't fit may be followed by one that does
81+
for (const attr of nodeAttrs) {
82+
const attrStr = ` ${attr.name}="${attr.value}"`;
83+
if (source.length + attrStr.length > maxLength - truncateEnd.length) {
84+
tagEnd = truncateEnd;
85+
continue;
86+
}
87+
source += attrStr;
88+
}
89+
90+
return source + tagEnd;
91+
}
92+
93+
/**
94+
* Truncates a string to max length, appending '...' when truncated
95+
* @param {String} str the string to truncate
96+
* @param {Number} attrLimit maximum length before truncation
97+
* @returns {String} The original string or truncated version with '...' suffix
98+
*/
99+
function truncate(str, attrLimit) {
100+
return str.length <= attrLimit ? str : str.substring(0, attrLimit) + '...';
101+
}

lib/core/utils/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export { default as getStandards } from './get-standards';
4242
export { default as getStyleSheetFactory } from './get-stylesheet-factory';
4343
export { default as getXpath } from './get-xpath';
4444
export { default as getAncestry } from './get-ancestry';
45+
export { default as getElementSource } from './get-element-source';
4546
export { default as injectStyle } from './inject-style';
4647
export { default as isArrayLike } from './is-array-like';
4748
export {

test/core/utils/dq-element.js

Lines changed: 0 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -46,96 +46,6 @@ describe('DqElement', () => {
4646
});
4747

4848
describe('source', () => {
49-
it('should include the outerHTML of the element', () => {
50-
const vNode = queryFixture('<div class="bar" id="target">Hello!</div>');
51-
const outerHTML = vNode.actualNode.outerHTML;
52-
const result = new DqElement(vNode);
53-
assert.equal(result.source, outerHTML);
54-
});
55-
56-
it('should work with SVG elements', () => {
57-
const vNode = queryFixture('<svg aria-label="foo" id="target"></svg>');
58-
const result = new DqElement(vNode);
59-
assert.equal(result.source, vNode.actualNode.outerHTML);
60-
});
61-
62-
it('should work with MathML', () => {
63-
const vNode = queryFixture(
64-
'<math display="block" id="target">' +
65-
'<mrow><msup><mi>x</mi><mn>2</mn></msup></mrow>' +
66-
'</math>'
67-
);
68-
69-
const result = new DqElement(vNode);
70-
assert.equal(result.source, vNode.actualNode.outerHTML);
71-
});
72-
73-
it('should truncate large elements', () => {
74-
let div = '<div class="foo" id="target">';
75-
for (let i = 0; i < 300; i++) {
76-
div += i;
77-
}
78-
div += '</div>';
79-
const vNode = queryFixture(div);
80-
const result = new DqElement(vNode);
81-
assert.equal(result.source, '<div class="foo" id="target">');
82-
});
83-
84-
it('should truncate large attributes of large element', () => {
85-
const el = document.createElement('div');
86-
let attributeName = 'data-';
87-
let attributeValue = '';
88-
for (let i = 0; i < 500; i++) {
89-
attributeName += 'foo';
90-
attributeValue += i;
91-
}
92-
el.setAttribute(attributeName, attributeValue);
93-
94-
const vNode = new DqElement(el);
95-
assert.equal(
96-
vNode.source,
97-
`<div ${attributeName.substring(0, 20)}...="${attributeValue.substring(0, 20)}...">`
98-
);
99-
});
100-
101-
it('should remove attributes for a large element having a large number of attributes', () => {
102-
let customElement = '<div id="target"';
103-
104-
for (let i = 0; i < 100; i++) {
105-
customElement += ` attr${i}="value${i}"`;
106-
}
107-
108-
customElement += `><div>`;
109-
const vNode = queryFixture(customElement);
110-
const result = new DqElement(vNode);
111-
const truncatedAttrCount = (result.source.match(/attr/g) || []).length;
112-
assert.isBelow(truncatedAttrCount, 100);
113-
assert.isAtLeast(truncatedAttrCount, 10);
114-
});
115-
116-
it('should truncate a large element with long custom tag name', () => {
117-
let longCustomElementTagName = new Array(300).join('b');
118-
let customElement = `<${longCustomElementTagName} id="target">A</${longCustomElementTagName}>`;
119-
const vNode = queryFixture(customElement);
120-
const result = new DqElement(vNode);
121-
assert.equal(result.source, `${customElement.substring(0, 300)} ...>`);
122-
});
123-
124-
it('should not truncate attributes if children are long but attribute itself is within limits', () => {
125-
let el = document.createElement('div');
126-
let attributeValue = '';
127-
let innerHtml = '';
128-
for (let i = 0; i < 50; i++) {
129-
attributeValue += 'a';
130-
innerHtml += 'foobar';
131-
}
132-
el.setAttribute('long-attribute', attributeValue);
133-
el.innerHTML = innerHtml;
134-
135-
const vNode = new DqElement(el);
136-
assert.equal(vNode.source, `<div long-attribute="${attributeValue}">`);
137-
});
138-
13949
it('should use spec object over passed element', () => {
14050
const vNode = queryFixture('<div id="target" class="bar">Hello!</div>');
14151
const spec = { source: 'woot' };

0 commit comments

Comments
 (0)