diff --git a/src/scriptlets/trusted-create-element.js b/src/scriptlets/trusted-create-element.ts similarity index 53% rename from src/scriptlets/trusted-create-element.js rename to src/scriptlets/trusted-create-element.ts index 264f3b16c..830902750 100644 --- a/src/scriptlets/trusted-create-element.js +++ b/src/scriptlets/trusted-create-element.ts @@ -3,8 +3,12 @@ import { logMessage, observeDocumentWithTimeout, nativeIsNaN, + parseAttributePairs, + getErrorMessage, } from '../helpers/index'; +import type { ParsedAttributePair } from '../helpers/attribute-utils'; + /* eslint-disable max-len */ /** * @trustedScriptlet trusted-create-element @@ -34,19 +38,19 @@ import { * example.com#%#//scriptlet('trusted-create-element', 'body', 'div', 'data-cur|1') * ``` * - * 2. Create a div element with text content + * 1. Create a div element with text content * * ```adblock * example.com#%#//scriptlet('trusted-create-element', 'body', 'div', '', 'Hello world!') * ``` * - * 3. Create a button element with multiple attributes, including attribute without value, and text content + * 1. Create a button element with multiple attributes, including attribute without value, and text content * * ```adblock * example.com#%#//scriptlet('trusted-create-element', 'body', 'button', 'disabled aria-hidden|true style|width:0px', 'Press here') * ``` * - * 4. Create a paragraph element with text content and remove it after 5 seconds + * 1. Create a paragraph element with text content and remove it after 5 seconds * * ```adblock * example.com#%#//scriptlet('trusted-create-element', '.container > article', 'p', '', 'Hello world!', 5000) @@ -56,9 +60,9 @@ import { */ /* eslint-enable max-len */ export function trustedCreateElement( - source, - parentSelector, - tagName, + source: Source, + parentSelector: string, + tagName: string, attributePairs = '', textContent = '', cleanupDelayMs = NaN, @@ -75,77 +79,89 @@ export function trustedCreateElement( return; } - // '|' is used as it is not a valid attribute name character - const ATTR_SEPARATOR = '|'; - const ATTR_STR_SEPARATOR = ' '; + const logError = (prefix: string, error: unknown) => { + logMessage(source, `${prefix} due to ${getErrorMessage(error)}`); + }; + + let attributes: ParsedAttributePair[] | null = null; - let attributes = []; - if (attributePairs) { - const chunks = attributePairs.split(ATTR_STR_SEPARATOR); - attributes = chunks.map((chunk) => chunk.split(ATTR_SEPARATOR)); + try { + attributes = parseAttributePairs(attributePairs); + } catch (e) { + logError(`Cannot parse attributePairs param: '${attributePairs}'`, e); + return; } - let element; + let element: HTMLElement; try { element = document.createElement(tagName); element.textContent = textContent; - } catch { - logMessage(source, `Could not create element with tag name: ${tagName}.`); + } catch (e) { + logError(`Cannot create element with tag name '${tagName}'`, e); return; } - if (attributes.length > 0) { - attributes.forEach((entry) => { - const name = entry[0]; - const value = entry[1] || ''; + if (attributes && attributes.length > 0) { + attributes.forEach((attr) => { try { - element.setAttribute(name, value); - } catch { - logMessage(source, `Could not set attribute "${name}" with value "${value}".`); + element.setAttribute(attr.name, attr.value); + } catch (e) { + logError(`Cannot set attribute '${attr.name}' with value '${attr.value}'`, e); } }); } - let timerId = null; + let timerId: ReturnType; - const findParentAndAppendEl = (parentSelector, element) => { + /** + * Finds parent element by `parentElSelector` and appends the `el` element to it. + * + * If `removeElDelayMs` is not `NaN`, + * schedules the `el` element to be removed after `removeElDelayMs` milliseconds. + * + * @param parentElSelector CSS selector of the parent element. + * @param el HTML element to append to the parent element. + * @param removeElDelayMs Delay in milliseconds before the `el` element is removed from the DOM. + * + * @returns True if the `el` element was successfully appended to the parent element, otherwise false. + */ + const findParentAndAppendEl = (parentElSelector: string, el: HTMLElement, removeElDelayMs: number) => { let parentEl; try { - parentEl = document.querySelector(parentSelector); - } catch { - logMessage(source, `Could not find parent element by selector "${parentSelector}"`); + parentEl = document.querySelector(parentElSelector); + } catch (e) { + logError(`Cannot find parent element by selector '${parentElSelector}'`, e); } if (!parentEl) { + logMessage(source, `No parent element found by selector: '${parentElSelector}'`); return false; } try { - parentEl.append(element); - - if (element instanceof HTMLIFrameElement) { - element.contentWindow.name = IFRAME_WINDOW_NAME; + parentEl.append(el); + if (el instanceof HTMLIFrameElement && el.contentWindow) { + el.contentWindow.name = IFRAME_WINDOW_NAME; } - hit(source); - } catch { - logMessage(source, `Could not append child to parent by selector "${parentSelector}"`); + } catch (e) { + logError(`Cannot append child to parent by selector '${parentElSelector}'`, e); return false; } - if (!nativeIsNaN(cleanupDelayMs)) { + if (!nativeIsNaN(removeElDelayMs)) { timerId = setInterval(() => { - element.remove(); + el.remove(); clearInterval(timerId); - }, cleanupDelayMs); + }, removeElDelayMs); } return true; }; - if (!findParentAndAppendEl(parentSelector, element)) { + if (!findParentAndAppendEl(parentSelector, element, cleanupDelayMs)) { observeDocumentWithTimeout((mutations, observer) => { - if (findParentAndAppendEl(parentSelector, element)) { + if (findParentAndAppendEl(parentSelector, element, cleanupDelayMs)) { observer.disconnect(); } }); @@ -162,4 +178,6 @@ trustedCreateElement.injections = [ logMessage, observeDocumentWithTimeout, nativeIsNaN, + parseAttributePairs, + getErrorMessage, ]; diff --git a/tests/scriptlets/trusted-create-element.test.js b/tests/scriptlets/trusted-create-element.test.js index a511a1e14..67a066184 100644 --- a/tests/scriptlets/trusted-create-element.test.js +++ b/tests/scriptlets/trusted-create-element.test.js @@ -37,7 +37,7 @@ module(name, { beforeEach, afterEach }); test('creating empty div', (assert) => { const { children } = createRoot(ROOT_ID); - const childTagName = 'DIV'; + const childTagName = 'div'; assert.strictEqual(children.length, 0, 'Parent element has no children before scriptlet is run'); @@ -47,7 +47,7 @@ test('creating empty div', (assert) => { const child = children[0]; - assert.strictEqual(child.tagName, childTagName, 'Tag name is set correctly'); + assert.strictEqual(child.tagName.toLowerCase(), childTagName, 'Tag name is set correctly'); assert.strictEqual(child.textContent, '', 'Text content is set correctly'); assert.strictEqual(child.attributes.length, 0, 'No attributes were set'); @@ -68,7 +68,7 @@ test('setting text content', (assert) => { const child = children[0]; - assert.strictEqual(child.tagName, childTagName, 'Tag name is set correctly'); + assert.strictEqual(child.tagName.toLowerCase(), childTagName.toLowerCase(), 'Tag name is set correctly'); assert.strictEqual(child.textContent, textContent, 'Text content is set correctly'); assert.strictEqual(child.attributes.length, 0, 'No attributes were set'); @@ -78,10 +78,10 @@ test('setting text content', (assert) => { test('setting single attribute', (assert) => { const { children } = createRoot(ROOT_ID); - const childTagName = 'IMG'; + const childTagName = 'img'; const attributeName = 'src'; const attributeValue = './test-files/test-image.jpeg'; - const attributesParam = `${attributeName}|${attributeValue}`; + const attributesParam = `${attributeName}="${attributeValue}"`; assert.strictEqual(children.length, 0, 'Parent element has no children before scriptlet is run'); @@ -91,7 +91,7 @@ test('setting single attribute', (assert) => { const child = children[0]; - assert.strictEqual(child.tagName, childTagName, 'Tag name is set correctly'); + assert.strictEqual(child.tagName.toLowerCase(), childTagName, 'Tag name is set correctly'); assert.strictEqual(child.textContent, '', 'Text content is set correctly'); assert.strictEqual(child.getAttribute(attributeName), attributeValue, 'Attribute is set correctly'); @@ -100,17 +100,50 @@ test('setting single attribute', (assert) => { assert.strictEqual(window.hit, 'FIRED', 'hit fired'); }); +test('multiple attributes with space in value', (assert) => { + const { children } = createRoot(ROOT_ID); + + const childTagName = 'div'; + const attributeName1 = 'class'; + const attributeValue1 = 'adsbygoogle adsbygoogle-noablate'; + const attributeName2 = 'data-adsbygoogle-status'; + const attributeValue2 = 'done'; + const attributeName3 = 'data-ad-status'; + const attributeValue3 = 'filled'; + // eslint-disable-next-line max-len + const attributesParam = `${attributeName1}="${attributeValue1}" ${attributeName2}="${attributeValue2}" ${attributeName3}="${attributeValue3}"`; + const textContent = 'this is text content of an element'; + + assert.strictEqual(children.length, 0, 'Parent element has no children before scriptlet is run'); + + runScriptlet(name, [ROOT_SELECTOR, childTagName, attributesParam, textContent]); + + assert.strictEqual(children.length, 1, 'Only specified child was appended'); + + const child = children[0]; + + assert.strictEqual(child.tagName.toLowerCase(), childTagName, 'Tag name is set correctly'); + assert.strictEqual(child.textContent, textContent, 'Text content is set correctly'); + + assert.strictEqual(child.getAttribute(attributeName1), attributeValue1, 'Attribute 1 is set correctly'); + assert.strictEqual(child.getAttribute(attributeName2), attributeValue2, 'Attribute 2 is set correctly'); + assert.strictEqual(child.getAttribute(attributeName3), attributeValue3, 'Attribute 3 is set correctly'); + assert.strictEqual(child.getAttributeNames().length, 3, 'Specified amount of attributes were set'); + + assert.strictEqual(window.hit, 'FIRED', 'hit fired'); +}); + test('multiple attributes + attribute with no value', (assert) => { const { children } = createRoot(ROOT_ID); - const childTagName = 'P'; + const childTagName = 'p'; const attributeName1 = 'aria-hidden'; const attributeValue1 = 'true'; const attributeName2 = 'style'; const attributeValue2 = 'width:0px'; const attributeWithoutValue = 'attr3'; // eslint-disable-next-line max-len - const attributesParam = `${attributeName1}|${attributeValue1} ${attributeName2}|${attributeValue2} ${attributeWithoutValue}`; + const attributesParam = `${attributeName1}="${attributeValue1}" ${attributeName2}="${attributeValue2}" ${attributeWithoutValue}`; const textContent = 'this is text content of an element'; assert.strictEqual(children.length, 0, 'Parent element has no children before scriptlet is run'); @@ -121,7 +154,7 @@ test('multiple attributes + attribute with no value', (assert) => { const child = children[0]; - assert.strictEqual(child.tagName, childTagName, 'Tag name is set correctly'); + assert.strictEqual(child.tagName.toLowerCase(), childTagName, 'Tag name is set correctly'); assert.strictEqual(child.textContent, textContent, 'Text content is set correctly'); assert.strictEqual(child.getAttribute(attributeName1), attributeValue1, 'Attribute 1 is set correctly'); @@ -159,7 +192,7 @@ test('element cleanup by timeout', (assert) => { }); test('running scriptlet before root element is created', (assert) => { - const childTagName = 'DIV'; + const childTagName = 'div'; runScriptlet(name, [ROOT_SELECTOR, childTagName]); @@ -171,7 +204,7 @@ test('running scriptlet before root element is created', (assert) => { const child = children[0]; - assert.strictEqual(child.tagName, childTagName, 'Tag name is set correctly'); + assert.strictEqual(child.tagName.toLowerCase(), childTagName, 'Tag name is set correctly'); assert.strictEqual(child.textContent, '', 'Text content is set correctly'); assert.strictEqual(window.hit, 'FIRED', 'hit fired');