@serenity-is/corelib
Version:
Serenity Core Library
616 lines (514 loc) • 23.2 kB
text/typescript
import { Config } from "./config";
import { addClass, appendToNode, cssEscape, getElementReadOnly, getReturnUrl, htmlEncode, parseQueryString, removeClass, sanitizeUrl, setElementReadOnly, toggleClass } from "./html";
describe("htmlEncode", () => {
it("encodes html", () => {
expect(htmlEncode("<div>test</div>")).toBe("<div>test</div>");
expect(htmlEncode("'&><\"")).toBe("'&><"");
});
it("handles null and empty string values", () => {
expect(htmlEncode(null)).toBe("");
expect(htmlEncode(undefined)).toBe("");
expect(htmlEncode("")).toBe("");
});
it("converts other types to string", () => {
expect(htmlEncode(1)).toBe("1");
expect(htmlEncode(true)).toBe("true");
expect(htmlEncode(false)).toBe("false");
});
});
describe("toggleClass", () => {
it("does nothing if class is null or empty", () => {
const el = document.createElement("div");
el.setAttribute("class", "test1 test2");
toggleClass(el, null, true);
expect(el.getAttribute("class")).toBe("test1 test2");
toggleClass(el, undefined, false);
expect(el.getAttribute("class")).toBe("test1 test2");
toggleClass(el, '', false);
expect(el.getAttribute("class")).toBe("test1 test2");
});
it("can toggle single class", () => {
const el = document.createElement("div");
el.setAttribute("class", "test1 test2");
toggleClass(el, "test1");
expect(el.getAttribute("class")).toBe("test2");
toggleClass(el, "test3");
expect(el.getAttribute("class")).toBe("test2 test3");
toggleClass(el, "test3");
expect(el.getAttribute("class")).toBe("test2");
});
it("can toggle multiple classes", () => {
const el = document.createElement("div");
el.setAttribute("class", "test1 test2");
toggleClass(el, "test1 test2");
expect(el.getAttribute("class")).toBe("");
toggleClass(el, "test3 test4");
expect(el.getAttribute("class")).toBe("test3 test4");
toggleClass(el, "test5 test3 test6");
expect(el.getAttribute("class")).toBe("test4 test5 test6");
});
it("adds classes if third parameter is true", () => {
const el = document.createElement("div");
el.setAttribute("class", "test1 test2");
toggleClass(el, "test1 test2", true);
expect(el.getAttribute("class")).toBe("test1 test2");
toggleClass(el, "test3 test4", true);
expect(el.getAttribute("class")).toBe("test1 test2 test3 test4");
toggleClass(el, "test5 test3 test6", true);
expect(el.getAttribute("class")).toBe("test1 test2 test3 test4 test5 test6");
});
it("removes classes if third parameter is false", () => {
const el = document.createElement("div");
el.setAttribute("class", "test1 test2");
toggleClass(el, "test1 test2", false);
expect(el.getAttribute("class")).toBe("");
});
it("ignores whitespace", () => {
const el = document.createElement("div");
el.setAttribute("class", "test1 test2");
toggleClass(el, "test1 test2", false);
expect(el.getAttribute("class")).toBe("");
});
});
describe("addClass", () => {
it("adds class to the element", () => {
const el = document.createElement("div");
el.setAttribute("class", "test1");
addClass(el, "test2");
expect(el.getAttribute("class")).toBe("test1 test2");
});
it("does nothing if class is null or empty", () => {
const el = document.createElement("div");
el.setAttribute("class", "test1");
addClass(el, null);
expect(el.getAttribute("class")).toBe("test1");
addClass(el, undefined);
expect(el.getAttribute("class")).toBe("test1");
addClass(el, '');
expect(el.getAttribute("class")).toBe("test1");
});
it("does not add duplicate classes", () => {
const el = document.createElement("div");
el.setAttribute("class", "test1");
addClass(el, "test1");
expect(el.getAttribute("class")).toBe("test1");
});
});
describe("removeClass", () => {
it("removes class from the element", () => {
const el = document.createElement("div");
el.setAttribute("class", "test1 test2");
removeClass(el, "test2");
expect(el.getAttribute("class")).toBe("test1");
});
it("does nothing if class is null or empty", () => {
const el = document.createElement("div");
el.setAttribute("class", "test1");
removeClass(el, null);
expect(el.getAttribute("class")).toBe("test1");
removeClass(el, undefined);
expect(el.getAttribute("class")).toBe("test1");
removeClass(el, '');
expect(el.getAttribute("class")).toBe("test1");
});
it("does not remove non-existing classes", () => {
const el = document.createElement("div");
el.setAttribute("class", "test1");
removeClass(el, "test2");
expect(el.getAttribute("class")).toBe("test1");
});
});
describe("appendToNode", () => {
it("throws on null parent", () => {
expect(() => appendToNode(null, document.createElement("div"))).toThrow();
});
it("ignores null content", () => {
const parent = document.createElement("div");
appendToNode(parent, null);
expect(parent.innerHTML).toBe("");
});
it("ignores false content", () => {
const parent = document.createElement("div");
appendToNode(parent, false);
expect(parent.innerHTML).toBe("");
});
it("ignores array with false and null content", () => {
const parent = document.createElement("div");
appendToNode(parent, [false, null]);
expect(parent.innerHTML).toBe("");
});
it("appends array elements", () => {
const parent = document.createElement("div");
const div1 = document.createElement("div");
div1.innerHTML = "1";
const div2 = document.createElement("div");
div2.innerHTML = "2";
appendToNode(parent, [div1, div2]);
expect(parent.innerHTML).toBe("<div>1</div><div>2</div>");
expect(parent.firstChild).toBe(div1);
expect(parent.lastChild).toBe(div2);
});
it("creates text node from string", () => {
const parent = document.createElement("div");
appendToNode(parent, "test");
expect(parent.innerHTML).toBe("test");
expect(parent.firstChild?.nodeType).toBe(Node.TEXT_NODE);
expect(parent.firstChild.textContent).toBe("test");
});
it("can wait for promise to resolve", async () => {
const parent = document.createElement("div");
const div = document.createElement("div");
div.innerHTML = "test";
appendToNode(parent, Promise.resolve(div));
expect(parent.innerHTML).toBe("<!--Loading content...-->");
await Promise.resolve();
expect(parent.innerHTML).toBe("<div>test</div>");
});
it("handles rejected promise", () => new Promise(async done => {
const parent = document.createElement("div");
const div = document.createElement("div");
div.innerHTML = "test";
const unhandledRejection = () => {
done(void 0);
globalThis.process?.off ? globalThis.process.off("unhandledRejection", unhandledRejection) :
window.removeEventListener("unhandledrejection", unhandledRejection);
};
globalThis.process?.on ? globalThis.process.on("unhandledRejection", unhandledRejection) :
window.addEventListener("unhandledrejection", unhandledRejection);
appendToNode(parent, Promise.reject("some reject reason"));
expect(parent.innerHTML).toBe("<!--Loading content...-->");
await Promise.resolve();
expect(parent.innerHTML).toBe("<!--Error loading content: some reject reason-->");
}));
it("calls append for other content types", () => {
const parent = document.createElement("div");
const content = { abc: 5 };
appendToNode(parent, content);
expect(parent.innerHTML).toBe("[object Object]");
});
});
describe("getElementReadOnly", () => {
it("returns null if element is null", () => {
expect(getElementReadOnly(null)).toBeNull();
});
it("returns true if element has readonly class", () => {
const el = document.createElement("div");
el.classList.add("readonly");
expect(getElementReadOnly(el)).toBe(true);
});
it("returns true if element is a select and has disabled attribute", () => {
const el = document.createElement("select");
el.setAttribute("disabled", "disabled");
expect(getElementReadOnly(el)).toBe(true);
});
it("returns true if element is a radio and has disabled attribute", () => {
const el = document.createElement("input");
el.setAttribute("type", "radio");
el.setAttribute("disabled", "disabled");
expect(getElementReadOnly(el)).toBe(true);
});
it("returns true if element is a checkbox and has disabled attribute", () => {
const el = document.createElement("input");
el.setAttribute("type", "checkbox");
el.setAttribute("disabled", "disabled");
expect(getElementReadOnly(el)).toBe(true);
});
it("returns true if element has readonly attribute", () => {
const el = document.createElement("input");
el.setAttribute("readonly", "readonly");
expect(getElementReadOnly(el)).toBe(true);
});
it("returns false if element does not have readonly class or attributes", () => {
const el = document.createElement("input");
expect(getElementReadOnly(el)).toBe(false);
});
it("returns false if element is a select without disabled attribute", () => {
const el = document.createElement("select");
expect(getElementReadOnly(el)).toBe(false);
});
it("returns false if element is a radio without disabled attribute", () => {
const el = document.createElement("input");
el.setAttribute("type", "radio");
expect(getElementReadOnly(el)).toBe(false);
});
it("returns false if element is a checkbox without disabled attribute", () => {
const el = document.createElement("input");
el.setAttribute("type", "checkbox");
expect(getElementReadOnly(el)).toBe(false);
});
});
describe("setElementReadOnly", () => {
it("ignores null elements", () => {
setElementReadOnly(null, true);
setElementReadOnly([null], true);
});
it("sets readonly class and attribute for single element", () => {
const el = document.createElement("input");
setElementReadOnly(el, true);
expect(el.classList.contains("readonly")).toBe(true);
expect(el.hasAttribute("readonly")).toBe(true);
});
it("removes readonly class and attribute for single element", () => {
const el = document.createElement("input");
el.classList.add("readonly");
el.setAttribute("readonly", "readonly");
setElementReadOnly(el, false);
expect(el.classList.contains("readonly")).toBe(false);
expect(el.hasAttribute("readonly")).toBe(false);
});
it("sets disabled attribute for select element", () => {
const el = document.createElement("select");
setElementReadOnly(el, true);
expect(el.classList.contains("readonly")).toBe(true);
expect(el.hasAttribute("disabled")).toBe(true);
});
it("removes disabled attribute for select element", () => {
const el = document.createElement("select");
el.classList.add("readonly");
el.setAttribute("disabled", "disabled");
setElementReadOnly(el, false);
expect(el.classList.contains("readonly")).toBe(false);
expect(el.hasAttribute("disabled")).toBe(false);
});
it("sets disabled attribute for radio input", () => {
const el = document.createElement("input");
el.setAttribute("type", "radio");
setElementReadOnly(el, true);
expect(el.classList.contains("readonly")).toBe(true);
expect(el.hasAttribute("disabled")).toBe(true);
});
it("removes disabled attribute for radio input", () => {
const el = document.createElement("input");
el.setAttribute("type", "radio");
el.classList.add("readonly");
el.setAttribute("disabled", "disabled");
setElementReadOnly(el, false);
expect(el.classList.contains("readonly")).toBe(false);
expect(el.hasAttribute("disabled")).toBe(false);
});
it("sets disabled attribute for checkbox input", () => {
const el = document.createElement("input");
el.setAttribute("type", "checkbox");
setElementReadOnly(el, true);
expect(el.classList.contains("readonly")).toBe(true);
expect(el.hasAttribute("disabled")).toBe(true);
});
it("removes disabled attribute for checkbox input", () => {
const el = document.createElement("input");
el.setAttribute("type", "checkbox");
el.classList.add("readonly");
el.setAttribute("disabled", "disabled");
setElementReadOnly(el, false);
expect(el.classList.contains("readonly")).toBe(false);
expect(el.hasAttribute("disabled")).toBe(false);
});
it("sets readonly class and attribute for multiple elements", () => {
const el1 = document.createElement("input");
const el2 = document.createElement("select");
setElementReadOnly([el1, el2], true);
expect(el1.classList.contains("readonly")).toBe(true);
expect(el1.hasAttribute("readonly")).toBe(true);
expect(el2.classList.contains("readonly")).toBe(true);
expect(el2.hasAttribute("disabled")).toBe(true);
});
it("removes readonly class and attribute for multiple elements", () => {
const el1 = document.createElement("input");
el1.classList.add("readonly");
el1.setAttribute("readonly", "readonly");
const el2 = document.createElement("select");
el2.classList.add("readonly");
el2.setAttribute("disabled", "disabled");
setElementReadOnly([el1, el2], false);
expect(el1.classList.contains("readonly")).toBe(false);
expect(el1.hasAttribute("readonly")).toBe(false);
expect(el2.classList.contains("readonly")).toBe(false);
expect(el2.hasAttribute("disabled")).toBe(false);
});
});
describe("sanitizeUrl", () => {
it("returns 'about:blank' for null, empty string, or 'about:blank'", () => {
expect(sanitizeUrl(null)).toBe("about:blank");
expect(sanitizeUrl("")).toBe("about:blank");
expect(sanitizeUrl("about:blank")).toBe("about:blank");
});
it("returns the same URL if it matches SAFE_URL_PATTERN", () => {
expect(sanitizeUrl("http://example.com")).toBe("http://example.com");
expect(sanitizeUrl("https://example.com")).toBe("https://example.com");
expect(sanitizeUrl("mailto:test@example.com")).toBe("mailto:test@example.com");
expect(sanitizeUrl("ftp://example.com")).toBe("ftp://example.com");
expect(sanitizeUrl("tel:+1234567890")).toBe("tel:+1234567890");
expect(sanitizeUrl("file:///C:/path/to/file")).toBe("file:///C:/path/to/file");
expect(sanitizeUrl("sms:+1234567890")).toBe("sms:+1234567890");
});
it("returns the same URL if it matches DATA_URL_PATTERN", () => {
expect(sanitizeUrl("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA")).toBe("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA");
expect(sanitizeUrl("data:video/mp4;base64,AAAAFGZ0eXBtcDQyAAAAAG1w")).toBe("data:video/mp4;base64,AAAAFGZ0eXBtcDQyAAAAAG1w");
expect(sanitizeUrl("data:audio/mp3;base64,SUQzAwAAAAAA")).toBe("data:audio/mp3;base64,SUQzAwAAAAAA");
});
it("returns 'unsafe:' prefixed URL for unsafe URLs", () => {
expect(sanitizeUrl("javascript:alert('XSS')")).toBe("unsafe:javascript:alert('XSS')");
expect(sanitizeUrl("data:text/html;base64,PHNjcmlwdD5hbGVydCgnWFNTJyk8L3NjcmlwdD4=")).toBe("unsafe:data:text/html;base64,PHNjcmlwdD5hbGVydCgnWFNTJyk8L3NjcmlwdD4=");
});
it("returns 'javascript:void(0)' and 'javascript:;' as is", () => {
expect(sanitizeUrl("javascript:void(0)")).toBe("javascript:void(0)");
expect(sanitizeUrl("javascript:;")).toBe("javascript:;");
});
it("trims the URL before sanitizing", () => {
expect(sanitizeUrl(" http://example.com ")).toBe("http://example.com");
expect(sanitizeUrl(" javascript:alert('XSS') ")).toBe("unsafe:javascript:alert('XSS')");
});
});
describe("parseQueryString", () => {
it("parses query string into object", () => {
const queryString = "param1=value1¶m2=value2";
const result = parseQueryString(queryString);
expect(result).toEqual({ param1: "value1", param2: "value2" });
});
it("decodes URI components", () => {
const queryString = "param1=value%201¶m2=value%202";
const result = parseQueryString(queryString);
expect(result).toEqual({ param1: "value 1", param2: "value 2" });
});
it("handles empty query string", () => {
const result = parseQueryString("");
expect(result).toEqual({});
});
it("handles query string with no value", () => {
const queryString = "param1¶m2";
const result = parseQueryString(queryString);
expect(result).toEqual({ param1: "param1", param2: "param2" });
});
it("handles query string with empty value", () => {
const queryString = "param1=¶m2=";
const result = parseQueryString(queryString);
expect(result).toEqual({ param1: "", param2: "" });
});
it("handles query string with special characters", () => {
const queryString = "param1=value1¶m2=value@2¶m3=value/3";
const result = parseQueryString(queryString);
expect(result).toEqual({ param1: "value1", param2: "value@2", param3: "value/3" });
});
it("parses query string from location.search if no argument is provided", () => {
var oldLocation = window.location.href;
if (changeJSDOMURL("http://localhost?param1=value1¶m2=value2")) {
try {
const result = parseQueryString();
expect(result).toEqual({ param1: "value1", param2: "value2" });
} finally {
changeJSDOMURL(oldLocation);
}
}
});
});
describe("getReturnUrl", () => {
it("returns returnUrl from query string if it is safe", () => {
const oldLocation = window.location.href;
if (changeJSDOMURL("http://localhost?returnUrl=/safe/path"))
try {
const result = getReturnUrl();
expect(result).toBe("/safe/path");
} finally {
changeJSDOMURL(oldLocation);
}
});
it("returns null if returnUrl from query string is unsafe", () => {
const oldLocation = window.location.href;
if (changeJSDOMURL("http://localhost?returnUrl=http://unsafe.com"))
try {
const result = getReturnUrl();
expect(result).toBeNull();
} finally {
changeJSDOMURL(oldLocation);
}
});
it("returns default returnUrl if queryOnly is false and no returnUrl in query string", () => {
const oldLocation = window.location.href;
if (changeJSDOMURL("http://localhost"))
try {
const result = getReturnUrl({ queryOnly: false });
expect(result).toBe(Config.defaultReturnUrl());
} finally {
changeJSDOMURL(oldLocation);
}
});
it("returns undefined if queryOnly is true and no returnUrl in query string", () => {
const oldLocation = window.location.href;
if (changeJSDOMURL("http://localhost"))
try {
const result = getReturnUrl({ queryOnly: true });
expect(result).toBeUndefined();
} finally {
changeJSDOMURL(oldLocation);
}
});
it("returns returnUrl from query string if ignoreUnsafe is true", () => {
const oldLocation = window.location.href;
if (changeJSDOMURL("http://localhost?returnUrl=http://unsafe.com"))
try {
const result = getReturnUrl({ ignoreUnsafe: true });
expect(result).toBe("http://unsafe.com");
} finally {
changeJSDOMURL(oldLocation);
}
});
it("returns default returnUrl with purpose if provided", () => {
const oldLocation = window.location.href;
if (changeJSDOMURL("http://localhost"))
try {
const result = getReturnUrl({ purpose: "test" });
expect(result).toBe(Config.defaultReturnUrl("test"));
} finally {
changeJSDOMURL(oldLocation);
}
});
});
function changeJSDOMURL(url: string) {
if ((globalThis as any).jsdom?.reconfigure) {
(globalThis as any).jsdom.reconfigure({ url: url });
return true;
}
else
return false;
}
describe("cssEscape", () => {
it("escapes leading dash", () => {
expect(cssEscape("-")).toBe("\\-");
expect(cssEscape("-test")).toBe("-test");
});
it("escapes null character", () => {
expect(cssEscape("\u0000")).toBe("\uFFFD");
expect(cssEscape("a\u0000b")).toBe("a\uFFFDb");
});
it("escapes control characters and DEL", () => {
expect(cssEscape("\u0001")).toBe("\\1 ");
expect(cssEscape("\u001F")).toBe("\\1f ");
expect(cssEscape("\u007F")).toBe("\\7f ");
});
it("escapes digit at start", () => {
expect(cssEscape("1abc")).toBe("\\31 abc");
expect(cssEscape("2")).toBe("\\32 ");
});
it("escapes digit at index 1 if first char is dash", () => {
expect(cssEscape("-1abc")).toBe("-\\31 abc");
expect(cssEscape("-2")).toBe("-\\32 ");
});
it("does not escape ASCII letters, digits (except at start), dash, underscore", () => {
expect(cssEscape("abcABC123-_")).toBe("abcABC123-_");
});
it("escapes special characters", () => {
expect(cssEscape("a b.c#d:e;f[g]")).toBe("a\\ b\\.c\\#d\\:e\\;f\\[g\\]");
});
it("escapes non-ASCII characters", () => {
expect(cssEscape("üñîçødë")).toBe("üñîçødë");
expect(cssEscape("测试")).toBe("测试");
});
it("returns empty string for empty input", () => {
expect(cssEscape("")).toBe("");
});
it("uses CSS.escape if available", () => {
const orig = (globalThis as any).CSS;
(globalThis as any).CSS = { escape: (s: string) => "ESCAPED:" + s };
expect(cssEscape("test")).toBe("ESCAPED:test");
(globalThis as any).CSS = orig;
});
});