Repository: capricorn86/happy-dom
Pull request #157 (2020-11-18 21:21:39)

#155: Adds support for Document.cookie


Patch:
diff --git a/packages/happy-dom/README.md b/packages/happy-dom/README.md
index a1ffedf6..e53037d4 100644
--- a/packages/happy-dom/README.md
+++ b/packages/happy-dom/README.md
@@ -61,16 +61,7 @@ import { Window } from 'happy-dom';
 const window = new Window();
 const document = window.document;
 
-document.body.innerHTML = `
-    <html>
-        <head>
-             <title>Test page</title>
-        </head>
-        <body>
-             <div class="myContainer"></div>
-        </body>
-    </html>
-`;
+document.body.innerHTML = '<div class="myContainer"></div>';
 
 const myContainer = document.querySelector('.myContainer');
 const button = document.createElement('button');
diff --git a/packages/happy-dom/src/cookie/CookieUtility.ts b/packages/happy-dom/src/cookie/CookieUtility.ts
new file mode 100644
index 00000000..120b1880
--- /dev/null
+++ b/packages/happy-dom/src/cookie/CookieUtility.ts
@@ -0,0 +1,90 @@
+import Location from '../location/Location';
+
+/**
+ * Cookie utility.
+ */
+export default class CookieUtility {
+	/**
+	 * Returns a cookie string.
+	 *
+	 * @param location Location.
+	 * @param cookies Current cookie string.
+	 * @param newCookie New cookie string.
+	 * @returns Generated cookie string.
+	 */
+	public static getCookieString(location: Location, cookies: string, newCookie): string {
+		const newCookieParts = newCookie.split(';');
+		const [newCookieName, newCookieValue] = newCookieParts
+			.shift()
+			.trim()
+			.split('=');
+		let isExpired = false;
+
+		for (const part of newCookieParts) {
+			const [key, value] = part.trim().split('=');
+
+			switch (key.toLowerCase()) {
+				case 'expires':
+					const expires = new Date(value).getTime();
+					const now = Date.now();
+					if (expires < now) {
+						isExpired = true;
+						break;
+					}
+					break;
+				case 'domain':
+					const hostnameParts = location.hostname.split('.');
+					if (hostnameParts.length > 2) {
+						hostnameParts.shift();
+					}
+					const currentDomain = hostnameParts.join('.');
+					if (!value.endsWith(currentDomain)) {
+						return cookies;
+					}
+					break;
+				case 'path':
+					const pathname = location.pathname;
+					const currentPath = pathname.startsWith('/') ? pathname.replace('/', '') : pathname;
+					const path = value.startsWith('/') ? value.replace('/', '') : value;
+					if (path && !currentPath.startsWith(path)) {
+						return cookies;
+					}
+					break;
+				case 'max-age':
+					if (parseInt(value) <= 0) {
+						return cookies;
+					}
+					break;
+			}
+		}
+
+		const newCookies = [];
+
+		if (cookies) {
+			for (const cookie of cookies.split(';')) {
+				const [name, value] = cookie.trim().split('=');
+				if (
+					(name && name !== newCookieName) ||
+					(!value && newCookieValue) ||
+					(value && !newCookieValue)
+				) {
+					if (value) {
+						newCookies.push(`${name}=${value}`);
+					} else {
+						newCookies.push(name);
+					}
+				}
+			}
+		}
+
+		if (!isExpired) {
+			if (newCookieValue) {
+				newCookies.push(`${newCookieName}=${newCookieValue}`);
+			} else {
+				newCookies.push(newCookieName);
+			}
+		}
+
+		return newCookies.join('; ');
+	}
+}
diff --git a/packages/happy-dom/src/location/URL.ts b/packages/happy-dom/src/location/URL.ts
index 98af31df..fe5a9f26 100644
--- a/packages/happy-dom/src/location/URL.ts
+++ b/packages/happy-dom/src/location/URL.ts
@@ -1,6 +1,5 @@
-import DOMException from '../exception/DOMException';
-
 const URL_REGEXP = /(https?:)\/\/([-a-zA-Z0-9@:%._\+~#=]{2,256}[a-z]{2,6})(:[0-9]*)?([-a-zA-Z0-9@:%_\+.~c&//=]*)(\?[^#]*)?(#.*)?/;
+const PATH_REGEXP = /([-a-zA-Z0-9@:%_\+.~c&//=]*)(\?[^#]*)?(#.*)?/;
 
 export default class URL {
 	public protocol = '';
@@ -15,9 +14,9 @@ export default class URL {
 	/**
 	 * Constructor.
 	 *
-	 * @param url URL.
+	 * @param [url] URL.
 	 */
-	constructor(url: string) {
+	constructor(url?: string) {
 		if (url) {
 			this.parse(url);
 		}
@@ -37,21 +36,27 @@ export default class URL {
 	 */
 	protected parse(url: string): void {
 		const match = url.match(URL_REGEXP);
-		if (!match) {
-			throw new DOMException('Failed to parse URL "' + url + '". URL is in an invalid format.');
-		}
 
-		const hostnamePart = match[2] ? match[2].split('@') : '';
-		const credentialsPart = hostnamePart.length > 1 ? hostnamePart[0].split(':') : null;
+		if (match) {
+			const hostnamePart = match[2] ? match[2].split('@') : '';
+			const credentialsPart = hostnamePart.length > 1 ? hostnamePart[0].split(':') : null;
 
-		this.protocol = match[1] || '';
-		this.hostname = hostnamePart.length > 1 ? hostnamePart[1] : hostnamePart[0];
-		this.port = match[3] || '';
-		this.pathname = match[4] || '';
-		this.search = match[5] || '';
-		this.hash = match[6] || '';
-		this.username = credentialsPart ? credentialsPart[0] : '';
-		this.password = credentialsPart ? credentialsPart[1] : '';
+			this.protocol = match[1] || '';
+			this.hostname = hostnamePart.length > 1 ? hostnamePart[1] : hostnamePart[0];
+			this.port = match[3] || '';
+			this.pathname = match[4] || '';
+			this.search = match[5] || '';
+			this.hash = match[6] || '';
+			this.username = credentialsPart ? credentialsPart[0] : '';
+			this.password = credentialsPart ? credentialsPart[1] : '';
+		} else {
+			const pathMatch = url.match(PATH_REGEXP);
+			if (pathMatch) {
+				this.pathname = pathMatch[1] || '';
+				this.search = pathMatch[2] || '';
+				this.hash = pathMatch[3] || '';
+			}
+		}
 	}
 
 	/**
diff --git a/packages/happy-dom/src/nodes/document/Document.ts b/packages/happy-dom/src/nodes/document/Document.ts
index 02e5d55e..d482ece1 100644
--- a/packages/happy-dom/src/nodes/document/Document.ts
+++ b/packages/happy-dom/src/nodes/document/Document.ts
@@ -19,6 +19,7 @@ import QuerySelector from '../../query-selector/QuerySelector';
 import IDocument from './IDocument';
 import CSSStyleSheet from '../../css/CSSStyleSheet';
 import DOMException from '../../exception/DOMException';
+import CookieUtility from '../../cookie/CookieUtility';
 
 /**
  * Document.
@@ -32,6 +33,7 @@ export default class Document extends Node implements IDocument {
 	protected _isFirstWriteAfterOpen = false;
 	public implementation: DOMImplementation;
 	public readonly children: Element[] = [];
+	private _cookie = '';
 
 	/**
 	 * Creates an instance of Document.
@@ -81,6 +83,24 @@ export default class Document extends Node implements IDocument {
 		return this.children ? this.children[this.children.length - 1] || null : null;
 	}
 
+	/**
+	 * Returns cookie string.
+	 *
+	 * @return Cookie.
+	 */
+	public get cookie(): string {
+		return this._cookie;
+	}
+
+	/**
+	 * Sets a cookie string.
+	 *
+	 * @param cookie Cookie string.
+	 */
+	public set cookie(cookie: string) {
+		this._cookie = CookieUtility.getCookieString(this.defaultView.location, this._cookie, cookie);
+	}
+
 	/**
 	 * Inserts a set of Node objects or DOMString objects after the last child of the ParentNode. DOMString objects are inserted as equivalent Text nodes.
 	 *
diff --git a/packages/happy-dom/src/xml-parser/XMLParser.ts b/packages/happy-dom/src/xml-parser/XMLParser.ts
index 59352618..72c9779b 100755
--- a/packages/happy-dom/src/xml-parser/XMLParser.ts
+++ b/packages/happy-dom/src/xml-parser/XMLParser.ts
@@ -5,7 +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';
+import HTMLScriptElement from '../nodes/html-script-element/HTMLScriptElement';
 
 const MARKUP_REGEXP = /<(\/?)([a-z][-.0-9_a-z]*)\s*([^>]*?)(\/?)>/gi;
 const COMMENT_REGEXP = /<!--(.*?)-->/gi;


Test patch:
diff --git a/packages/happy-dom/test/nodes/document/Document.test.ts b/packages/happy-dom/test/nodes/document/Document.test.ts
index 6ca7b56a..0019b08c 100644
--- a/packages/happy-dom/test/nodes/document/Document.test.ts
+++ b/packages/happy-dom/test/nodes/document/Document.test.ts
@@ -93,6 +93,84 @@ describe('Document', () => {
 		});
 	});
 
+	describe('get cookie()', () => {
+		test('Returns cookie string.', () => {
+			document.cookie = 'name=value1';
+			expect(document.cookie).toBe('name=value1');
+		});
+	});
+
+	describe('set cookie()', () => {
+		test('Sets multiple cookies.', () => {
+			document.cookie = 'name1=value1';
+			document.cookie = 'name2=value2';
+			expect(document.cookie).toBe('name1=value1; name2=value2');
+		});
+
+		test('Replaces cookie with the same name, but treats cookies with no value set differently from cookies with a value.', () => {
+			document.cookie = 'name=value1';
+			document.cookie = 'name';
+			document.cookie = 'name=value2';
+			document.cookie = 'name';
+			expect(document.cookie).toBe('name=value2; name');
+		});
+
+		test('Sets a cookie with a domain.', () => {
+			window.location.href = 'https://test.com';
+			document.cookie = 'name=value1; domain=test.com';
+			expect(document.cookie).toBe('name=value1');
+		});
+
+		test('Sets a cookie with an invalid domain.', () => {
+			window.location.href = 'https://test.com';
+			document.cookie = 'name=value1; domain=invalid.com';
+			expect(document.cookie).toBe('');
+		});
+
+		test('Sets a cookie on a top-domain from a sub-domain.', () => {
+			window.location.href = 'https://sub.test.com';
+			document.cookie = 'name=value1; domain=test.com';
+			expect(document.cookie).toBe('name=value1');
+		});
+
+		test('Sets a cookie with a path.', () => {
+			window.location.href = '/path/to/cookie/';
+			document.cookie = 'name1=value1; path=path/to';
+			document.cookie = 'name2=value2; path=/path/to';
+			document.cookie = 'name3=value3; path=/path/to/cookie/';
+			expect(document.cookie).toBe('name1=value1; name2=value2; name3=value3');
+		});
+
+		test('Does not set cookie if the path does not match the current path.', () => {
+			window.location.href = '/path/to/cookie/';
+			document.cookie = 'name1=value1; path=/cookie/';
+			expect(document.cookie).toBe('');
+		});
+
+		test('Sets a cookie if expires is in the future.', () => {
+			const date = new Date();
+			const oneHour = 3600000;
+			date.setTime(date.getTime() + oneHour);
+			const expires = date.getUTCDate();
+			document.cookie = `name=value1; expires=${expires}`;
+			expect(document.cookie).toBe('name=value1');
+		});
+
+		test('Does not set cookie if "expires" is in the past.', () => {
+			document.cookie = 'name=value1; expires=Thu, 01 Jan 1970 00:00:01 GMT';
+			expect(document.cookie).toBe('');
+		});
+
+		test('Removes a previously defined cookie if "expires" is in the past, but treats cookies with no value set differently from cookies with a value.', () => {
+			document.cookie = 'name=value1';
+			document.cookie = 'name';
+			document.cookie = 'name=value1; expires=Thu, 01 Jan 1970 00:00:01 GMT';
+			expect(document.cookie).toBe('name');
+			document.cookie = 'name; expires=Thu, 01 Jan 1970 00:00:01 GMT';
+			expect(document.cookie).toBe('');
+		});
+	});
+
 	describe('append()', () => {
 		test('Inserts a set of Node objects or DOMString objects after the last child of the ParentNode. DOMString objects are inserted as equivalent Text nodes.', () => {
 			const node1 = document.createComment('test1');