diff --git a/package-lock.json b/package-lock.json
index 601f3f3..5c41aa4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4,11 +4,11 @@
"requires": true,
"packages": {
"": {
- "name": "comfyui-model-resolver",
"devDependencies": {
"@eslint/js": "^10.0.1",
"eslint": "^10.7.0",
- "globals": "^17.7.0"
+ "globals": "^17.7.0",
+ "happy-dom": "^20.11.1"
}
},
"node_modules/@eslint-community/eslint-utils": {
@@ -226,6 +226,33 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/node": {
+ "version": "26.1.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
+ "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
+ "node_modules/@types/whatwg-mimetype": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
+ "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/acorn": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
@@ -289,6 +316,19 @@
"node": "20 || >=22"
}
},
+ "node_modules/buffer-image-size": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
+ "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -329,6 +369,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -595,6 +648,25 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/happy-dom": {
+ "version": "20.11.1",
+ "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.1.tgz",
+ "integrity": "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": ">=20.0.0",
+ "@types/whatwg-mimetype": "^3.0.2",
+ "@types/ws": "^8.18.1",
+ "buffer-image-size": "^0.6.4",
+ "entities": "^7.0.1",
+ "whatwg-mimetype": "^3.0.0",
+ "ws": "^8.21.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -862,6 +934,13 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -872,6 +951,16 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/whatwg-mimetype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
+ "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -898,6 +987,28 @@
"node": ">=0.10.0"
}
},
+ "node_modules/ws": {
+ "version": "8.21.2",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz",
+ "integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
diff --git a/package.json b/package.json
index 557371d..bd3cd7f 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"devDependencies": {
"@eslint/js": "^10.0.1",
"eslint": "^10.7.0",
- "globals": "^17.7.0"
+ "globals": "^17.7.0",
+ "happy-dom": "^20.11.1"
}
}
diff --git a/tests/test_dom_patchers.mjs b/tests/test_dom_patchers.mjs
new file mode 100644
index 0000000..02f2ea9
--- /dev/null
+++ b/tests/test_dom_patchers.mjs
@@ -0,0 +1,228 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { Window } from 'happy-dom';
+
+const projectRoot = path.resolve(import.meta.dirname, '..');
+const resolveDownloadMethodsSource = fs.readFileSync(
+ path.join(projectRoot, 'web/resolver/actions/resolve_download_methods.js'),
+ 'utf8'
+);
+const queueMethodsSource = fs.readFileSync(
+ path.join(projectRoot, 'web/resolver/actions/queue_methods.js'),
+ 'utf8'
+);
+const renderFormatMethodsSource = fs.readFileSync(
+ path.join(projectRoot, 'web/resolver/utils/render_format_methods.js'),
+ 'utf8'
+);
+
+function extractMethod(source, methodName, paramsPattern = '[^)]*') {
+ const signatureRegex = new RegExp(`\\n\\s+(async\\s+)?${methodName}\\s*\\(${paramsPattern}\\)\\s*\\{`);
+ const match = signatureRegex.exec(source);
+ assert.ok(match, `Could not find ${methodName}`);
+ const isAsync = Boolean(match[1]);
+ const parenStart = source.indexOf('(', match.index);
+ const parenEnd = source.indexOf(')', parenStart);
+ const params = source.slice(parenStart + 1, parenEnd);
+ const braceStart = source.indexOf('{', parenEnd);
+ let depth = 0;
+ for (let index = braceStart; index < source.length; index += 1) {
+ const char = source[index];
+ if (char === '{') depth += 1;
+ if (char === '}') depth -= 1;
+ if (depth === 0) {
+ return `${isAsync ? 'async ' : ''}function ${methodName}(${params}) ${source.slice(braceStart, index + 1)}`;
+ }
+ }
+ throw new Error(`Could not parse ${methodName}`);
+}
+
+const patchSearchResultsContainer = eval(`(${extractMethod(resolveDownloadMethodsSource, 'patchSearchResultsContainer')})`);
+const patchSearchProgressElement = eval(`(${extractMethod(resolveDownloadMethodsSource, 'patchSearchProgressElement')})`);
+const patchSearchProgressItem = eval(`(${extractMethod(resolveDownloadMethodsSource, 'patchSearchProgressItem')})`);
+const patchSearchProgressActions = eval(`(${extractMethod(resolveDownloadMethodsSource, 'patchSearchProgressActions')})`);
+const patchSearchResultsTable = eval(`(${extractMethod(resolveDownloadMethodsSource, 'patchSearchResultsTable')})`);
+const syncSearchElementAttributes = eval(`(${extractMethod(resolveDownloadMethodsSource, 'syncSearchElementAttributes')})`);
+const getSearchResultsPatchKind = eval(`(${extractMethod(resolveDownloadMethodsSource, 'getSearchResultsPatchKind')})`);
+const wireSearchDownloadButtons = eval(`(${extractMethod(resolveDownloadMethodsSource, 'wireSearchDownloadButtons')})`);
+const patchLocalMatchesContainer = eval(`(${extractMethod(resolveDownloadMethodsSource, 'patchLocalMatchesContainer')})`);
+const patchQueuedSelections = eval(`(${extractMethod(queueMethodsSource, 'patchQueuedSelections')})`);
+const patchDownloadsPanelElement = eval(`(${extractMethod(queueMethodsSource, 'patchDownloadsPanelElement')})`);
+const patchLoadedModelsProgress = eval(`(${extractMethod(renderFormatMethodsSource, 'patchLoadedModelsProgress')})`);
+
+function createDialog(window) {
+ return {
+ syncSearchElementAttributes,
+ patchSearchProgressActions,
+ patchSearchProgressItem,
+ patchSearchProgressElement,
+ patchSearchResultsTable,
+ getSearchResultsPatchKind,
+ patchSearchResultsContainer,
+ patchLocalMatchesContainer,
+ patchQueuedSelections,
+ patchDownloadsPanelElement,
+ patchLoadedModelsProgress,
+ wireSearchDownloadButtons,
+ window,
+ wireSearchHashMatchHighlights() {},
+ };
+}
+
+test('search patcher preserves keyed progress items and result rows', () => {
+ const window = new Window();
+ const dialog = createDialog(window);
+ const container = window.document.createElement('div');
+ const firstHtml = `
+
+
+
CivitAI
+
Searching... 10%
+
+
+
+
+ `;
+ const secondHtml = `
+
+
+
CivitAI
+
Searching... 55%
+
+
+
+
HuggingFace
+
Queued
+
+
+
+
+ `;
+
+ dialog.patchSearchResultsContainer(container, firstHtml);
+ const progressItem = container.querySelector('[data-search-progress-source="civitai"]');
+ const resultRow = container.querySelector('[data-search-result-key="civitai%3Amodel"]');
+ dialog.patchSearchResultsContainer(container, secondHtml);
+
+ assert.strictEqual(
+ container.querySelector('[data-search-progress-source="civitai"]'),
+ progressItem
+ );
+ assert.strictEqual(
+ container.querySelector('[data-search-result-key="civitai%3Amodel"]'),
+ resultRow
+ );
+ assert.equal(progressItem.querySelector('.mr-search-progress-status').textContent, 'Searching... 55%');
+ assert.equal(progressItem.querySelector('.mr-search-progress-fill').style.width, '55%');
+ assert.equal(resultRow.textContent.trim(), 'Updated result');
+ assert.ok(container.querySelector('[data-search-progress-source="huggingface"]'));
+});
+
+test('local match patcher preserves rows and collapsed alternatives', () => {
+ const window = new Window();
+ const dialog = createDialog(window);
+ const container = window.document.createElement('div');
+ const firstHtml = `
+ One
+
+
+ `;
+ const secondHtml = `
+ One updated
+
+
+ `;
+
+ dialog.patchLocalMatchesContainer(container, firstHtml);
+ const mainRow = container.querySelector('[data-local-match-key="path%3A%2Fmodels%2Fone"]');
+ const alternativeRow = container.querySelector('[data-local-match-key="path%3A%2Fmodels%2Ftwo"]');
+ const group = container.querySelector('#more-matches');
+ dialog.patchLocalMatchesContainer(container, secondHtml);
+
+ assert.strictEqual(container.querySelector('[data-local-match-key="path%3A%2Fmodels%2Fone"]'), mainRow);
+ assert.strictEqual(container.querySelector('[data-local-match-key="path%3A%2Fmodels%2Ftwo"]'), alternativeRow);
+ assert.strictEqual(container.querySelector('#more-matches'), group);
+ assert.equal(mainRow.textContent, 'One updated');
+ assert.equal(alternativeRow.textContent, 'Two updated');
+ assert.ok(group.classList.contains('mr-hidden'), 'user-collapsed alternatives should stay collapsed');
+});
+
+test('loaded progress patcher preserves the progress root and fill element', () => {
+ const window = new Window();
+ const dialog = createDialog(window);
+ const container = window.document.createElement('div');
+ const makeProgress = (message, percent) => `
+
+ `;
+
+ dialog.patchLoadedModelsProgress(container, makeProgress('Loading one', 10));
+ const root = container.firstElementChild;
+ const fill = container.querySelector('.mr-progress-fill');
+ dialog.patchLoadedModelsProgress(container, makeProgress('Loading two', 60));
+
+ assert.strictEqual(container.firstElementChild, root);
+ assert.strictEqual(container.querySelector('.mr-progress-fill'), fill);
+ assert.equal(container.querySelector('.mr-download-info').textContent, 'Loading two');
+ assert.equal(fill.style.width, '60%');
+});
+
+test('download queue patcher preserves keyed cards', () => {
+ const window = new Window();
+ const dialog = createDialog(window);
+ const queueList = window.document.createElement('div');
+ dialog.queueList = queueList;
+ queueList.innerHTML = '';
+ dialog.patchQueuedSelections('');
+
+ const item = queueList.querySelector('[data-queue-key="one"]');
+ assert.equal(item.textContent, 'Updated');
+ assert.strictEqual(queueList.querySelectorAll('[data-queue-key="one"]').length, 1);
+ assert.ok(queueList.querySelector('[data-queue-key="two"]'));
+});
+
+test('downloads panel patcher preserves a keyed card node', () => {
+ const window = new Window();
+ const dialog = createDialog(window);
+ const current = window.document.createElement('div');
+ const next = window.document.createElement('div');
+ current.innerHTML = '10%
';
+ next.innerHTML = '20%
';
+ const card = current.firstElementChild;
+
+ dialog.patchDownloadsPanelElement(current, next);
+
+ assert.strictEqual(current.firstElementChild, card);
+ assert.equal(card.textContent, '20%');
+});
+
+test('search download button wiring remains idempotent on the same node', () => {
+ const window = new Window();
+ const dialog = createDialog(window);
+ const container = window.document.createElement('div');
+ container.innerHTML = '';
+ let calls = 0;
+ dialog.downloadFromSearch = () => { calls += 1; };
+
+ dialog.wireSearchDownloadButtons(container, {});
+ dialog.wireSearchDownloadButtons(container, {});
+ container.querySelector('.search-download-btn').click();
+
+ assert.equal(calls, 1);
+});