Repository: capricorn86/happy-dom
Pull request #154 (2020-11-15 23:57:32)

Task/142 add support for script evalution


Patch:
diff --git a/packages/happy-dom/src/config/ElementClass.ts b/packages/happy-dom/src/config/ElementClass.ts
index 434a6861..baafb38a 100644
--- a/packages/happy-dom/src/config/ElementClass.ts
+++ b/packages/happy-dom/src/config/ElementClass.ts
@@ -5,6 +5,7 @@ import HTMLInputElement from '../nodes/html-input-element/HTMLInputElement';
 import HTMLTextAreaElement from '../nodes/html-text-area-element/HTMLTextAreaElement';
 import SVGSVGElement from '../nodes/svg-element/SVGSVGElement';
 import SVGElement from '../nodes/svg-element/SVGElement';
+import HTMLScriptElement from '../nodes/html-script-element/HTMLScriptElement';
 
 export default {
 	HTMLTemplateElement: HTMLTemplateElement,
@@ -56,7 +57,7 @@ export default {
 	HTMLPictureElement: HTMLElement,
 	HTMLProgressElement: HTMLElement,
 	HTMLQuoteElement: HTMLElement,
-	HTMLScriptElement: HTMLElement,
+	HTMLScriptElement: HTMLScriptElement,
 	HTMLSelectElement: HTMLElement,
 	HTMLSlotElement: HTMLElement,
 	HTMLSourceElement: HTMLElement,
diff --git a/packages/happy-dom/src/config/ElementTag.ts b/packages/happy-dom/src/config/ElementTag.ts
index 8322ec59..8e7b9b98 100644
--- a/packages/happy-dom/src/config/ElementTag.ts
+++ b/packages/happy-dom/src/config/ElementTag.ts
@@ -4,12 +4,14 @@ import HTMLInputElement from '../nodes/html-input-element/HTMLInputElement';
 import HTMLTextAreaElement from '../nodes/html-text-area-element/HTMLTextAreaElement';
 import SVGSVGElement from '../nodes/svg-element/SVGSVGElement';
 import SVGElement from '../nodes/svg-element/SVGElement';
+import HTMLScriptElement from '../nodes/html-script-element/HTMLScriptElement';
 
 export default {
 	template: HTMLTemplateElement,
 	form: HTMLFormElement,
 	input: HTMLInputElement,
 	textarea: HTMLTextAreaElement,
+	script: HTMLScriptElement,
 	svg: SVGSVGElement,
 	circle: SVGElement,
 	ellipse: SVGElement,
diff --git a/packages/happy-dom/src/dom-parser/DOMParser.ts b/packages/happy-dom/src/dom-parser/DOMParser.ts
index 119c1c83..59f91aee 100644
--- a/packages/happy-dom/src/dom-parser/DOMParser.ts
+++ b/packages/happy-dom/src/dom-parser/DOMParser.ts
@@ -34,7 +34,7 @@ export default class DOMParser {
 		(<Node[]>newDocument.childNodes) = [];
 		(<Element[]>newDocument.children) = [];
 
-		const root = XMLParser.parse(newDocument, string);
+		const root = XMLParser.parse(newDocument, string, true);
 		let documentElement = null;
 		let documentTypeNode = null;
 
diff --git a/packages/happy-dom/src/nodes/document/Document.ts b/packages/happy-dom/src/nodes/document/Document.ts
index 91e11548..02e5d55e 100644
--- a/packages/happy-dom/src/nodes/document/Document.ts
+++ b/packages/happy-dom/src/nodes/document/Document.ts
@@ -317,7 +317,7 @@ export default class Document extends Node implements IDocument {
 	 * @param html HTML.
 	 */
 	public write(html: string): void {
-		const root = XMLParser.parse(this, html);
+		const root = XMLParser.parse(this, html, true);
 
 		if (this._isFirstWrite || this._isFirstWriteAfterOpen) {
 			if (this._isFirstWrite) {
diff --git a/packages/happy-dom/src/nodes/element/Element.ts b/packages/happy-dom/src/nodes/element/Element.ts
index cefff7b5..4d907b9c 100644
--- a/packages/happy-dom/src/nodes/element/Element.ts
+++ b/packages/happy-dom/src/nodes/element/Element.ts
@@ -181,6 +181,15 @@ export default class Element extends Node implements IElement {
 		return new XMLSerializer().serializeToString(this);
 	}
 
+	/**
+	 * Returns outer HTML.
+	 *
+	 * @param html HTML.
+	 */
+	public set outerHTML(html: string) {
+		this.replaceWith(html);
+	}
+
 	/**
 	 * Returns attributes.
 	 *
diff --git a/packages/happy-dom/src/nodes/html-input-element/HTMLInputElement.ts b/packages/happy-dom/src/nodes/html-input-element/HTMLInputElement.ts
index 437c4516..488ba880 100644
--- a/packages/happy-dom/src/nodes/html-input-element/HTMLInputElement.ts
+++ b/packages/happy-dom/src/nodes/html-input-element/HTMLInputElement.ts
@@ -85,10 +85,10 @@ export default class HTMLInputElement extends HTMLElement {
 			case 'image':
 			case 'reset':
 			case 'button':
-				return this.getAttribute('value') || '';
+				return this.getAttributeNS(null, 'value') || '';
 			case 'checkbox':
 			case 'radio':
-				const attritube = this.getAttribute('value');
+				const attritube = this.getAttributeNS(null, 'value');
 				return attritube !== null ? attritube : 'on';
 			case 'file':
 				return this.files.length > 0 ? '/fake/path/' + this.files[0].name : '';
@@ -111,7 +111,7 @@ export default class HTMLInputElement extends HTMLElement {
 			case 'button':
 			case 'checkbox':
 			case 'radio':
-				this.setAttribute('value', value);
+				this.setAttributeNS(null, 'value', value);
 				break;
 			case 'file':
 				if (value !== '') {
diff --git a/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts b/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts
new file mode 100644
index 00000000..66154a62
--- /dev/null
+++ b/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts
@@ -0,0 +1,176 @@
+import HTMLElement from '../html-element/HTMLElement';
+
+/**
+ * HTML Script Element.
+ *
+ * Reference:
+ * https://developer.mozilla.org/en-US/docs/Web/API/HTMLScriptElement
+ */
+export default class HTMLScriptElement extends HTMLElement {
+	public _evaluateScript = true;
+
+	/**
+	 * "true" if connected to DOM.
+	 *
+	 * @return "true" if connected.
+	 */
+	public get isConnected(): boolean {
+		return this._isConnected;
+	}
+
+	/**
+	 * Sets the connected state.
+	 *
+	 * @param isConnected "true" if connected.
+	 */
+	public set isConnected(isConnected) {
+		if (this._isConnected !== isConnected) {
+			this._isConnected = isConnected;
+
+			for (const child of this.childNodes) {
+				child.isConnected = isConnected;
+			}
+
+			// eslint-disable-next-line
+			if (this.shadowRoot) {
+				// eslint-disable-next-line
+				this.shadowRoot.isConnected = isConnected;
+			}
+
+			if (this._evaluateScript) {
+				eval(this.textContent);
+			}
+
+			if (isConnected && this.connectedCallback) {
+				this.connectedCallback();
+			} else if (!isConnected && this.disconnectedCallback) {
+				this.disconnectedCallback();
+			}
+		}
+	}
+	/**
+	 * Returns type.
+	 *
+	 * @return Type.
+	 */
+	public get type(): string {
+		return this.getAttributeNS(null, 'type') || '';
+	}
+
+	/**
+	 * Sets type.
+	 *
+	 * @param type Type.
+	 */
+	public set type(type: string) {
+		this.setAttributeNS(null, 'type', type);
+	}
+
+	/**
+	 * Returns source.
+	 *
+	 * @return Source.
+	 */
+	public get src(): string {
+		return this.getAttributeNS(null, 'src') || '';
+	}
+
+	/**
+	 * Sets source.
+	 *
+	 * @param source Source.
+	 */
+	public set src(src: string) {
+		this.setAttributeNS(null, 'src', src);
+	}
+
+	/**
+	 * Returns charset.
+	 *
+	 * @return Charset.
+	 */
+	public get charset(): string {
+		return this.getAttributeNS(null, 'charset') || '';
+	}
+
+	/**
+	 * Sets charset.
+	 *
+	 * @param charset Charset.
+	 */
+	public set charset(charset: string) {
+		this.setAttributeNS(null, 'charset', charset);
+	}
+
+	/**
+	 * Returns lang.
+	 *
+	 * @return Lang.
+	 */
+	public get lang(): string {
+		return this.getAttributeNS(null, 'lang') || '';
+	}
+
+	/**
+	 * Sets lang.
+	 *
+	 * @param lang Lang.
+	 */
+	public set lang(lang: string) {
+		this.setAttributeNS(null, 'lang', lang);
+	}
+
+	/**
+	 * Returns async.
+	 *
+	 * @return Async.
+	 */
+	public get async(): boolean {
+		return this.getAttributeNS(null, 'async') !== null;
+	}
+
+	/**
+	 * Sets async.
+	 *
+	 * @param async Async.
+	 */
+	public set async(async: boolean) {
+		this.setAttributeNS(null, 'async', async ? '' : null);
+	}
+
+	/**
+	 * Returns defer.
+	 *
+	 * @return Defer.
+	 */
+	public get defer(): boolean {
+		return this.getAttributeNS(null, 'defer') !== null;
+	}
+
+	/**
+	 * Sets defer.
+	 *
+	 * @param defer Defer.
+	 */
+	public set defer(defer: boolean) {
+		this.setAttributeNS(null, 'defer', defer ? '' : null);
+	}
+
+	/**
+	 * Returns text.
+	 *
+	 * @return Text.
+	 */
+	public get text(): string {
+		return this.textContent;
+	}
+
+	/**
+	 * Sets text.
+	 *
+	 * @param text Text.
+	 */
+	public set text(text: string) {
+		this.textContent = text;
+	}
+}
diff --git a/packages/happy-dom/src/xml-parser/XMLParser.ts b/packages/happy-dom/src/xml-parser/XMLParser.ts
index b7124e90..59352618 100755
--- a/packages/happy-dom/src/xml-parser/XMLParser.ts
+++ b/packages/happy-dom/src/xml-parser/XMLParser.ts
@@ -5,6 +5,7 @@ import SelfClosingElements from '../config/SelfClosingElements';
 import UnnestableElements from '../config/UnnestableElements';
 import { decode } from 'he';
 import NamespaceURI from '../config/NamespaceURI';
+import HTMLScriptElement from 'src/nodes/html-script-element/HTMLScriptElement';
 
 const MARKUP_REGEXP = /<(\/?)([a-z][-.0-9_a-z]*)\s*([^>]*?)(\/?)>/gi;
 const COMMENT_REGEXP = /<!--(.*?)-->/gi;
@@ -22,9 +23,10 @@ export default class XMLParser {
 	 *
 	 * @param {Document} document Document.
 	 * @param data HTML data.
+	 * @param [evaluateScripts = false] Set to "true" to enable script execution.
 	 * @return Root element.
 	 */
-	public static parse(document: Document, data: string): Element {
+	public static parse(document: Document, data: string, evaluateScripts = false): Element {
 		const root = document.createElement('root');
 		const stack = [root];
 		const markupRegexp = new RegExp(MARKUP_REGEXP, 'gi');
@@ -46,6 +48,13 @@ export default class XMLParser {
 				const newElement = document.createElement(tagName);
 				const xmlnsAttribute = this.getXmlnsAttribute(match[3]);
 
+				// Scripts are not allowed to be executed when they are parsed using innerHTML, outerHTML, replaceWith() etc.
+				// However, they are allowed to be executed when document.write() is used.
+				// See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLScriptElement
+				if (tagName === 'script') {
+					(<HTMLScriptElement>newElement)._evaluateScript = evaluateScripts;
+				}
+
 				// The HTML engine can guess that the namespace is SVG for SVG tags
 				// even if "xmlns" is not set if the parent namespace is HTML.
 				if (tagName === 'svg' && parent.namespaceURI === NamespaceURI.html) {
diff --git a/packages/server-rendering/README.md b/packages/server-rendering/README.md
index 84880d5e..0a343df5 100644
--- a/packages/server-rendering/README.md
+++ b/packages/server-rendering/README.md
@@ -59,6 +59,7 @@ import { Script } from 'vm';
 const context = new HappyDOMContext();
 const result = context.render({
     url: 'http://localhost:8080',
+    evaluateScripts: true,
     html: `
         <html>
             <head>
diff --git a/packages/server-rendering/src/context/HappyDOMContext.ts b/packages/server-rendering/src/context/HappyDOMContext.ts
index 71385d38..150ac686 100644
--- a/packages/server-rendering/src/context/HappyDOMContext.ts
+++ b/packages/server-rendering/src/context/HappyDOMContext.ts
@@ -27,6 +27,7 @@ export default class HappyDOMContext {
 		html = null,
 		scripts = null,
 		url = null,
+		evaluateScripts = false,
 		customElements = {
 			openShadowRoots: false,
 			extractCSS: false,
@@ -37,6 +38,7 @@ export default class HappyDOMContext {
 		html: string;
 		scripts: VM.Script[];
 		url?: string;
+		evaluateScripts?: boolean;
 		customElements?: {
 			openShadowRoots: boolean;
 			extractCSS: boolean;
@@ -58,6 +60,10 @@ export default class HappyDOMContext {
 				scopeCSS: customElements.scopeCSS
 			});
 
+			if (!evaluateScripts) {
+				global.eval = () => {};
+			}
+
 			window
 				.whenAsyncComplete()
 				.then(() => {


Test patch:
diff --git a/packages/happy-dom/test/nodes/element/Element.test.ts b/packages/happy-dom/test/nodes/element/Element.test.ts
index a56669d0..5977e5b7 100644
--- a/packages/happy-dom/test/nodes/element/Element.test.ts
+++ b/packages/happy-dom/test/nodes/element/Element.test.ts
@@ -158,7 +158,7 @@ describe('Element', () => {
 	});
 
 	describe('get innerHTML()', () => {
-		test('Returns HTML an element and its children as a concatenated string.', () => {
+		test('Returns HTML of an elements children as a concatenated string.', () => {
 			const div = document.createElement('div');
 			const textNode1 = document.createTextNode('text1');
 
@@ -169,6 +169,34 @@ describe('Element', () => {
 		});
 	});
 
+	describe('get outerHTML()', () => {
+		test('Returns HTML of an element and its children as a concatenated string.', () => {
+			const div = document.createElement('div');
+			const textNode = document.createTextNode('text1');
+
+			div.appendChild(textNode);
+
+			element.appendChild(div);
+
+			expect(element.innerHTML).toBe('<div>text1</div>');
+		});
+	});
+
+	describe('set outerHTML()', () => {
+		test('Sets outer HTML of an element.', () => {
+			const div = document.createElement('div');
+			const textNode = document.createTextNode('text1');
+
+			div.appendChild(textNode);
+
+			element.appendChild(div);
+
+			div.outerHTML = '<span>text2</span>';
+
+			expect(element.innerHTML).toBe('<span>text2</span>');
+		});
+	});
+
 	describe('get attributes()', () => {
 		test('Returns all attributes as an object.', () => {
 			element.setAttribute('key1', 'value1');
diff --git a/packages/happy-dom/test/nodes/html-script-element/HTMLScriptElement.test.ts b/packages/happy-dom/test/nodes/html-script-element/HTMLScriptElement.test.ts
new file mode 100644
index 00000000..38a42366
--- /dev/null
+++ b/packages/happy-dom/test/nodes/html-script-element/HTMLScriptElement.test.ts
@@ -0,0 +1,120 @@
+import Window from '../../../src/window/Window';
+import Document from '../../../src/nodes/document/Document';
+import HTMLScriptElement from '../../../src/nodes/html-script-element/HTMLScriptElement';
+
+describe('HTMLScriptElement', () => {
+	let window: Window;
+	let document: Document;
+
+	beforeEach(() => {
+		window = new Window();
+		document = window.document;
+	});
+
+	for (const property of ['type', 'src', 'charset', 'lang']) {
+		describe(`get ${property}()`, () => {
+			test(`Returns the "${property}" attribute.`, () => {
+				const element = document.createElement('script');
+				element.setAttribute(property, 'test');
+				expect(element[property]).toBe('test');
+			});
+		});
+
+		describe(`set ${property}()`, () => {
+			test(`Sets the attribute "${property}".`, () => {
+				const element = document.createElement('script');
+				element[property] = 'test';
+				expect(element.getAttribute(property)).toBe('test');
+			});
+		});
+	}
+
+	for (const property of ['async', 'defer']) {
+		describe(`get ${property}()`, () => {
+			test(`Returns "true" if the "${property}" attribute is defined.`, () => {
+				const element = document.createElement('script');
+				element.setAttribute(property, '');
+				expect(element[property]).toBe(true);
+			});
+		});
+
+		describe(`set ${property}()`, () => {
+			test(`Sets the "${property}" attribute to an empty string if set to "true".`, () => {
+				const element = document.createElement('script');
+				element[property] = true;
+				expect(element.getAttribute(property)).toBe('');
+			});
+		});
+	}
+
+	describe('get text()', () => {
+		test('Returns the data of text nodes.', () => {
+			const element = <HTMLScriptElement>document.createElement('script');
+			const text = document.createTextNode('test');
+			element.appendChild(text);
+			expect(element.text).toBe('test');
+		});
+
+		test('Replaces all child nodes with a text node.', () => {
+			const element = <HTMLScriptElement>document.createElement('script');
+			const text = document.createTextNode('test');
+			element.appendChild(text);
+			element.text = 'test2';
+			expect(element.text).toBe('test2');
+		});
+	});
+
+	describe('set isConnected()', () => {
+		test('Evaluates the text content as code when appended to an element that is connected to the document.', () => {
+			const element = <HTMLScriptElement>document.createElement('script');
+			element.text = 'global.test = "test";';
+			document.body.appendChild(element);
+			expect(global['test']).toBe('test');
+			delete global['test'];
+		});
+
+		test('Evaluates the text content as code when inserted before an element that is connected to the document.', () => {
+			const element = <HTMLScriptElement>document.createElement('script');
+			const div1 = document.createElement('div');
+			const div2 = document.createElement('div');
+
+			element.text = 'global.test = "test";';
+
+			div1.appendChild(element);
+
+			document.body.appendChild(div2);
+			document.body.insertBefore(div1, div2);
+			document.body.appendChild(element);
+
+			expect(global['test']).toBe('test');
+			delete global['test'];
+		});
+
+		test('Does not evaluate code when added as innerHTML.', () => {
+			const div = document.createElement('div');
+			div.innerHTML = '<script>global.test = "test";</script>';
+			document.body.appendChild(div);
+			expect(global['test']).toBe(undefined);
+		});
+
+		test('Does not evaluate code when added as outerHTML.', () => {
+			const div = document.createElement('div');
+			document.body.appendChild(div);
+			div.outerHTML = '<script>global.test = "test";</script>';
+			expect(global['test']).toBe(undefined);
+		});
+
+		test('Evaluates the text content as code when using document.write().', () => {
+			document.write('<script>global.test = "test";</script>');
+			expect(global['test']).toBe('test');
+			delete global['test'];
+		});
+
+		test('Evaluates the text content as code when using DOMParser.parseFromString().', () => {
+			const domParser = new window.DOMParser();
+			domParser.parseFromString('<script>global.test = "test";</script>', 'text/html');
+			expect(global['test']).toBe('test');
+			delete global['test'];
+		});
+	});
+});