test(frontend): add DOM patcher behavior coverage

This commit is contained in:
Dariusz L
2026-08-04 20:03:23 +02:00
parent f5ad8c8af1
commit c8155d955c
3 changed files with 343 additions and 3 deletions
+113 -2
View File
@@ -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",
+2 -1
View File
@@ -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"
}
}
+228
View File
@@ -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 = `
<div class="mr-search-progress-list">
<div class="mr-search-progress-item mr-search-progress-running" data-search-progress-source="civitai">
<span class="mr-search-progress-source">CivitAI</span>
<span class="mr-search-progress-status">Searching... 10%</span>
<div class="mr-search-progress-bar" role="progressbar" aria-valuenow="10"><div class="mr-search-progress-fill" style="width: 10%;"></div></div>
</div>
</div>
<div class="mr-search-results-table-wrap">
<table class="mr-search-results-table"><tbody>
<tr data-search-result-key="civitai%3Amodel"><td>Old result</td></tr>
</tbody></table>
</div>
`;
const secondHtml = `
<div class="mr-search-progress-list">
<div class="mr-search-progress-item mr-search-progress-running" data-search-progress-source="civitai">
<span class="mr-search-progress-source">CivitAI</span>
<span class="mr-search-progress-status">Searching... 55%</span>
<div class="mr-search-progress-bar" role="progressbar" aria-valuenow="55"><div class="mr-search-progress-fill" style="width: 55%;"></div></div>
</div>
<div class="mr-search-progress-item mr-search-progress-pending" data-search-progress-source="huggingface">
<span class="mr-search-progress-source">HuggingFace</span>
<span class="mr-search-progress-status">Queued</span>
<div class="mr-search-progress-bar" role="progressbar" aria-valuenow="0"><div class="mr-search-progress-fill" style="width: 0%;"></div></div>
</div>
</div>
<div class="mr-search-results-table-wrap">
<table class="mr-search-results-table"><tbody>
<tr data-search-result-key="civitai%3Amodel"><td>Updated result</td></tr>
</tbody></table>
</div>
`;
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 = `
<div class="mr-match-row" data-local-match-key="path%3A%2Fmodels%2Fone"><span>One</span></div>
<button class="mr-local-alternatives-toggle" aria-expanded="false">Alternatives</button>
<div id="more-matches" class="mr-stack-sm mr-hidden">
<div class="mr-match-row" data-local-match-key="path%3A%2Fmodels%2Ftwo"><span>Two</span></div>
</div>
`;
const secondHtml = `
<div class="mr-match-row" data-local-match-key="path%3A%2Fmodels%2Fone"><span>One updated</span></div>
<button class="mr-local-alternatives-toggle" aria-expanded="true">Alternatives</button>
<div id="more-matches" class="mr-stack-sm">
<div class="mr-match-row" data-local-match-key="path%3A%2Fmodels%2Ftwo"><span>Two updated</span></div>
</div>
`;
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) => `
<div class="mr-download-section">
<div class="mr-status-inline"><span class="mr-download-info">${message}</span></div>
<div class="mr-progress-container"><div class="mr-progress-bar"><div class="mr-progress-fill" style="width: ${percent}%;"></div></div><div class="mr-progress-text"><span>1 / 2</span><span>${percent}%</span></div></div>
</div>
`;
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 = '<div class="mr-queue-items"><div class="mr-queue-item" data-queue-key="one"><span>One</span></div></div>';
dialog.patchQueuedSelections('<div class="mr-queue-items"><div class="mr-queue-item" data-queue-key="one"><span>Updated</span></div><div class="mr-queue-item" data-queue-key="two"><span>Two</span></div></div>');
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 = '<div class="mr-download-queue-item" data-download-id="download-1"><span>10%</span></div>';
next.innerHTML = '<div class="mr-download-queue-item" data-download-id="download-1"><span>20%</span></div>';
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 = '<button class="search-download-btn" data-url="https://example.test/model" data-filename="model.safetensors" data-category="checkpoints"></button>';
let calls = 0;
dialog.downloadFromSearch = () => { calls += 1; };
dialog.wireSearchDownloadButtons(container, {});
dialog.wireSearchDownloadButtons(container, {});
container.querySelector('.search-download-btn').click();
assert.equal(calls, 1);
});