From c8155d955c70cfd886ed04e13e68ff277170cf2c Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Tue, 4 Aug 2026 20:03:23 +0200 Subject: [PATCH] test(frontend): add DOM patcher behavior coverage --- package-lock.json | 115 +++++++++++++++++- package.json | 3 +- tests/test_dom_patchers.mjs | 228 ++++++++++++++++++++++++++++++++++++ 3 files changed, 343 insertions(+), 3 deletions(-) create mode 100644 tests/test_dom_patchers.mjs 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% +
+
+
+
+ + +
Old result
+
+ `; + const secondHtml = ` +
+
+ CivitAI + Searching... 55% +
+
+
+ HuggingFace + Queued +
+
+
+
+ + +
Updated result
+
+ `; + + 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
+ +
+
Two
+
+ `; + const secondHtml = ` +
One updated
+ +
+
Two 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) => ` +
+
${message}
+
1 / 2${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 = '
One
'; + dialog.patchQueuedSelections('
Updated
Two
'); + + 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); +});