Files
KOFiblto-ComfyUI-SaturnNodes/tests/test_batch_queue_dom.mjs
T

364 lines
11 KiB
JavaScript

import assert from "node:assert/strict";
import fs from "node:fs";
import vm from "node:vm";
import path from "node:path";
import { fileURLToPath } from "node:url";
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "..");
const sourcePath = path.join(repoRoot, "web", "batch_queue.js");
class FakeElement {
constructor(tagName) {
this.tagName = String(tagName).toUpperCase();
this.nodeType = 1;
this.attributes = new Map();
this.children = [];
this.parentNode = null;
this.className = "";
this.id = "";
this.innerHTML = "";
this.style = {};
this.dataset = {};
}
setAttribute(name, value) {
this.attributes.set(String(name), String(value));
}
getAttribute(name) {
return this.attributes.has(String(name)) ? this.attributes.get(String(name)) : null;
}
appendChild(child) {
child.parentNode = this;
this.children.push(child);
return child;
}
remove() {
if (!this.parentNode) return;
const index = this.parentNode.children.indexOf(this);
if (index >= 0) this.parentNode.children.splice(index, 1);
this.parentNode = null;
}
querySelector(selector) {
if (selector === ".saturnnodes-batch-line-wrap") {
return this.children.find(child => child.className === "saturnnodes-batch-line-wrap") || null;
}
return null;
}
matches(selector) {
return String(selector).split(",").some(rawPart => {
const part = rawPart.trim();
if (part === "[data-job-id]") return this.attributes.has("data-job-id");
if (part === '[data-testid="job-assets-list"]') {
return this.getAttribute("data-testid") === "job-assets-list";
}
if (part === ".comfy-table") {
return this.className.split(/\s+/).includes("comfy-table");
}
if (part === "tr") return this.tagName === "TR";
if (part === ".comfy-table tr[data-job-id]") {
return this.tagName === "TR" && this.attributes.has("data-job-id");
}
return false;
});
}
closest(selector) {
let node = this;
while (node) {
if (node.matches?.(selector)) return node;
node = node.parentNode;
}
return null;
}
}
class FakeMutationObserver {
constructor(callback) {
this.callback = callback;
this.target = null;
this.options = null;
FakeMutationObserver.instances.push(this);
}
observe(target, options) {
this.target = target;
this.options = options;
}
}
FakeMutationObserver.instances = [];
let clock = 1_000_000;
let timerId = 0;
const timers = [];
function fakeSetTimeout(callback, delay = 0) {
const timer = {
id: ++timerId,
at: clock + Math.max(0, Number(delay) || 0),
callback,
};
timers.push(timer);
return timer.id;
}
function fakeClearTimeout(id) {
const index = timers.findIndex(timer => timer.id === id);
if (index >= 0) timers.splice(index, 1);
}
function advance(ms) {
const end = clock + Math.max(0, Number(ms) || 0);
while (true) {
let nextIndex = -1;
for (let i = 0; i < timers.length; i++) {
if (timers[i].at <= end && (nextIndex < 0 || timers[i].at < timers[nextIndex].at)) {
nextIndex = i;
}
}
if (nextIndex < 0) break;
const [timer] = timers.splice(nextIndex, 1);
clock = Math.max(clock, timer.at);
timer.callback();
}
clock = end;
}
const queueRoot = new FakeElement("div");
queueRoot.setAttribute("data-testid", "job-assets-list");
const activeCard = new FakeElement("div");
activeCard.setAttribute("data-job-id", "pid-a");
const pendingCard = new FakeElement("div");
pendingCard.setAttribute("data-job-id", "pid-b");
queueRoot.appendChild(activeCard);
queueRoot.appendChild(pendingCard);
// Legacy v1 rows are inserted separately by the test.
const v1Rows = [];
const document = {
body: new FakeElement("body"),
head: new FakeElement("head"),
getElementById(id) {
const find = node => {
if (node.id === id) return node;
for (const child of node.children) {
const found = find(child);
if (found) return found;
}
return null;
};
return find(this.body) || find(this.head);
},
createElement(tagName) {
return new FakeElement(tagName);
},
querySelectorAll(selector) {
if (selector.includes(".comfy-table")) {
return [activeCard, pendingCard, ...v1Rows];
}
if (selector.includes("[data-job-id]")) {
return [activeCard, pendingCard];
}
if (selector === ".saturnnodes-batch-line-wrap") {
const wraps = [];
const find = node => {
for (const child of node.children) {
if (child.className === "saturnnodes-batch-line-wrap") wraps.push(child);
find(child);
}
};
find(this.body);
return wraps;
}
return [];
},
};
document.body.appendChild(queueRoot);
const storage = new Map();
const localStorage = {
getItem(key) {
return storage.has(key) ? storage.get(key) : null;
},
setItem(key, value) {
storage.set(key, String(value));
},
};
function batchInfo(promptId, batchId, color, itemIndex, batchCount = 2) {
return {
prompt_id: promptId,
batchId,
batchNumber: 1,
batchCount,
itemIndex,
color,
colorIndex: 0,
queuedAt: clock,
};
}
const oldBatch = "batch_old";
const newBatch = "batch_new";
storage.set(
"saturnnodes_batch_queue_meta",
JSON.stringify([
batchInfo("pid-a", oldBatch, "#4ade80", 0, 1),
batchInfo("pid-b", newBatch, "#fbbf24", 1, 2),
batchInfo("pid-c", newBatch, "#fbbf24", 0, 2),
batchInfo("pid-d", newBatch, "#fbbf24", 1, 2),
])
);
const extension = {
name: "test",
setup: null,
};
const app = {
queuePrompt: async () => true,
extensionManager: {
setting: {
get: () => true,
},
},
registerExtension(candidate) {
extension.name = candidate.name;
extension.setup = candidate.setup;
},
};
const apiEvents = new Map();
const api = {
queuePrompt: async () => ({ prompt_id: "queued" }),
fetchApi: async () => null,
addEventListener(name, callback) {
if (!apiEvents.has(name)) apiEvents.set(name, []);
apiEvents.get(name).push(callback);
},
};
const source = fs
.readFileSync(sourcePath, "utf8")
.replace(/^import .*$/gm, "")
.replace(/export \{[^}]*\};?\s*$/m, "");
const context = vm.createContext({
console,
app,
api,
authenticatedFetch: async () => null,
document,
localStorage,
MutationObserver: FakeMutationObserver,
setTimeout: fakeSetTimeout,
clearTimeout: fakeClearTimeout,
Date: { now: () => clock },
Math,
JSON,
String,
Number,
Object,
Array,
Map,
Set,
Promise,
parseInt,
isNaN,
Infinity,
});
vm.runInContext(source, context, { filename: sourcePath });
assert.equal(typeof extension.setup, "function", "batch queue extension must register");
await extension.setup();
advance(100);
const activeLine = activeCard.querySelector(".saturnnodes-batch-line-wrap");
assert.ok(activeLine, "active card must have a batch line");
assert.match(activeLine.innerHTML, /#4ade80/, "initial active line must use its own batch color");
const observer = FakeMutationObserver.instances.find(
item => item.options && item.options.attributes && item.options.attributeFilter?.includes("data-job-id")
);
assert.ok(observer, "queue observer must watch data-job-id attribute changes");
// Simulate Vue reusing the running card for the first item of the next batch.
// The old green SVG remains in the DOM until the attribute mutation is handled.
activeCard.setAttribute("data-job-id", "pid-c");
observer.callback([{ target: activeCard, type: "attributes", attributeName: "data-job-id" }]);
advance(100);
assert.match(
activeCard.querySelector(".saturnnodes-batch-line-wrap").innerHTML,
/#fbbf24/,
"reused active card must repaint with the new batch color"
);
assert.doesNotMatch(
activeCard.querySelector(".saturnnodes-batch-line-wrap").innerHTML,
/#4ade80/,
"stale batch color must not survive a data-job-id change"
);
// The new batch's pending item and active item must share the same color.
const pendingLine = pendingCard.querySelector(".saturnnodes-batch-line-wrap");
assert.ok(pendingLine, "pending card must have a batch line");
assert.match(pendingLine.innerHTML, /#fbbf24/, "pending item must use the new batch color");
// 4. Legacy v1: a table can be inserted with its rows already built, so the
// mutation target is the new parent and the table only appears in addedNodes.
const table = new FakeElement("table");
table.className = "comfy-table";
const v1Row = new FakeElement("tr");
v1Row.setAttribute("data-job-id", "pid-d");
table.appendChild(v1Row);
const tableWrapper = new FakeElement("div");
document.body.appendChild(tableWrapper);
tableWrapper.appendChild(table);
v1Rows.push(v1Row);
observer.callback([{
type: "childList",
target: tableWrapper,
addedNodes: [table],
removedNodes: [],
}]);
advance(100);
const v1Line = v1Row.querySelector(".saturnnodes-batch-line-wrap");
assert.ok(v1Line, "a v1 table inserted with rows must get a batch line");
assert.match(v1Line.innerHTML, /#fbbf24/, "v1 row must use the batch color");
// 5. Duplicate data-job-id rows must not skew batch grouping or paint twice.
const duplicateRow = new FakeElement("tr");
duplicateRow.setAttribute("data-job-id", "pid-b");
// Simulate a line left over from an earlier render on the duplicate.
const staleDuplicateLine = new FakeElement("div");
staleDuplicateLine.className = "saturnnodes-batch-line-wrap";
duplicateRow.appendChild(staleDuplicateLine);
table.appendChild(duplicateRow);
v1Rows.push(duplicateRow);
observer.callback([{
type: "childList",
target: table,
addedNodes: [duplicateRow],
removedNodes: [],
}]);
advance(100);
assert.equal(
duplicateRow.querySelector(".saturnnodes-batch-line-wrap"),
null,
"duplicate job row must be ignored"
);
assert.ok(
pendingCard.querySelector(".saturnnodes-batch-line-wrap"),
"the first occurrence of the job id keeps its batch line"
);
console.log("batch queue DOM regression test passed");