Repository: capricorn86/happy-dom (ts)

Issue:
Various <select>/<option> issues
Discovered a number of issues with the behavior of select's and option's:

1. Setting HTMLSelectElement.value doesn't ever work (never updates selected index). This breaks v-model updates in Vue.
2. Setting HTMLSelectElement.selectedIndex to -1 doesn't unselect any/all options
3. Setting HTMLSelectElement.selectedIndex doesn't update `selected` attributes on option nodes
4. Getting HTMLSelectElement.selectedIndex initially is always -1, even if there's an `<option selected>` present in the initial HTML
5. HTMLSelectElement.options doesn't reflect DOM updates when option nodes are added/removed (such as when a `v-for` on an `<option>` in Vue re-runs)

I will be submitting a PR shortly to resolve these issues.

Pull request:
#618@patch: Fix various issues with <select> and <option>.
Fixes #618. There are a number of other potential problems/unimplemented behaviors I noticed, but not necessary to fix for my uses (at least yet). A 100% spec compliant implementation would make certain areas a lot more complex. Good enough for now :)

cc @fatihcure-jf for visibility/review since they added the original implementation (nice work!).

Patch:
diff --git a/packages/happy-dom/src/nodes/element/Element.ts b/packages/happy-dom/src/nodes/element/Element.ts
index a90f3a48..e41f4c9f 100644
--- a/packages/happy-dom/src/nodes/element/Element.ts
+++ b/packages/happy-dom/src/nodes/element/Element.ts
@@ -365,13 +365,9 @@ export default class Element extends Node implements IElement {
 	}
 
 	/**
-	 * Append a child node to childNodes.
-	 *
 	 * @override
-	 * @param  node Node to append.
-	 * @returns Appended node.
 	 */
