364 lines
11 KiB
JavaScript
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");
|