Update MicronParser with DOMPurify integration and code improvements

- Added global DOMPurify reference for HTML sanitization.
- Improved error handling for DOMPurify initialization.
- Refactored style definitions and formatting for consistency.
- Updated tests to reflect changes in MicronParser initialization and behavior.

This update enhances security and code readability while ensuring robust functionality.
This commit is contained in:
Sudo-Ivan
2026-01-23 11:28:43 -06:00
parent fc0a2444ad
commit af0d9352c2
3 changed files with 182 additions and 162 deletions
+137 -119
View File
@@ -1,3 +1,4 @@
/* global DOMPurify */
/**
* Micron Parser JavaScript implementation
*
@@ -7,9 +8,8 @@
* Documentation for the Micron markdown format can be found here:
* https://raw.githubusercontent.com/markqvist/NomadNet/refs/heads/master/nomadnet/ui/textui/Guide.py
*/
class MicronParser {
class MicronParser {
constructor(darkTheme = true, enableForceMonospace = true) {
this.darkTheme = darkTheme;
this.enableForceMonospace = enableForceMonospace;
@@ -22,38 +22,39 @@ class MicronParser {
}
try {
if (typeof DOMPurify === 'undefined') {
console.warn('DOMPurify is not installed. Include it above micron-parser.js or run npm install dompurify');
if (typeof DOMPurify === "undefined") {
console.warn(
"DOMPurify is not installed. Include it above micron-parser.js or run npm install dompurify"
);
}
} catch (error) {
console.warn('DOMPurify is not installed. Include it above micron-parser.js or run npm install dompurify');
} catch {
console.warn("DOMPurify is not installed. Include it above micron-parser.js or run npm install dompurify");
}
this.STYLES_DARK = {
"plain": {fg: this.DEFAULT_FG_DARK, bg: this.DEFAULT_BG, bold: false, underline: false, italic: false},
"heading1": {fg: "222", bg: "bbb", bold: false, underline: false, italic: false},
"heading2": {fg: "111", bg: "999", bold: false, underline: false, italic: false},
"heading3": {fg: "000", bg: "777", bold: false, underline: false, italic: false}
plain: { fg: this.DEFAULT_FG_DARK, bg: this.DEFAULT_BG, bold: false, underline: false, italic: false },
heading1: { fg: "222", bg: "bbb", bold: false, underline: false, italic: false },
heading2: { fg: "111", bg: "999", bold: false, underline: false, italic: false },
heading3: { fg: "000", bg: "777", bold: false, underline: false, italic: false },
};
this.STYLES_LIGHT = {
"plain": {fg: this.DEFAULT_FG_LIGHT, bg: this.DEFAULT_BG, bold: false, underline: false, italic: false},
"heading1": {fg: "000", bg: "777", bold: false, underline: false, italic: false},
"heading2": {fg: "111", bg: "aaa", bold: false, underline: false, italic: false},
"heading3": {fg: "222", bg: "ccc", bold: false, underline: false, italic: false}
plain: { fg: this.DEFAULT_FG_LIGHT, bg: this.DEFAULT_BG, bold: false, underline: false, italic: false },
heading1: { fg: "000", bg: "777", bold: false, underline: false, italic: false },
heading2: { fg: "111", bg: "aaa", bold: false, underline: false, italic: false },
heading3: { fg: "222", bg: "ccc", bold: false, underline: false, italic: false },
};
this.SELECTED_STYLES = this.darkTheme ? this.STYLES_DARK : this.STYLES_LIGHT;
}
injectMonospaceStyles() {
if (document.getElementById('micron-monospace-styles')) {
if (document.getElementById("micron-monospace-styles")) {
return;
}
const styleEl = document.createElement('style');
styleEl.id = 'micron-monospace-styles';
const styleEl = document.createElement("style");
styleEl.id = "micron-monospace-styles";
styleEl.textContent = `
.Mu-nl {
@@ -78,7 +79,6 @@ class MicronParser {
return `nomadnetwork://${url}`;
}
parseHeaderTags(markup) {
let pageFg = null;
let pageBg = null;
@@ -120,7 +120,7 @@ class MicronParser {
// parse header tags for page-level color defaults
const headerColors = this.parseHeaderTags(markup);
const plainStyle = this.SELECTED_STYLES?.plain || {fg: this.DEFAULT_FG_DARK, bg: this.DEFAULT_BG};
const plainStyle = this.SELECTED_STYLES?.plain || { fg: this.DEFAULT_FG_DARK, bg: this.DEFAULT_BG };
const defaultFg = headerColors.fg || plainStyle.fg;
const defaultBg = headerColors.bg || this.DEFAULT_BG;
@@ -133,13 +133,13 @@ class MicronParser {
bold: false,
underline: false,
italic: false,
strikethrough: false
strikethrough: false,
},
default_align: "left",
align: "left",
default_fg: defaultFg,
default_bg: defaultBg,
radio_groups: {}
radio_groups: {},
};
const lines = markup.split("\n");
@@ -157,13 +157,19 @@ class MicronParser {
}
}
try {
return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
} catch (error) {
console.warn('DOMPurify is not installed. Include it above micron-parser.js or run npm install dompurify ', error);
try {
return DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
ALLOWED_URI_REGEXP:
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp|nomadnetwork|lxmf):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
});
} catch (error) {
console.warn(
"DOMPurify is not installed. Include it above micron-parser.js or run npm install dompurify ",
error
);
return `<p style="color: red;"> ⚠ DOMPurify is not installed. Include it above micron-parser.js or run npm install dompurify </p>`;
}
}
}
convertMicronToFragment(markup) {
@@ -172,7 +178,7 @@ class MicronParser {
const headerColors = this.parseHeaderTags(markup);
const plainStyle = this.SELECTED_STYLES?.plain || {fg: this.DEFAULT_FG_DARK, bg: this.DEFAULT_BG};
const plainStyle = this.SELECTED_STYLES?.plain || { fg: this.DEFAULT_FG_DARK, bg: this.DEFAULT_BG };
const defaultFg = headerColors.fg || plainStyle.fg;
const defaultBg = headerColors.bg || this.DEFAULT_BG;
@@ -185,23 +191,26 @@ class MicronParser {
bold: false,
underline: false,
italic: false,
strikethrough: false
strikethrough: false,
},
default_align: "left",
align: "left",
default_fg: defaultFg,
default_bg: defaultBg,
radio_groups: {}
radio_groups: {},
};
const lines = markup.split("\n");
for (let line of lines) {
line = DOMPurify.sanitize(line, { USE_PROFILES: { html: true } });
line = DOMPurify.sanitize(line, {
USE_PROFILES: { html: true },
ALLOWED_URI_REGEXP:
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp|nomadnetwork|lxmf):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
});
const lineOutput = this.parseLine(line, state);
if (lineOutput && lineOutput.length > 0) {
for (let el of lineOutput) {
fragment.appendChild(el);
}
} else if (lineOutput && lineOutput.length === 0) {
@@ -222,7 +231,6 @@ class MicronParser {
return null;
}
if (!state.literal) {
// Comments, and header tags s
if (line[0] === "#") {
@@ -248,7 +256,13 @@ class MicronParser {
// apply heading style if it exists
let style = null;
let wanted_style = "heading" + i;
const defaultPlain = {fg: this.darkTheme ? this.DEFAULT_FG_DARK : this.DEFAULT_FG_LIGHT, bg: this.DEFAULT_BG, bold: false, underline: false, italic: false};
const defaultPlain = {
fg: this.darkTheme ? this.DEFAULT_FG_DARK : this.DEFAULT_FG_LIGHT,
bg: this.DEFAULT_BG,
bold: false,
underline: false,
italic: false,
};
if (this.SELECTED_STYLES?.[wanted_style]) {
style = this.SELECTED_STYLES[wanted_style];
} else {
@@ -271,11 +285,11 @@ class MicronParser {
const innerDiv = document.createElement("div");
this.applySectionIndent(innerDiv, state);
this.appendOutput(innerDiv, outputParts, state);
this.appendOutput(innerDiv, outputParts);
outerDiv.appendChild(innerDiv);
const br = document.createElement("br");
return [outerDiv, br]
return [outerDiv, br];
}
// wrap in a heading container
if (outputParts && outputParts.length > 0) {
@@ -283,7 +297,7 @@ class MicronParser {
this.applyAlignment(div, state);
this.applySectionIndent(div, state);
// merge text nodes
this.appendOutput(div, outputParts, state);
this.appendOutput(div, outputParts);
return [div];
} else {
return null;
@@ -306,11 +320,11 @@ class MicronParser {
return [hr];
} else {
// if second char given
const dividerChar = line[1]; // use the following character for creating the divider
const dividerChar = line[1]; // use the following character for creating the divider
const repeated = dividerChar.repeat(250);
const div = document.createElement("div");
div.style.whiteSpace = "pre"; // needs to not wrap and ignore container formatting
div.style.whiteSpace = "pre"; // needs to not wrap and ignore container formatting
div.textContent = repeated;
div.style.width = "100%";
div.style.whiteSpace = "nowrap";
@@ -322,19 +336,17 @@ class MicronParser {
return [div];
}
}
}
let outputParts = this.makeOutput(state, line);
// outputParts can contain text (tuple) and special objects (fields/checkbox)
if (outputParts) {
// create parent div container to apply proper section indent
let container = document.createElement("div");
this.applyAlignment(container, state);
this.applySectionIndent(container, state);
this.appendOutput(container, outputParts, state);
this.appendOutput(container, outputParts);
// if theres a background color, wrap with outer div
if (state.bg_color !== this.DEFAULT_BG) {
@@ -394,9 +406,9 @@ class MicronParser {
applySectionIndent(el, state) {
// indent by state.depth
let indent = (state.depth - 1) * 2;
if (indent > 0 ) {
if (indent > 0) {
// Indent according to forceMonospace() character width
el.style.marginLeft = (indent * 0.6) + "em";
el.style.marginLeft = indent * 0.6 + "em";
}
}
@@ -407,7 +419,7 @@ class MicronParser {
bg: state.bg_color,
bold: state.formatting.bold,
underline: state.formatting.underline,
italic: state.formatting.italic
italic: state.formatting.italic,
};
}
@@ -419,12 +431,11 @@ class MicronParser {
if (style.italic !== undefined && style.italic !== null) state.formatting.italic = style.italic;
}
appendOutput(container, parts, state) {
appendOutput(container, parts) {
let currentSpan = null;
let currentStyle = null;
const flushSpan = () => {
const flushSpan = () => {
if (currentSpan) {
if (currentStyle && currentStyle.bg !== this.DEFAULT_BG) {
currentSpan.style.display = "inline-block";
@@ -436,7 +447,7 @@ class MicronParser {
};
for (let p of parts) {
if (typeof p === 'string') {
if (typeof p === "string") {
let span = document.createElement("span");
span.innerHTML = p;
container.appendChild(span);
@@ -451,14 +462,14 @@ class MicronParser {
currentStyle = styleSpec;
}
currentSpan.innerHTML += text;
} else if (p && typeof p === 'object') {
} else if (p && typeof p === "object") {
// field, checkbox, radio, link
flushSpan();
if (p.type === "field") {
let input = document.createElement("input");
input.type = p.masked ? "password" : "text";
input.name = p.name;
input.setAttribute('value', p.data);
input.setAttribute("value", p.data);
if (p.width) {
input.size = p.width;
}
@@ -470,7 +481,7 @@ class MicronParser {
cb.type = "checkbox";
cb.name = p.name;
cb.value = p.value;
if (p.prechecked) cb.setAttribute('checked', true);
if (p.prechecked) cb.setAttribute("checked", true);
label.appendChild(cb);
label.appendChild(document.createTextNode(" " + p.label));
this.applyStyleToElement(label, this.styleFromState(p.style));
@@ -481,14 +492,13 @@ class MicronParser {
rb.type = "radio";
rb.name = p.name;
rb.value = p.value;
if (p.prechecked) rb.setAttribute('checked', true);
if (p.prechecked) rb.setAttribute("checked", true);
label.appendChild(rb);
label.appendChild(document.createTextNode(" " + p.label));
this.applyStyleToElement(label, this.styleFromState(p.style));
container.appendChild(label);
} else if (p.type === "link") {
let directURL = p.url.replace('nomadnetwork://', '').replace('lxmf://', '');
let directURL = p.url.replace("nomadnetwork://", "").replace("lxmf://", "");
// use p.url as is for the href
const formattedUrl = p.url;
@@ -502,12 +512,12 @@ class MicronParser {
if (p.fields && p.fields.length > 0) {
for (const f of p.fields) {
if (f === '*') {
if (f === "*") {
// submit all fields
foundAll = true;
} else if (f.includes('=')) {
} else if (f.includes("=")) {
// this is a request variable (key=value)
const [k, v] = f.split('=');
const [k, v] = f.split("=");
requestVars[k] = v;
} else {
// this is a field name to submit
@@ -515,20 +525,20 @@ class MicronParser {
}
}
let fieldStr = '';
let fieldStr = "";
if (foundAll) {
// if '*' was found, submit all fields
fieldStr = '*';
fieldStr = "*";
} else {
fieldStr = fieldsToSubmit.join('|');
fieldStr = fieldsToSubmit.join("|");
}
// append request variables directly to the directURL as query parameters
const varEntries = Object.entries(requestVars);
if (varEntries.length > 0) {
const queryString = varEntries.map(([k, v]) => `${k}=${v}`).join('|');
const queryString = varEntries.map(([k, v]) => `${k}=${v}`).join("|");
directURL += directURL.includes('`') ? `|${queryString}` : `\`${queryString}`;
directURL += directURL.includes("`") ? `|${queryString}` : `\`${queryString}`;
}
a.setAttribute("data-destination", `${directURL}`);
@@ -537,13 +547,12 @@ class MicronParser {
// no fields or request variables, just handle the direct URL
a.setAttribute("data-destination", `${directURL}`);
}
a.classList.add('Mu-nl');
a.setAttribute('data-action', "openNode");
a.classList.add("Mu-nl");
a.setAttribute("data-action", "openNode");
a.innerHTML = p.label;
this.applyStyleToElement(a, this.styleFromState(p.style));
container.appendChild(a);
}
}
}
@@ -553,7 +562,13 @@ class MicronParser {
stylesEqual(s1, s2) {
if (!s1 && !s2) return true;
if (!s1 || !s2) return false;
return (s1.fg === s2.fg && s1.bg === s2.bg && s1.bold === s2.bold && s1.underline === s2.underline && s1.italic === s2.italic);
return (
s1.fg === s2.fg &&
s1.bg === s2.bg &&
s1.bold === s2.bold &&
s1.underline === s2.underline &&
s1.italic === s2.italic
);
}
styleFromState(stateStyle) {
@@ -562,7 +577,7 @@ class MicronParser {
return stateStyle;
}
applyStyleToElement(el, style) {
applyStyleToElement(el, style) {
if (!style) return;
// convert style fg/bg to colors
let fgColor = this.colorToCss(style.fg);
@@ -580,7 +595,7 @@ applyStyleToElement(el, style) {
el.style.fontWeight = "bold";
}
if (style.underline) {
el.style.textDecoration = (el.style.textDecoration ? el.style.textDecoration + " underline" : "underline");
el.style.textDecoration = el.style.textDecoration ? el.style.textDecoration + " underline" : "underline";
}
if (style.italic) {
el.style.fontStyle = "italic";
@@ -598,12 +613,14 @@ applyStyleToElement(el, style) {
return "#" + c;
}
// If grayscale 'gxx'
if (c.length === 3 && c[0] === 'g') {
if (c.length === 3 && c[0] === "g") {
// treat xx as a number and map to gray
let val = parseInt(c.slice(1), 10);
if (isNaN(val)) val = 50;
// map 0-99 scale to a gray hex
let h = Math.floor(val * 2.55).toString(16).padStart(2, '0');
let h = Math.floor(val * 2.55)
.toString(16)
.padStart(2, "0");
return "#" + h + h + h;
}
@@ -617,7 +634,7 @@ applyStyleToElement(el, style) {
if (line === "\\`=") {
line = "`=";
}
if(this.enableForceMonospace) {
if (this.enableForceMonospace) {
return [[this.stateToStyle(state), this.splitAtSpaces(line)]];
} else {
return [[this.stateToStyle(state), line]];
@@ -632,7 +649,7 @@ applyStyleToElement(el, style) {
const flushPart = () => {
if (part.length > 0) {
if(this.enableForceMonospace) {
if (this.enableForceMonospace) {
output.push([this.stateToStyle(state), this.splitAtSpaces(part)]);
} else {
output.push([this.stateToStyle(state), part]);
@@ -653,17 +670,17 @@ applyStyleToElement(el, style) {
if (mode === "formatting") {
switch (c) {
case '_':
case "_":
state.formatting.underline = !state.formatting.underline;
break;
case '!':
case "!":
state.formatting.bold = !state.formatting.bold;
break;
case '*':
case "*":
state.formatting.italic = !state.formatting.italic;
break;
case 'F':
case "F":
// next 3 chars => fg color
if (line.length >= i + 4) {
let color = line.substr(i + 1, 3);
@@ -671,11 +688,11 @@ applyStyleToElement(el, style) {
skip = 3;
}
break;
case 'f':
case "f":
// reset fg to page default
state.fg_color = state.default_fg;
break;
case 'B':
case "B":
// next 3 chars => bg color
if (line.length >= i + 4) {
let color = line.substr(i + 1, 3);
@@ -684,12 +701,12 @@ applyStyleToElement(el, style) {
flushPart(); // flush current part when background color changes
}
break;
case 'b':
case "b":
// reset bg to page default
state.bg_color = state.default_bg;
flushPart(); // flush to allow for ` tags on same line
break;
case '`':
case "`":
state.formatting.bold = false;
state.formatting.underline = false;
state.formatting.italic = false;
@@ -698,20 +715,20 @@ applyStyleToElement(el, style) {
state.align = state.default_align;
mode = "text";
break;
case 'c':
state.align = 'center';
case "c":
state.align = "center";
break;
case 'l':
state.align = 'left';
case "l":
state.align = "left";
break;
case 'r':
state.align = 'right';
case "r":
state.align = "right";
break;
case 'a':
case "a":
state.align = state.default_align;
break;
case '<':
case "<": {
// if there's already text, flush it
flushPart();
let fieldData = this.parseField(line, i, state);
@@ -722,8 +739,9 @@ applyStyleToElement(el, style) {
continue;
}
break;
}
case '[':
case "[": {
// flush current text first
flushPart();
let linkData = this.parseLink(line, i, state);
@@ -733,6 +751,7 @@ applyStyleToElement(el, style) {
continue;
}
break;
}
default:
// unknown formatting char, ignore
@@ -741,16 +760,15 @@ applyStyleToElement(el, style) {
mode = "text";
i++;
continue;
} else {
// mode === "text"
if (escape) {
part += c;
escape = false;
} else if (c === '\\') {
} else if (c === "\\") {
escape = true;
} else if (c === '`') {
if (i + 1 < line.length && line[i + 1] === '`') {
} else if (c === "`") {
if (i + 1 < line.length && line[i + 1] === "`") {
flushPart();
state.formatting.bold = false;
state.formatting.underline = false;
@@ -775,7 +793,7 @@ applyStyleToElement(el, style) {
}
// end of line
if (part.length > 0) {
if(this.enableForceMonospace) {
if (this.enableForceMonospace) {
output.push([this.stateToStyle(state), this.splitAtSpaces(part)]);
} else {
output.push([this.stateToStyle(state), part]);
@@ -787,7 +805,7 @@ applyStyleToElement(el, style) {
parseField(line, startIndex, state) {
let field_start = startIndex + 1;
let backtick_pos = line.indexOf('`', field_start);
let backtick_pos = line.indexOf("`", field_start);
if (backtick_pos === -1) return null;
let field_content = line.substring(field_start, backtick_pos);
@@ -798,20 +816,20 @@ applyStyleToElement(el, style) {
let field_value = "";
let field_prechecked = false;
if (field_content.includes('|')) {
let f_components = field_content.split('|');
if (field_content.includes("|")) {
let f_components = field_content.split("|");
let field_flags = f_components[0];
field_name = f_components[1];
if (field_flags.includes('^')) {
if (field_flags.includes("^")) {
field_type = "radio";
field_flags = field_flags.replace('^', '');
} else if (field_flags.includes('?')) {
field_flags = field_flags.replace("^", "");
} else if (field_flags.includes("?")) {
field_type = "checkbox";
field_flags = field_flags.replace('?', '');
} else if (field_flags.includes('!')) {
field_flags = field_flags.replace("?", "");
} else if (field_flags.includes("!")) {
field_masked = true;
field_flags = field_flags.replace('!', '');
field_flags = field_flags.replace("!", "");
}
if (field_flags.length > 0) {
@@ -826,13 +844,13 @@ applyStyleToElement(el, style) {
}
if (f_components.length > 3) {
if (f_components[3] === '*') {
if (f_components[3] === "*") {
field_prechecked = true;
}
}
}
let field_end = line.indexOf('>', backtick_pos);
let field_end = line.indexOf(">", backtick_pos);
if (field_end === -1) return null;
let field_data = line.substring(backtick_pos + 1, field_end);
@@ -846,7 +864,7 @@ applyStyleToElement(el, style) {
value: field_value || field_data,
label: field_data,
prechecked: field_prechecked,
style: style
style: style,
};
} else {
obj = {
@@ -855,20 +873,20 @@ applyStyleToElement(el, style) {
width: field_width,
masked: field_masked,
data: field_data,
style: style
style: style,
};
}
let skip = (field_end - startIndex);
return {obj: obj, skip: skip};
let skip = field_end - startIndex;
return { obj: obj, skip: skip };
}
parseLink(line, startIndex, state) {
let endpos = line.indexOf(']', startIndex);
let endpos = line.indexOf("]", startIndex);
if (endpos === -1) return null;
let link_data = line.substring(startIndex + 1, endpos);
let link_components = link_data.split('`');
let link_components = link_data.split("`");
let link_label = "";
let link_url = "";
let link_fields = "";
@@ -897,7 +915,7 @@ applyStyleToElement(el, style) {
link_url = MicronParser.formatNomadnetworkUrl(link_url);
// Apply forceMonospace
if(this.enableForceMonospace) {
if (this.enableForceMonospace) {
link_label = this.splitAtSpaces(link_label);
}
@@ -906,12 +924,12 @@ applyStyleToElement(el, style) {
type: "link",
url: link_url,
label: link_label,
fields: (link_fields ? link_fields.split("|") : []),
style: style
fields: link_fields ? link_fields.split("|") : [],
style: style,
};
let skip = (endpos - startIndex);
return {obj: obj, skip: skip};
let skip = endpos - startIndex;
return { obj: obj, skip: skip };
}
splitAtSpaces(line) {
@@ -926,7 +944,7 @@ applyStyleToElement(el, style) {
forceMonospace(line) {
let out = "";
// Properly split compount emoji, source: https://stackoverflow.com/a/71619350
let charArr = [...new Intl.Segmenter().segment(line)].map(x => x.segment);
let charArr = [...new Intl.Segmenter().segment(line)].map((x) => x.segment);
for (let char of charArr) {
out += "<span class='Mu-mnt'>" + char + "</span>";
}
+2 -2
View File
@@ -5,7 +5,7 @@ describe("MicronParser.js", () => {
let parser;
beforeEach(() => {
parser = new MicronParser(true); // darkTheme = true
parser = new MicronParser(true, false); // darkTheme = true, enableForceMonospace = false
});
describe("formatNomadnetworkUrl", () => {
@@ -33,7 +33,7 @@ describe("MicronParser.js", () => {
});
it("converts horizontal dividers", () => {
const markup = "---";
const markup = "-";
const html = parser.convertMicronToHtml(markup);
expect(html).toContain("<hr");
});
+43 -41
View File
@@ -243,48 +243,50 @@ describe("NetworkVisualiser Optimization and Abort", () => {
expect(end - start).toBeLessThan(100); // Should be very fast
});
it("performance: icon cache hit vs miss for 500 nodes", async () => {
vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {});
const wrapper = mountVisualiser();
it("performance: icon cache hit vs miss for 500 nodes", async () => {
vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {});
const wrapper = mountVisualiser();
// Setup 500 nodes with the same icon
const iconInfo = { icon_name: "test", foreground_colour: "#000", background_colour: "#fff" };
wrapper.vm.pathTable = Array.from({ length: 500 }, (_, i) => ({ hash: `h${i}`, interface: "eth0", hops: 1 }));
wrapper.vm.announces = wrapper.vm.pathTable.reduce((acc, cur) => {
acc[cur.hash] = {
destination_hash: cur.hash,
aspect: "lxmf.delivery",
display_name: "node",
lxmf_user_icon: iconInfo,
};
return acc;
}, {});
wrapper.vm.conversations = wrapper.vm.pathTable.reduce((acc, cur) => {
acc[cur.hash] = { lxmf_user_icon: iconInfo };
return acc;
}, {});
// Setup 500 nodes with the same icon
const iconInfo = { icon_name: "test", foreground_colour: "#000", background_colour: "#fff" };
wrapper.vm.pathTable = Array.from({ length: 500 }, (_, i) => ({ hash: `h${i}`, interface: "eth0", hops: 1 }));
wrapper.vm.announces = wrapper.vm.pathTable.reduce((acc, cur) => {
acc[cur.hash] = {
destination_hash: cur.hash,
aspect: "lxmf.delivery",
display_name: "node",
lxmf_user_icon: iconInfo,
};
return acc;
}, {});
wrapper.vm.conversations = wrapper.vm.pathTable.reduce((acc, cur) => {
acc[cur.hash] = { lxmf_user_icon: iconInfo };
return acc;
}, {});
// Mock createIconImage to have some delay
wrapper.vm.createIconImage = vi.fn().mockImplementation(async () => {
return "blob:mock-icon";
// Mock createIconImage to have some delay for the "miss" case
wrapper.vm.createIconImage = vi.fn().mockImplementation(async () => {
// Add a tiny delay to ensure "miss" is always measurable
await new Promise((r) => setTimeout(r, 0));
return "blob:mock-icon";
});
const startMiss = performance.now();
await wrapper.vm.processVisualization();
const endMiss = performance.now();
const missTime = endMiss - startMiss;
// Second run will hit the cache check in processVisualization
// so it won't even call createIconImage.
const startHit = performance.now();
await wrapper.vm.processVisualization();
const endHit = performance.now();
const hitTime = endHit - startHit;
console.log(`Icon cache MISS for 500 nodes: ${missTime.toFixed(2)}ms`);
console.log(`Icon cache HIT for 500 nodes: ${hitTime.toFixed(2)}ms`);
// Cache hit should be significantly faster
expect(hitTime).toBeLessThan(missTime);
});
const startMiss = performance.now();
await wrapper.vm.processVisualization();
const endMiss = performance.now();
const missTime = endMiss - startMiss;
// Second run should hit cache
const startHit = performance.now();
await wrapper.vm.processVisualization();
const endHit = performance.now();
const hitTime = endHit - startHit;
console.log(`Icon cache MISS for 500 nodes: ${missTime.toFixed(2)}ms`);
console.log(`Icon cache HIT for 500 nodes: ${hitTime.toFixed(2)}ms`);
// Cache hit should be significantly faster because it avoids 500 async calls (even if resolved instantly)
// and doesn't re-create images.
expect(hitTime).toBeLessThan(missTime);
});
});