-	public appendChild(node: INode): INode {
+	public override appendChild(node: INode): INode {
 		// If the type is DocumentFragment, then the child nodes of if it should be moved instead of the actual node.
 		// See: https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment
 		if (node.nodeType !== Node.DOCUMENT_FRAGMENT_NODE) {
@@ -391,12 +387,9 @@ export default class Element extends Node implements IElement {
 	}
 
 	/**
-	 * Remove Child element from childNodes array.
-	 *
 	 * @override
-	 * @param node Node to remove.
 	 */
-	public removeChild(node: INode): INode {
+	public override removeChild(node: INode): INode {
 		if (node.nodeType === Node.ELEMENT_NODE) {
 			const index = this.children.indexOf(<IElement>node);
 			if (index !== -1) {
@@ -415,14 +408,9 @@ export default class Element extends Node implements IElement {
 	}
 
 	/**
-	 * Inserts a node before another.
-	 *
 	 * @override
-	 * @param newNode Node to insert.
-	 * @param [referenceNode] Node to insert before.
-	 * @returns Inserted node.
 	 */
-	public insertBefore(newNode: INode, referenceNode?: INode): INode {
+	public override insertBefore(newNode: INode, referenceNode: INode | null): INode {
 		const returnValue = super.insertBefore(newNode, referenceNode);
 
 		// If the type is DocumentFragment, then the child nodes of if it should be moved instead of the actual node.
diff --git a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElement.ts b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElement.ts
index e88bea39..2f19d364 100644
--- a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElement.ts
+++ b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElement.ts
@@ -1,9 +1,10 @@
 import HTMLElement from '../html-element/HTMLElement';
 import IHTMLElement from '../html-element/IHTMLElement';
 import IHTMLFormElement from '../html-form-element/IHTMLFormElement';
-import HTMLOptionElementValueSanitizer from './HTMLOptionElementValueSanitizer';
 import IHTMLOptionElement from './IHTMLOptionElement';
 
+const NEW_LINES_REGEXP = /[\n\r]/gm;
+
 /**
  * HTML Option Element.
  *
@@ -112,6 +113,6 @@ export default class HTMLOptionElement extends HTMLElement implements IHTMLOptio
 	 * @param value Value.
 	 */
 	public set value(value: string) {
-		this.setAttributeNS(null, 'value', HTMLOptionElementValueSanitizer.sanitize(value));
+		this.setAttributeNS(null, 'value', String(value).trim().replace(NEW_LINES_REGEXP, ''));
 	}
 }
diff --git a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElementValueSanitizer.ts b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElementValueSanitizer.ts
deleted file mode 100644
index a90e0f03..00000000
--- a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionElementValueSanitizer.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-const NEW_LINES_REGEXP = /[\n\r]/gm;
-
-/**
- * HTML select element value sanitizer.
- */
-export default class HTMLOptionElementValueSanitizer {
-	/**
-	 * Sanitizes a value.
-	 *
-	 * @param value Value.
-	 */
-	public static sanitize(value: string): string {
-		return value.trim().replace(NEW_LINES_REGEXP, '');
-	}
-}
diff --git a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionsCollection.ts b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionsCollection.ts
index fae98cfd..9395783e 100644
--- a/packages/happy-dom/src/nodes/html-option-element/HTMLOptionsCollection.ts
+++ b/packages/happy-dom/src/nodes/html-option-element/HTMLOptionsCollection.ts
@@ -1,7 +1,9 @@
 import DOMException from '../../exception/DOMException';
 import HTMLCollection from '../element/HTMLCollection';
-import HTMLOptGroupElement from '../html-opt-group-element/HTMLOptGroupElement';
+import IHTMLOptGroupElement from '../html-opt-group-element/IHTMLOptGroupElement';
+import IHTMLSelectElement from '../html-select-element/IHTMLSelectElement';
 import HTMLOptionElement from './HTMLOptionElement';
+import IHTMLOptionElement from './IHTMLOptionElement';
 import IHTMLOptionsCollection from './IHTMLOptionsCollection';
 
 /**
@@ -14,7 +16,17 @@ export default class HTMLOptionsCollection
 	extends HTMLCollection
 	implements IHTMLOptionsCollection
 {
-	public _selectedIndex: number;
+	private _selectElement: IHTMLSelectElement;
+
+	/**
+	 *
+	 * @param selectElement
+	 */
+	constructor(selectElement: IHTMLSelectElement) {
+		super();
+
+		this._selectElement = selectElement;
+	}
 
 	/**
 	 * Returns selectedIndex.
@@ -22,7 +34,14 @@ export default class HTMLOptionsCollection
 	 * @returns SelectedIndex.
 	 */
 	public get selectedIndex(): number {
-		return this._selectedIndex;
+		for (let i = 0; i < this.length; i++) {
+			const item = this[i];
+			if (item instanceof HTMLOptionElement && item.selected) {
+				return i;
+			}
+		}
+
+		return -1;
 	}
 
 	/**
@@ -31,7 +50,12 @@ export default class HTMLOptionsCollection
 	 * @param selectedIndex SelectedIndex.
 	 */
 	public set selectedIndex(selectedIndex: number) {
-		this._selectedIndex = selectedIndex;
+		for (let i = 0; i < this.length; i++) {
+			const item = this[i];
+			if (item instanceof HTMLOptionElement) {
+				this[i].selected = i === selectedIndex;
+			}
+		}
 	}
 
 	/**
@@ -39,7 +63,7 @@ export default class HTMLOptionsCollection
 	 *
 	 * @param index Index.
 	 */
-	public item(index: number): HTMLOptionElement | HTMLOptGroupElement {
+	public item(index: number): IHTMLOptionElement | IHTMLOptGroupElement {
 		return this[index];
 	}
 
@@ -49,11 +73,11 @@ export default class HTMLOptionsCollection
 	 * @param before
 	 */
 	public add(
-		element: HTMLOptionElement | HTMLOptGroupElement,
-		before?: number | HTMLOptionElement | HTMLOptGroupElement
+		element: IHTMLOptionElement | IHTMLOptGroupElement,
+		before?: number | IHTMLOptionElement | IHTMLOptGroupElement
 	): void {
 		if (!before && before !== 0) {
-			this.push(element);
+			this._selectElement.appendChild(element);
 			return;
 		}
 
@@ -62,18 +86,19 @@ export default class HTMLOptionsCollection
 				return;
 			}
 
-			this.splice(<number>before, 0, element);
+			this._selectElement.insertBefore(element, this[<number>before]);
 			return;
 		}
 
-		const idx = this.findIndex((element) => element === before);
-		if (idx === -1) {
+		const index = this.indexOf(before);
+
+		if (index === -1) {
 			throw new DOMException(
 				"Failed to execute 'add' on 'DOMException': The node before which the new node is to be inserted is not a child of this node."
 			);
 		}
 
-		this.splice(idx, 0, element);
+		this._selectElement.insertBefore(element, this[index]);
 	}
 
 	/**
@@ -82,13 +107,8 @@ export default class HTMLOptionsCollection
 	 * @param index Index.
 	 */
 	public remove(index: number): void {
-		this.splice(index, 1);
-		if (index === this.selectedIndex) {
-			if (this.length) {
-				this.selectedIndex = 0;
-			} else {
-				this.selectedIndex = -1;
-			}
+		if (this[index]) {
+			this._selectElement.removeChild(<IHTMLOptionElement>this[index]);
 		}
 	}
 }
diff --git a/packages/happy-dom/src/nodes/html-option-element/IHTMLOptionsCollection.ts b/packages/happy-dom/src/nodes/html-option-element/IHTMLOptionsCollection.ts
index 4044d9e1..f71cf76e 100644
--- a/packages/happy-dom/src/nodes/html-option-element/IHTMLOptionsCollection.ts
+++ b/packages/happy-dom/src/nodes/html-option-element/IHTMLOptionsCollection.ts
@@ -24,6 +24,13 @@ export default interface IHTMLOptionsCollection
 		before?: number | IHTMLOptionElement | IHTMLOptGroupElement
 	): void;
 
+	/**
+	 * Returns option element by index.
+	 *
+	 * @param index Index.
+	 */
+	item(index: number): IHTMLOptionElement | IHTMLOptGroupElement;
+
 	/**
 	 * Removes option element from the collection.
 	 *
diff --git a/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElement.ts b/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElement.ts
index 5fbddce9..50ead16f 100644
--- a/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElement.ts
+++ b/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElement.ts
@@ -5,14 +5,16 @@ import IHTMLElement from '../html-element/IHTMLElement';
 import IHTMLFormElement from '../html-form-element/IHTMLFormElement';
 import ValidityState from '../validity-state/ValidityState';
 import IHTMLLabelElement from '../html-label-element/IHTMLLabelElement';
-import HTMLOptGroupElement from '../html-opt-group-element/HTMLOptGroupElement';
 import HTMLOptionElement from '../html-option-element/HTMLOptionElement';
 import HTMLOptionsCollection from '../html-option-element/HTMLOptionsCollection';
-import IHTMLOptionsCollection from '../html-option-element/IHTMLOptionsCollection';
 import INodeList from '../node/INodeList';
-import HTMLSelectElementValueSanitizer from './HTMLSelectElementValueSanitizer';
 import IHTMLSelectElement from './IHTMLSelectElement';
 import Event from '../../event/Event';
+import IHTMLOptionElement from '../html-option-element/IHTMLOptionElement';
+import IHTMLOptGroupElement from '../html-opt-group-element/IHTMLOptGroupElement';
+import IHTMLOptionsCollection from '../html-option-element/IHTMLOptionsCollection';
+import INode from '../node/INode';
+import NodeTypeEnum from '../node/NodeTypeEnum';
 
 /**
  * HTML Select Element.
@@ -21,17 +23,13 @@ import Event from '../../event/Event';
  * https://developer.mozilla.org/en-US/docs/Web/API/HTMLSelectElement.
  */
 export default class HTMLSelectElement extends HTMLElement implements IHTMLSelectElement {
-	public type: string;
 	public labels: INodeList<IHTMLLabelElement>;
+	public readonly options: IHTMLOptionsCollection = new HTMLOptionsCollection(this);
 
 	// Events
 	public onchange: (event: Event) => void | null = null;
 	public oninput: (event: Event) => void | null = null;
 
-	public _value = null;
-	public _selectedIndex = -1;
-	public _options: IHTMLOptionsCollection = null;
-
 	/**
 	 * Returns name.
 	 *
@@ -116,6 +114,24 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec
 		}
 	}
 
+	/**
+	 * Returns length.
+	 *
+	 * @returns length.
+	 */
+	public get length(): number {
+		return this.options.length;
+	}
+
+	/**
+	 * Sets length.
+	 *
+	 * @param length Length.
+	 */
+	public set length(length: number) {
+		this.options.length = length;
+	}
+
 	/**
 	 * Returns required.
 	 *
@@ -138,13 +154,28 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec
 		}
 	}
 
+	/**
+	 * Returns type.
+	 *
+	 * @returns type.
+	 */
+	public get type(): string {
+		return this.hasAttributeNS(null, 'multiple') ? 'select-multiple' : 'select-one';
+	}
+
 	/**
 	 * Returns value.
 	 *
 	 * @returns Value.
 	 */
 	public get value(): string {
-		return this._value;
+		if (this.options.selectedIndex === -1) {
+			return '';
+		}
+
+		const option = this.options[this.options.selectedIndex];
+
+		return option instanceof HTMLOptionElement ? option.value : '';
 	}
 
 	/**
@@ -153,12 +184,9 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec
 	 * @param value Value.
 	 */
 	public set value(value: string) {
-		this._value = HTMLSelectElementValueSanitizer.sanitize(value);
-
-		const idx = this.options.findIndex((o) => o.nodeValue === value);
-		if (idx > -1) {
-			this._selectedIndex = idx;
-		}
+		this.options.selectedIndex = this.options.findIndex(
+			(o) => o instanceof HTMLOptionElement && o.value === value
+		);
 	}
 
 	/**
@@ -167,7 +195,7 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec
 	 * @returns Value.
 	 */
 	public get selectedIndex(): number {
-		return this._options ? this._options.selectedIndex : -1;
+		return this.options.selectedIndex;
 	}
 
 	/**
@@ -176,14 +204,14 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec
 	 * @param value Value.
 	 */
 	public set selectedIndex(value: number) {
-		if (value > this.options.length - 1 || value < 0) {
+		if (value > this.options.length - 1 || value < -1) {
 			throw new DOMException(
 				'Select elements selected index must be valid',
 				DOMExceptionNameEnum.indexSizeError
 			);
 		}
 
-		this._options.selectedIndex = value;
+		this.options.selectedIndex = value;
 	}
 
 	/**
@@ -224,21 +252,143 @@ export default class HTMLSelectElement extends HTMLElement implements IHTMLSelec
 	}
 
 	/**
-	 * Returns options.
+	 * Returns item from options collection by index.
+	 *
+	 * @param index Index.
+	 */
+	public item(index: number): IHTMLOptionElement | IHTMLOptGroupElement {
+		return this.options.item(index);
+	}
+
+	/**
+	 * Adds new option to options collection.
 	 *
-	 * @returns Options.
+	 * @param element HTMLOptionElement or HTMLOptGroupElement to add.
+	 * @param before HTMLOptionElement or index number.
+	 */
+	public add(
+		element: IHTMLOptionElement | IHTMLOptGroupElement,
+		before?: number | IHTMLOptionElement | IHTMLOptGroupElement
+	): void {
+		this.options.add(element, before);
+	}
+
+	/**
+	 * Removes indexed element from collection or the select element.
+	 *
+	 * @param [index] Index.
+	 */
+	public override remove(index?: number): void {
+		if (typeof index === 'number') {
+			this.options.remove(index);
+		} else {
+			super.remove();
+		}
+	}
+
+	/**
+	 * @override
+	 */
+	public override appendChild(node: INode): INode {
+		if (node.nodeType === NodeTypeEnum.elementNode) {
+			const element = <IHTMLElement>node;
+			const previousLength = this.options.length;
+
+			if (element.tagName === 'OPTION' || element.tagName === 'OPTGROUP') {
+				this.options.push(<IHTMLOptionElement | IHTMLOptGroupElement>element);
+			}
+
+			this._updateIndexProperties(previousLength, this.options.length);
+		}
+
+		return super.appendChild(node);
+	}
+
+	/**
+	 * @override
+	 */
+	public override insertBefore(newNode: INode, referenceNode: INode | null): INode {
+		const returnValue = super.insertBefore(newNode, referenceNode);
+
+		if (
+			newNode.nodeType === NodeTypeEnum.elementNode &&
+			referenceNode?.nodeType === NodeTypeEnum.elementNode
+		) {
+			const newElement = <IHTMLElement>newNode;
+			const previousLength = this.options.length;
+
+			if (newElement.tagName === 'OPTION' || newElement.tagName === 'OPTGROUP') {
+				const referenceElement = <IHTMLElement>referenceNode;
+
+				if (
+					referenceElement &&
+					(referenceElement.tagName === 'OPTION' || referenceElement.tagName === 'OPTGROUP')
+				) {
+					const referenceIndex = this.options.indexOf(
+						<IHTMLOptGroupElement | IHTMLOptionElement>referenceElement
+					);
+					if (referenceIndex !== -1) {
+						this.options.splice(
+							referenceIndex,
+							0,
+							<IHTMLOptionElement | IHTMLOptGroupElement>newElement
+						);
+					}
+				} else {
+					this.options.push(<IHTMLOptionElement | IHTMLOptGroupElement>newElement);
+				}
+			}
+
+			this._updateIndexProperties(previousLength, this.options.length);
+		}
+
+		return returnValue;
+	}
+
+	/**
+	 * @override
 	 */
-	public get options(): IHTMLOptionsCollection {
-		if (this._options === null) {
-			this._options = new HTMLOptionsCollection();
-			const childs = <INodeList<IHTMLElement>>this.childNodes;
-			for (const child of childs) {
-				if (child.tagName === 'OPTION') {
-					this._options.add(<HTMLOptionElement | HTMLOptGroupElement>child);
+	public override removeChild(node: INode): INode {
+		if (node.nodeType === NodeTypeEnum.elementNode) {
+			const element = <IHTMLElement>node;
+			const previousLength = this.options.length;
+
+			if (element.tagName === 'OPTION' || element.tagName === 'OPTION') {
+				const index = this.options.indexOf(<IHTMLOptionElement | IHTMLOptGroupElement>node);
+				if (index !== -1) {
+					this.options.splice(index, 1);
 				}
 			}
+
+			this._updateIndexProperties(previousLength, this.options.length);
 		}
 
-		return this._options;
+		return super.removeChild(node);
+	}
+
+	/**
+	 * Updates index properties.
+	 *
+	 * @param previousLength Length before the update.
+	 * @param newLength Length after the update.
+	 */
+	protected _updateIndexProperties(previousLength: number, newLength: number): void {
+		if (previousLength > newLength) {
+			for (let i = newLength; i < previousLength; i++) {
+				if (this.hasOwnProperty(String(i))) {
+					delete this[String(i)];
+				}
+			}
+		} else if (previousLength < newLength) {
+			for (let i = previousLength; i < newLength; i++) {
+				Object.defineProperty(this, String(i), {
+					get: () => {
+						return this.options[i];
+					},
+					enumerable: true,
+					configurable: true
+				});
+			}
+		}
 	}
 }
diff --git a/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElementValueSanitizer.ts b/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElementValueSanitizer.ts
deleted file mode 100644
index 1dc380b8..00000000
--- a/packages/happy-dom/src/nodes/html-select-element/HTMLSelectElementValueSanitizer.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-const NEW_LINES_REGEXP = /[\n\r]/gm;
-
-/**
- * HTML select element value sanitizer.
- */
-export default class HTMLSelectElementValueSanitizer {
-	/**
-	 * Sanitizes a value.
-	 *
-	 * @param value Value.
-	 */
-	public static sanitize(value: string): string {
-		return value.replace(NEW_LINES_REGEXP, '');
-	}
-}
diff --git a/packages/happy-dom/src/nodes/html-select-element/IHTMLSelectElement.ts b/packages/happy-dom/src/nodes/html-select-element/IHTMLSelectElement.ts
index d37235ba..c3d7ab88 100644
--- a/packages/happy-dom/src/nodes/html-select-element/IHTMLSelectElement.ts
+++ b/packages/happy-dom/src/nodes/html-select-element/IHTMLSelectElement.ts
@@ -5,6 +5,8 @@ import INodeList from '../node/INodeList';
 import IHTMLOptionsCollection from '../html-option-element/IHTMLOptionsCollection';
 import ValidityState from '../validity-state/ValidityState';
 import Event from '../../event/Event';
+import IHTMLOptionElement from '../html-option-element/IHTMLOptionElement';
+import IHTMLOptGroupElement from '../html-opt-group-element/IHTMLOptGroupElement';
 
 /**
  * HTML Select Element.
@@ -15,18 +17,44 @@ import Event from '../../event/Event';
 export default interface IHTMLSelectElement extends IHTMLElement {
 	readonly form: IHTMLFormElement;
 	readonly labels: INodeList<IHTMLLabelElement>;
-	type: string;
+	readonly options: IHTMLOptionsCollection;
+	readonly type: string;
+	readonly validity: ValidityState;
+	readonly willValidate: boolean;
 	autofocus: boolean;
 	disabled: boolean;
-	options: IHTMLOptionsCollection;
+	length: number;
 	selectedIndex: number;
-	validity: ValidityState;
 	value: string;
-	willValidate: boolean;
 	name: string;
 	multiple: boolean;
 
 	// Events
 	onchange: (event: Event) => void | null;
 	oninput: (event: Event) => void | null;
+
+	/**
+	 * Adds new option to collection.
+	 *
+	 * @param element HTMLOptionElement or HTMLOptGroupElement to add.
+	 * @param before HTMLOptionElement or index number.
+	 */
+	add(
+		element: IHTMLOptionElement | IHTMLOptGroupElement,
+		before?: number | IHTMLOptionElement | IHTMLOptGroupElement
+	): void;
+
+	/**
+	 * Returns option element by index.
+	 *
+	 * @param index Index.
+	 */
+	item(index: number): IHTMLOptionElement | IHTMLOptGroupElement;
+
+	/**
+	 * Removes option element from the collection.
+	 *
+	 * @param index Index.
+	 */
+	remove(index?: number): void;
 }
diff --git a/packages/happy-dom/src/nodes/node/Node.ts b/packages/happy-dom/src/nodes/node/Node.ts
index ab40eec9..de1b6634 100644
--- a/packages/happy-dom/src/nodes/node/Node.ts
+++ b/packages/happy-dom/src/nodes/node/Node.ts
@@ -388,7 +388,7 @@ export default class Node extends EventTarget implements INode {
 	 * Inserts a node before another.
 	 *
 	 * @param newNode Node to insert.
-	 * @param [referenceNode] Node to insert before.
+	 * @param referenceNode Node to insert before.
 	 * @returns Inserted node.
 	 */
 	public insertBefore(newNode: INode, referenceNode: INode | null): INode {


Test patch:
diff --git a/packages/happy-dom/test/nodes/html-option-element/HTMLOptionElement.test.ts b/packages/happy-dom/test/nodes/html-option-element/HTMLOptionElement.test.ts
index cea28981..8db4f077 100644
--- a/packages/happy-dom/test/nodes/html-option-element/HTMLOptionElement.test.ts
+++ b/packages/happy-dom/test/nodes/html-option-element/HTMLOptionElement.test.ts
@@ -31,11 +31,6 @@ describe('HTMLOptionElement', () => {
 			element.value = 'VALUE';
 			expect(element.getAttribute('value')).toBe('VALUE');
 		});
-
-		it('Trims and removes new lines.', () => {
-			element.value = '  \n\rtest  ';
-			expect(element.value).toBe('test');
-		});
 	});
 
 	for (const property of ['disabled', 'selected']) {
diff --git a/packages/happy-dom/test/nodes/html-option-element/HTMLOptionsCollection.test.ts b/packages/happy-dom/test/nodes/html-option-element/HTMLOptionsCollection.test.ts
index 06deabdd..f9ea7f3a 100644
--- a/packages/happy-dom/test/nodes/html-option-element/HTMLOptionsCollection.test.ts
+++ b/packages/happy-dom/test/nodes/html-option-element/HTMLOptionsCollection.test.ts
@@ -1,12 +1,13 @@
 import Window from '../../../src/window/Window';
-import Document from '../../../src/nodes/document/Document';
+import IWindow from '../../../src/window/IWindow';
+import IDocument from '../../../src/nodes/document/IDocument';
 import HTMLSelectElement from '../../../src/nodes/html-select-element/HTMLSelectElement';
 import HTMLOptionElement from '../../../src/nodes/html-option-element/HTMLOptionElement';
 import { DOMException } from '../../../src';
 
 describe('HTMLOptionsCollection', () => {
-	let window: Window;
-	let document: Document;
+	let window: IWindow;
+	let document: IDocument;
 
 	beforeEach(() => {
 		window = new Window();
@@ -17,6 +18,60 @@ describe('HTMLOptionsCollection', () => {
 		jest.restoreAllMocks();
 	});
 
+	describe('get selectedindex()', () => {
+		it('Returns the index of the first option element in the list of options in tree order that has its selectedness set to true.', () => {
+			const select = <HTMLSelectElement>document.createElement('select');
+			const option1 = <HTMLOptionElement>document.createElement('option');
+			const option2 = <HTMLOptionElement>document.createElement('option');
+			option1.selected = true;
+			option1.value = 'option1';
+			option2.value = 'option2';
+			select.appendChild(option1);
+			select.appendChild(option2);
+
+			expect(select.options.selectedIndex).toBe(0);
+		});
+
+		it('Returns -1 if there are no options.', () => {
+			const select = <HTMLSelectElement>document.createElement('select');
+			expect(select.options.selectedIndex).toBe(-1);
+		});
+
+		it('Returns -1 if no option is selected.', () => {
+			const select = <HTMLSelectElement>document.createElement('select');
+			const option1 = <HTMLOptionElement>document.createElement('option');
+			const option2 = <HTMLOptionElement>document.createElement('option');
+			option1.value = 'option1';
+			option2.value = 'option2';
+			select.appendChild(option1);
+			select.appendChild(option2);
+
+			expect(select.options.selectedIndex).toBe(-1);
+		});
+	});
+
+	describe('set selectedindex()', () => {
+		it('Updates option.selected', () => {
+			const select = <HTMLSelectElement>document.createElement('select');
+			select.appendChild(document.createElement('option'));
+			select.appendChild(document.createElement('option'));
+			document.body.appendChild(select);
+
+			expect((<HTMLOptionElement>select.options[0]).selected).toBe(false);
+			expect((<HTMLOptionElement>select.options[1]).selected).toBe(false);
+
+			select.options.selectedIndex = 1;
+
+			expect((<HTMLOptionElement>select.options[0]).selected).toBe(false);
+			expect((<HTMLOptionElement>select.options[1]).selected).toBe(true);
+
+			select.options.selectedIndex = -1;
+
+			expect((<HTMLOptionElement>select.options[0]).selected).toBe(false);
+			expect((<HTMLOptionElement>select.options[1]).selected).toBe(false);
+		});
+	});
+
 	describe('item()', () => {
 		it('Returns node at index.', () => {
 			const select = <HTMLSelectElement>document.createElement('select');
@@ -82,15 +137,13 @@ describe('HTMLOptionsCollection', () => {
 			select.appendChild(option);
 			select.appendChild(option2);
 			document.body.appendChild(select);
-			expect(select.selectedIndex).toBe(-1);
+			expect(select.options.selectedIndex).toBe(-1);
 
 			select.options.selectedIndex = 1;
-			expect(select.selectedIndex).toBe(1);
+			expect(select.options.selectedIndex).toBe(1);
 
+			// No option is selected after removing the selected option
 			select.options.remove(1);
-			expect(select.options.selectedIndex).toBe(0);
-
-			select.options.remove(0);
 			expect(select.options.selectedIndex).toBe(-1);
 		});
 	});
diff --git a/packages/happy-dom/test/nodes/html-select-element/HTMLSelectElement.test.ts b/packages/happy-dom/test/nodes/html-select-element/HTMLSelectElement.test.ts
index 03daf5dd..c2f6f027 100644
--- a/packages/happy-dom/test/nodes/html-select-element/HTMLSelectElement.test.ts
+++ b/packages/happy-dom/test/nodes/html-select-element/HTMLSelectElement.test.ts
@@ -1,16 +1,18 @@
 import Window from '../../../src/window/Window';
-import Document from '../../../src/nodes/document/Document';
-import HTMLSelectElement from '../../../src/nodes/html-select-element/HTMLSelectElement';
+import IWindow from '../../../src/window/IWindow';
+import IDocument from '../../../src/nodes/document/IDocument';
+import IHTMLSelectElement from '../../../src/nodes/html-select-element/IHTMLSelectElement';
+import IHTMLOptionElement from '../../../src/nodes/html-option-element/IHTMLOptionElement';
 
 describe('HTMLSelectElement', () => {
-	let window: Window;
-	let document: Document;
-	let element: HTMLSelectElement;
+	let window: IWindow;
+	let document: IDocument;
+	let element: IHTMLSelectElement;
 
 	beforeEach(() => {
 		window = new Window();
 		document = window.document;
-		element = <HTMLSelectElement>document.createElement('select');
+		element = <IHTMLSelectElement>document.createElement('select');
 	});
 
 	describe('Object.prototype.toString', () => {
@@ -19,12 +21,72 @@ describe('HTMLSelectElement', () => {
 		});
 	});
 
+	describe('get options()', () => {
+		it('Reflects changes as options elements are added and removed from the DOM.', () => {
+			const option1 = <IHTMLOptionElement>document.createElement('option');
+			option1.value = 'option1';
+			element.appendChild(option1);
+
+			expect(element.options.length).toBe(1);
+			expect((<IHTMLOptionElement>element.options[0]).value).toBe('option1');
+
+			element.removeChild(option1);
+
+			const option2 = <IHTMLOptionElement>document.createElement('option');
+			const option3 = <IHTMLOptionElement>document.createElement('option');
+			option2.value = 'option2';
+			option3.value = 'option3';
+			element.appendChild(option2);
+			element.appendChild(option3);
+
+			expect(element.options.length).toBe(2);
+			expect((<IHTMLOptionElement>element.options[0]).value).toBe('option2');
+			expect((<IHTMLOptionElement>element.options[1]).value).toBe('option3');
+		});
+	});
+
 	describe('get value()', () => {
-		it('Returns the attribute "value".', () => {});
+		it('Returns the value of the first option element in the list of options in tree order that has its selectedness set to true.', () => {
+			const option1 = <IHTMLOptionElement>document.createElement('option');
+			const option2 = <IHTMLOptionElement>document.createElement('option');
+			option1.selected = true;
+			option1.value = 'option1';
+			option2.value = 'option2';
+			element.appendChild(option1);
+			element.appendChild(option2);
+
+			expect(element.value).toBe('option1');
+		});
+
+		it('Returns empty string if there are no options.', () => {
+			expect(element.value).toBe('');
+		});
+
+		it('Returns empty string if no option is selected.', () => {
+			const option1 = <IHTMLOptionElement>document.createElement('option');
+			const option2 = <IHTMLOptionElement>document.createElement('option');
+			option1.value = 'option1';
+			option2.value = 'option2';
+			element.appendChild(option1);
+			element.appendChild(option2);
+
+			expect(element.value).toBe('');
+		});
 	});
 
 	describe('set value()', () => {
-		it('Sets the attribute "value".', () => {});
+		it('Sets options.selectedIndex.', () => {
+			const option1 = <IHTMLOptionElement>document.createElement('option');
+			const option2 = <IHTMLOptionElement>document.createElement('option');
+			option1.value = 'option1';
+			option2.value = 'option2';
+			element.appendChild(option1);
+			element.appendChild(option2);
+
+			element.value = 'option1';
+
+			expect(element.options.selectedIndex).toBe(0);
+		});
 
 		it('Trims and removes new lines.', () => {});
 	});
@@ -60,4 +122,256 @@ describe('HTMLSelectElement', () => {
 			expect(element.getAttribute('name')).toBe('value');
 		});
 	});
+
+	describe(`get selectedIndex()`, () => {
+		it('Defaults to -1.', () => {
+			expect(element.selectedIndex).toBe(-1);
+		});
+
+		it('Returns options selectedIndex.', () => {
+			element.appendChild(document.createElement('option'));
+			element.appendChild(document.createElement('option'));
+
+			element.options.selectedIndex = 1;
+			expect(element.selectedIndex).toBe(1);
+		});
+	});
+
+	describe(`set selectedIndex()`, () => {
+		it('Allows -1', () => {
+			element.selectedIndex = -1;
+		});
+
+		it('Sets options selectedIndex.', () => {
+			element.appendChild(document.createElement('option'));
+			element.appendChild(document.createElement('option'));
+
+			element.selectedIndex = 1;
+			expect(element.options.selectedIndex).toBe(1);
+		});
+	});
+
+	describe(`add()`, () => {
+		it('Appends options.', () => {
+			const option1 = <IHTMLOptionElement>document.createElement('option');
+			const option2 = <IHTMLOptionElement>document.createElement('option');
+
+			element.add(option1);
+			element.add(option2);
+
+			expect(element.length).toBe(2);
+			expect(element.children.length).toBe(2);
+			expect(element.options.length).toBe(2);
+			expect(element[0]).toBe(option1);
+			expect(element[1]).toBe(option2);
+			expect(element.children[0]).toBe(option1);
+			expect(element.children[1]).toBe(option2);
+			expect(element.options[0]).toBe(option1);
+			expect(element.options[1]).toBe(option2);
+		});
+
+		it('Appends an option before an index.', () => {
+			const option1 = <IHTMLOptionElement>document.createElement('option');
+			const option2 = <IHTMLOptionElement>document.createElement('option');
+			const option3 = <IHTMLOptionElement>document.createElement('option');
+
+			element.add(option1);
+			element.add(option2);
+			element.add(option3, 1);
+
+			expect(element.length).toBe(3);
+			expect(element.children.length).toBe(3);
+			expect(element.options.length).toBe(3);
+			expect(element[0]).toBe(option1);
+			expect(element[1]).toBe(option3);
+			expect(element[2]).toBe(option2);
+			expect(element.children[0]).toBe(option1);
+			expect(element.children[1]).toBe(option3);
+			expect(element.children[2]).toBe(option2);
+			expect(element.options[0]).toBe(option1);
+			expect(element.options[1]).toBe(option3);
+			expect(element.options[2]).toBe(option2);
+		});
+
+		it('Appends an option before an option element.', () => {
+			const option1 = <IHTMLOptionElement>document.createElement('option');
+			const option2 = <IHTMLOptionElement>document.createElement('option');
+			const option3 = <IHTMLOptionElement>document.createElement('option');
+
+			element.add(option1);
+			element.add(option2);
+			element.add(option3, option2);
+
+			expect(element.length).toBe(3);
+			expect(element.children.length).toBe(3);
+			expect(element.options.length).toBe(3);
+			expect(element[0]).toBe(option1);
+			expect(element[1]).toBe(option3);
+			expect(element[2]).toBe(option2);
+			expect(element.children[0]).toBe(option1);
+			expect(element.children[1]).toBe(option3);
+			expect(element.children[2]).toBe(option2);
+			expect(element.options[0]).toBe(option1);
+			expect(element.options[1]).toBe(option3);
+			expect(element.options[2]).toBe(option2);
+		});
+	});
+
+	describe(`item()`, () => {
+		it('Returns an option element on a specified index.', () => {
+			const option1 = <IHTMLOptionElement>document.createElement('option');
+			const option2 = <IHTMLOptionElement>document.createElement('option');
+			const option3 = <IHTMLOptionElement>document.createElement('option');
+
+			element.add(option1);
+			element.add(option2);
+			element.add(option3);
+
+			expect(element.length).toBe(3);
+			expect(element.options.length).toBe(3);
+			expect(element.item(0)).toBe(option1);
+			expect(element.item(1)).toBe(option2);
+			expect(element.item(2)).toBe(option3);
+			expect(element.options.item(0)).toBe(option1);
+			expect(element.options.item(1)).toBe(option2);
+			expect(element.options.item(2)).toBe(option3);
+		});
+	});
+
+	describe(`appendChild()`, () => {
+		it('Adds appended option or option group elements to the HTMLOptionsCollection.', () => {
+			const option1 = <IHTMLOptionElement>document.createElement('option');
+			const option2 = <IHTMLOptionElement>document.createElement('option');
+			const option3 = <IHTMLOptionElement>document.createElement('option');
+
+			element.appendChild(option1);
+			element.appendChild(option2);
+			element.appendChild(option3);
+
+			expect(element.length).toBe(3);
+			expect(element.children.length).toBe(3);
+			expect(element.options.length).toBe(3);
+			expect(element[0] === option1).toBe(true);
+			expect(element[1] === option2).toBe(true);
+			expect(element[2] === option3).toBe(true);
+			expect(element.children[0] === option1).toBe(true);
+			expect(element.children[1] === option2).toBe(true);
+			expect(element.children[2] === option3).toBe(true);
+			expect(element.options[0] === option1).toBe(true);
+			expect(element.options[1] === option2).toBe(true);
+			expect(element.options[2] === option3).toBe(true);
+			expect(element.item(0) === option1).toBe(true);
+			expect(element.item(1) === option2).toBe(true);
+			expect(element.item(2) === option3).toBe(true);
+		});
+
+		it('Does not include other types of elements in the HTMLOptionsCollection.', () => {
+			const option1 = <IHTMLOptionElement>document.createElement('option');
+			const option2 = <IHTMLOptionElement>document.createElement('option');
+			const option3 = <IHTMLOptionElement>document.createElement('option');
+			const div = <IHTMLOptionElement>document.createElement('div');
+
+			element.appendChild(option1);
+			element.appendChild(option2);
+			element.appendChild(div);
+			element.appendChild(option3);
+
+			expect(element.length).toBe(3);
+			expect(element.children.length).toBe(4);
+			expect(element.options.length).toBe(3);
+			expect(element[0] === option1).toBe(true);
+			expect(element[1] === option2).toBe(true);
+			expect(element[2] === option3).toBe(true);
+			expect(element.children[0] === option1).toBe(true);
+			expect(element.children[1] === option2).toBe(true);
+			expect(element.children[2] === div).toBe(true);
+			expect(element.children[3] === option3).toBe(true);
+			expect(element.options[0] === option1).toBe(true);
+			expect(element.options[1] === option2).toBe(true);
+			expect(element.options[2] === option3).toBe(true);
+			expect(element.item(0) === option1).toBe(true);
+			expect(element.item(1) === option2).toBe(true);
+			expect(element.item(2) === option3).toBe(true);
+		});
+	});
+
+	describe(`insertBefore()`, () => {
+		it('Adds inserted option or option group elements to the HTMLOptionsCollection at correct index.', () => {
+			const option1 = <IHTMLOptionElement>document.createElement('option');
+			const option2 = <IHTMLOptionElement>document.createElement('option');
+			const option3 = <IHTMLOptionElement>document.createElement('option');
+
+			element.appendChild(option1);
+			element.appendChild(option2);
+			element.insertBefore(option3, option2);
+
+			expect(element.length).toBe(3);
+			expect(element.children.length).toBe(3);
+			expect(element.options.length).toBe(3);
+			expect(element[0] === option1).toBe(true);
+			expect(element[1] === option3).toBe(true);
+			expect(element[2] === option2).toBe(true);
+			expect(element.children[0] === option1).toBe(true);
+			expect(element.children[1] === option3).toBe(true);
+			expect(element.children[2] === option2).toBe(true);
+			expect(element.options[0] === option1).toBe(true);
+			expect(element.options[1] === option3).toBe(true);
+			expect(element.options[2] === option2).toBe(true);
+			expect(element.item(0) === option1).toBe(true);
+			expect(element.item(1) === option3).toBe(true);
+			expect(element.item(2) === option2).toBe(true);
+		});
+
+		it('Appends inserted option or option group elements to the HTMLOptionsCollection if referenceNode is null.', () => {
+			const option1 = <IHTMLOptionElement>document.createElement('option');
+			const option2 = <IHTMLOptionElement>document.createElement('option');
+			const option3 = <IHTMLOptionElement>document.createElement('option');
+
+			element.appendChild(option1);
+			element.appendChild(option2);
+			element.insertBefore(option3, null);
+
+			expect(element.length).toBe(3);
+			expect(element.children.length).toBe(3);
+			expect(element.options.length).toBe(3);
+			expect(element[0] === option1).toBe(true);
+			expect(element[1] === option2).toBe(true);
+			expect(element[2] === option3).toBe(true);
+			expect(element.children[0] === option1).toBe(true);
+			expect(element.children[1] === option2).toBe(true);
+			expect(element.children[2] === option3).toBe(true);
+			expect(element.options[0] === option1).toBe(true);
+			expect(element.options[1] === option2).toBe(true);
+			expect(element.options[2] === option3).toBe(true);
+			expect(element.item(0) === option1).toBe(true);
+			expect(element.item(1) === option2).toBe(true);
+			expect(element.item(2) === option3).toBe(true);
+		});
+	});
+
+	describe(`removeChild()`, () => {
+		it('Removes an option or option group elements from the HTMLOptionsCollection.', () => {
+			const option1 = <IHTMLOptionElement>document.createElement('option');
+			const option2 = <IHTMLOptionElement>document.createElement('option');
+			const option3 = <IHTMLOptionElement>document.createElement('option');
+
+			element.appendChild(option1);
+			element.appendChild(option2);
+			element.appendChild(option3);
+
+			element.removeChild(option2);
+
+			expect(element.length).toBe(2);
+			expect(element.children.length).toBe(2);
+			expect(element.options.length).toBe(2);
+			expect(element[0] === option1).toBe(true);
+			expect(element[1] === option3).toBe(true);
+			expect(element.children[0] === option1).toBe(true);
+			expect(element.children[1] === option3).toBe(true);
+			expect(element.options[0] === option1).toBe(true);
+			expect(element.options[1] === option3).toBe(true);
+			expect(element.item(0) === option1).toBe(true);
+			expect(element.item(1) === option3).toBe(true);
+		});
+	});
 });