Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: [1706] Handle non string values gracefully for removeAttribute o… #1707

7 changes: 7 additions & 0 deletions packages/happy-dom/src/nodes/element/Element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import NodeFactory from '../NodeFactory.js';
import HTMLSerializer from '../../html-serializer/HTMLSerializer.js';
import HTMLParser from '../../html-parser/HTMLParser.js';
import IScrollToOptions from '../../window/IScrollToOptions.js';
import { AttributeUtility } from '../../utilities/AttributeUtility.js';

type InsertAdjacentPosition = 'beforebegin' | 'afterbegin' | 'beforeend' | 'afterend';

Expand Down Expand Up @@ -669,6 +670,12 @@ export default class Element
* @param value Value.
*/
public setAttribute(name: string, value: string): void {
AttributeUtility.validateAttributeName(
name,
this[PropertySymbol.ownerDocument][PropertySymbol.contentType],
{ method: 'setAttribute', instance: 'Element' }
);
name = String(name);
const namespaceURI = this[PropertySymbol.namespaceURI];
// TODO: Is it correct to check for namespaceURI === NamespaceURI.svg?
const attribute =
Expand Down
1 change: 1 addition & 0 deletions packages/happy-dom/src/nodes/element/NamedNodeMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export default class NamedNodeMap {
* @returns Item.
*/
public getNamedItem(name: string): Attr | null {
name = String(name);
if (
this[PropertySymbol.ownerElement][PropertySymbol.namespaceURI] === NamespaceURI.html &&
this[PropertySymbol.ownerElement][PropertySymbol.ownerDocument][
Expand Down
43 changes: 43 additions & 0 deletions packages/happy-dom/src/utilities/AttributeUtility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import DOMException from '../exception/DOMException.js';
import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum.js';

const HTML_INVALID_ATTRIBUTE_NAME_CHARACTER_REGEX =
/[\x00-\x1F\x7F\x80-\x9F "\'><=\/\uFDD0-\uFDEF\uFFFE\uFFFF\u1FFFE\u1FFFF\u2FFFE\u2FFFF\u3FFFE\u3FFFF\u4FFFE\u4FFFF\u5FFFE\u5FFFF\u6FFFE\u6FFFF\u7FFFE\u7FFFF\u8FFFE\u8FFFF\u9FFFE\u9FFFF\uAFFFE\uAFFFF\uBFFFE\uBFFFF\uCFFFE\uCFFFF\uDFFFE\uDFFFF\uEFFFE\uEFFFF\uFFFFE\uFFFFF\u10FFFE\u10FFFF]/;

/**
* Attribute utility
*/
export class AttributeUtility {
/**
*
* @param name the attribute name
* @param contentType the attribute has to be valid in
* @param context the context in which the error occurred in
* @param context.method
* @param context.instance
*/
public static validateAttributeName(
name: unknown,
contentType: string,
context: {
method: string;
instance: string;
}
): void {
const { method, instance } = context;
if (contentType === 'text/html') {
const normalizedName = String(name).toLowerCase();
if (
HTML_INVALID_ATTRIBUTE_NAME_CHARACTER_REGEX.test(normalizedName) ||
normalizedName.length === 0 ||
normalizedName[0] === '-'
) {
throw new DOMException(
`Uncaught InvalidCharacterError: Failed to execute '${method}' on '${instance}': '${name}' is not a valid attribute name.`,
DOMExceptionNameEnum.invalidCharacterError
);
}
}
// TODO: implement XML and other content types
}
}
163 changes: 163 additions & 0 deletions packages/happy-dom/test/nodes/element/Element.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import NodeList from '../../../src/nodes/node/NodeList.js';
import Event from '../../../src/event/Event.js';
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
import * as PropertySymbol from '../../../src/PropertySymbol.js';
import DOMExceptionNameEnum from '../../../lib/exception/DOMExceptionNameEnum';

const NAMESPACE_URI = 'https://test.test';

Expand Down Expand Up @@ -1566,6 +1567,162 @@ describe('Element', () => {
expect(element.attributes['key2'].ownerElement === element).toBe(true);
expect(element.attributes['key2'].ownerDocument === document).toBe(true);
});

it('Sets valid attribute names', () => {
// ✅ Basic letters (lowercase & uppercase)
element.setAttribute(`abc`, '1');
expect(element.getAttribute('abc')).toBe('1'); // lowercase letters

element.setAttribute(`ABC`, '1');
expect(element.getAttribute('ABC')).toBe('1'); // uppercase letters

element.setAttribute(`AbC`, '1');
expect(element.getAttribute('AbC')).toBe('1'); // mixed case

// ✅ Length variations
element.setAttribute(`a`, '1');
expect(element.getAttribute('a')).toBe('1'); // single character

element.setAttribute(`ab`, '1');
expect(element.getAttribute('ab')).toBe('1'); // two characters

element.setAttribute(`attribute`, '1');
expect(element.getAttribute('attribute')).toBe('1'); // common length

element.setAttribute(`averyverylongattributenamethatisvalid`, '1');
expect(element.getAttribute('averyverylongattributenamethatisvalid')).toBe('1'); // long attribute name

// ✅ Attribute names with digits
element.setAttribute(`attr1`, '1');
expect(element.getAttribute('attr1')).toBe('1'); // digit at the end

element.setAttribute(`a123`, '1');
expect(element.getAttribute('a123')).toBe('1'); // multiple digits at the end

element.setAttribute(`x9y`, '1');
expect(element.getAttribute('x9y')).toBe('1'); // digit in the middle

// ✅ Attribute names with allowed special characters
element.setAttribute(`_underscore`, '1');
expect(element.getAttribute('_underscore')).toBe('1'); // starts with underscore

element.setAttribute(`under_score`, '1');
expect(element.getAttribute('under_score')).toBe('1'); // contains underscore

element.setAttribute(`hyphen-ated`, '1');
expect(element.getAttribute('hyphen-ated')).toBe('1'); // contains hyphen

element.setAttribute(`ns:attribute`, '1');
expect(element.getAttribute('ns:attribute')).toBe('1'); // namespace-style (colon allowed)

// ✅ Unicode-based attribute names
element.setAttribute(`ö`, '1');
expect(element.getAttribute('ö')).toBe('1'); // Latin extended

element.setAttribute(`ñ`, '1');
expect(element.getAttribute('ñ')).toBe('1'); // Spanish tilde-n

element.setAttribute(`名`, '1');
expect(element.getAttribute('名')).toBe('1'); // Chinese character

element.setAttribute(`имя`, '1');
expect(element.getAttribute('имя')).toBe('1'); // Cyrillic (Russian)

element.setAttribute(`أسم`, '1');
expect(element.getAttribute('أسم')).toBe('1'); // Arabic script

element.setAttribute(`𝒜𝒷𝒸`, '1');
expect(element.getAttribute('𝒜𝒷𝒸')).toBe('1'); // Unicode math letters

element.setAttribute(`ⓐⓑⓒ`, '1');
expect(element.getAttribute('ⓐⓑⓒ')).toBe('1'); // Enclosed alphanumerics

element.setAttribute(`Ωμέγα`, '1');
expect(element.getAttribute('Ωμέγα')).toBe('1'); // Greek letters

// ✅ Edge cases
element.setAttribute(`a`, '1');
expect(element.getAttribute('a')).toBe('1'); // single lowercase letter

element.setAttribute(`Z`, '1');
expect(element.getAttribute('Z')).toBe('1'); // single uppercase letter

element.setAttribute(`_`, '1');
expect(element.getAttribute('_')).toBe('1'); // single underscore (valid but unusual)

// TODO: retest in XML content type
// element.setAttribute(`:`, '1');
// expect(element.getAttribute(':')).toBe('1'); // single colon (valid in XML namespaces)
// element.setAttribute(`-`, '1');
// expect(element.getAttribute('-')).toBe('1'); // single hyphen (valid in XML but discouraged in HTML)
// element.setAttribute(`-hyphen`, '1');
// expect(element.getAttribute('-hyphen')).toBe('1'); // starts with hyphen (allowed in XML)

element.setAttribute(`valid-attribute-name-123`, '1');
expect(element.getAttribute('valid-attribute-name-123')).toBe('1'); // mixed with hyphens and digits

element.setAttribute(`data-custom`, '1');
expect(element.getAttribute('data-custom')).toBe('1'); // common custom attribute pattern
});

it('Throws an error when given an invalid character in the attribute name', () => {
try {
element.setAttribute('☺', '1');
} catch (error) {
expect(error.name).toBe(DOMExceptionNameEnum.invalidCharacterError);
}
try {
// eslint-disable-next-line
element.setAttribute({} as string, '1');
} catch (error) {
expect(error.name).toBe(DOMExceptionNameEnum.invalidCharacterError);
}
try {
element.setAttribute('', '1');
} catch (error) {
expect(error.name).toBe(DOMExceptionNameEnum.invalidCharacterError);
}
try {
element.setAttribute('=', '1');
} catch (error) {
expect(error.name).toBe(DOMExceptionNameEnum.invalidCharacterError);
}
try {
element.setAttribute(' ', '1');
} catch (error) {
expect(error.name).toBe(DOMExceptionNameEnum.invalidCharacterError);
}
try {
element.setAttribute('"', '1');
} catch (error) {
expect(error.name).toBe(DOMExceptionNameEnum.invalidCharacterError);
}
try {
element.setAttribute(`'`, '1');
} catch (error) {
expect(error.name).toBe(DOMExceptionNameEnum.invalidCharacterError);
}
try {
element.setAttribute(`>`, '1');
} catch (error) {
expect(error.name).toBe(DOMExceptionNameEnum.invalidCharacterError);
}
try {
element.setAttribute(`\/`, '1');
} catch (error) {
expect(error.name).toBe(DOMExceptionNameEnum.invalidCharacterError);
}
try {
element.setAttribute(`\u007F`, '1'); // control character delete
} catch (error) {
expect(error.name).toBe(DOMExceptionNameEnum.invalidCharacterError);
}
try {
element.setAttribute(`\u9FFFE`, '1'); // non character
} catch (error) {
expect(error.name).toBe(DOMExceptionNameEnum.invalidCharacterError);
}
});
});

describe('setAttributeNS()', () => {
Expand Down Expand Up @@ -1646,6 +1803,12 @@ describe('Element', () => {
element.removeAttribute('key1');
expect(element.attributes.length).toBe(0);
});

it('Should stringify a non string attribute and remove it', () => {
element.setAttribute('undefined', 'value1');
element.removeAttribute(undefined);
expect(element.attributes.length).toBe(0);
});
});

describe('removeAttributeNS()', () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/happy-dom/test/nodes/element/NamedNodeMap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ describe('NamedNodeMap', () => {
expect(element.getAttribute('key')).toBe('value1');
expect(element.getAttributeNS('namespace', 'key')).toBe('value2');
});

it('Handles non string keys as strings', () => {
element.setAttribute(undefined, 'value1');
expect(element.getAttribute('undefined')).toBe('value1');
});
});

describe('setNamedItemNS()', () => {
Expand Down