711 lines
37 KiB
JavaScript
711 lines
37 KiB
JavaScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("../../scripts/api.js", () => ({ api: { clientId: "test-client" } }), { virtual: true });
|
|
|
|
import { createApiClient } from "../apiClient.js";
|
|
import { createWorkerSettingsForm } from "../ui/settingsForm.js";
|
|
import { DistributedUI } from "../ui.js";
|
|
import { checkWorkerStatus, formatServerlessStatus, getWorkerUrl, launchWorker, stopWorker, viewWorkerLog } from "../workerLifecycle.js";
|
|
import { handleClearMemory, handleInterruptWorkers } from "../workerUtils.js";
|
|
import { cancelWorkerSettings, isRemoteWorker, normalizeRunpodEndpointId, saveWorkerSettings, testServerlessWorker } from "../workerSettings.js";
|
|
import { performPreflightCheck } from "../executionUtils.js";
|
|
|
|
function createDocumentMock() {
|
|
const elements = new Map();
|
|
const makeElement = (tagName) => {
|
|
const element = {
|
|
tagName,
|
|
children: [],
|
|
dataset: {},
|
|
style: {},
|
|
setAttribute(name, value) { this[name] = value; },
|
|
classList: {
|
|
values: new Set(),
|
|
add(...names) { names.forEach((name) => this.values.add(name)); },
|
|
remove(...names) { names.forEach((name) => this.values.delete(name)); },
|
|
toggle(name, force) {
|
|
const enabled = force ?? !this.values.has(name);
|
|
if (enabled) this.values.add(name);
|
|
else this.values.delete(name);
|
|
return enabled;
|
|
},
|
|
},
|
|
appendChild(child) { this.children.push(child); child.parentNode = this; return child; },
|
|
dispatchEvent() { this.onchange?.({ target: this }); },
|
|
};
|
|
Object.defineProperty(element, "id", {
|
|
get() { return this._id || ""; },
|
|
set(value) { this._id = value; elements.set(value, this); },
|
|
});
|
|
return element;
|
|
};
|
|
return {
|
|
elements,
|
|
document: {
|
|
createElement: makeElement,
|
|
getElementById: (id) => elements.get(id) || null,
|
|
querySelector: () => null,
|
|
},
|
|
};
|
|
}
|
|
|
|
function createFormUi() {
|
|
return {
|
|
createFormGroup(label, value, id, type = "text", placeholder = "") {
|
|
const group = document.createElement("div");
|
|
const input = document.createElement("input");
|
|
input.id = id;
|
|
input.value = String(value ?? "");
|
|
input.type = type;
|
|
input.placeholder = placeholder;
|
|
group.appendChild(input);
|
|
return { group, input, label };
|
|
},
|
|
createButton(text, onclick) {
|
|
const button = document.createElement("button");
|
|
button.textContent = text;
|
|
button.onclick = onclick;
|
|
return button;
|
|
},
|
|
createButtonGroup(buttons) {
|
|
const group = document.createElement("div");
|
|
buttons.forEach((button) => group.appendChild(button));
|
|
return group;
|
|
},
|
|
};
|
|
}
|
|
|
|
function makeSaveExtension(worker, updateWorker = vi.fn().mockResolvedValue({})) {
|
|
return {
|
|
config: { workers: [worker] },
|
|
api: { updateWorker },
|
|
state: { updateWorker: vi.fn(), setWorkerExpanded: vi.fn() },
|
|
app: { extensionManager: { toast: { add: vi.fn() } } },
|
|
panelElement: null,
|
|
};
|
|
}
|
|
|
|
describe("Runpod serverless worker panel", () => {
|
|
let originalDocument;
|
|
let originalWindow;
|
|
let elements;
|
|
|
|
beforeEach(() => {
|
|
originalDocument = globalThis.document;
|
|
originalWindow = globalThis.window;
|
|
const mock = createDocumentMock();
|
|
elements = mock.elements;
|
|
globalThis.document = mock.document;
|
|
globalThis.window = {
|
|
location: { hostname: "localhost", protocol: "http:", port: "8188", origin: "http://localhost:8188" },
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.document = originalDocument;
|
|
globalThis.window = originalWindow;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("classifies Runpod serverless as remote for worker allocation", () => {
|
|
expect(isRemoteWorker({}, { type: "serverless" })).toBe(true);
|
|
});
|
|
|
|
it("initializes a saved serverless form before it is attached to the document", () => {
|
|
// Real document lookups cannot see a form while its card is being built.
|
|
vi.spyOn(document, "getElementById").mockReturnValue(null);
|
|
const worker = {
|
|
id: "saved", name: "Runpod", type: "serverless", endpoint_id: "ep_123",
|
|
jobs_per_run: 2, concurrency: 1, timeout_seconds: 900, api_key_configured: true,
|
|
};
|
|
const form = createWorkerSettingsForm(createFormUi(), {}, worker);
|
|
|
|
expect(form.parentNode).toBeUndefined();
|
|
expect(elements.get("worker-type-saved").value).toBe("serverless");
|
|
for (const field of ["host", "port", "cuda", "args"]) {
|
|
expect(elements.get(`${field}-group-saved`).style.display).toBe("none");
|
|
}
|
|
expect(elements.get("runpod-text-saved").style.display).toBe("none");
|
|
for (const field of ["endpoint-id", "media-profile", "api-key", "clear-api-key", "jobs-per-run", "timeout-seconds", "serverless-test"]) {
|
|
expect(elements.get(`${field}-group-saved`).style.display).toBe("flex");
|
|
}
|
|
// The separate concurrency setting was removed; a legacy value is ignored.
|
|
expect(elements.has("concurrency-saved")).toBe(false);
|
|
expect(elements.has("concurrency-group-saved")).toBe(false);
|
|
expect(elements.get("endpoint-id-saved").value).toBe("ep_123");
|
|
expect(elements.get("media-profile-saved").value).toBe("legacy-image-v1");
|
|
expect(elements.get("jobs-per-run-saved").value).toBe("2");
|
|
expect(elements.get("timeout-seconds-saved").value).toBe("900");
|
|
expect(elements.get("api-key-saved").value).toBe("");
|
|
expect(elements.get("api-key-saved").placeholder).toMatch(/leave blank/i);
|
|
});
|
|
|
|
it("renders explicit endpoint profiles with installation and no-auto-detection guidance", () => {
|
|
createWorkerSettingsForm(createFormUi(), {}, {
|
|
id: "profile", name: "Runpod", type: "serverless", media_profile: "comfy-media-v1",
|
|
});
|
|
const select = elements.get("media-profile-profile");
|
|
const group = elements.get("media-profile-group-profile");
|
|
expect(group.children[0].textContent).toBe("Endpoint media profile");
|
|
expect(group.children[0].htmlFor).toBe(select.id);
|
|
expect(select.children.map((option) => [option.value, option.textContent])).toEqual([
|
|
["legacy-image-v1", "Legacy images (existing handler)"],
|
|
["comfy-media-v1", "Typed media v1 (requires endpoint bridge)"],
|
|
]);
|
|
expect(select.value).toBe("comfy-media-v1");
|
|
expect(select.title).toMatch(/after installing.*endpoint bridge/i);
|
|
expect(select.title).toMatch(/health checks do not auto-detect/i);
|
|
expect(select.title).toMatch(/schema.*formats and limits are fixed/i);
|
|
const type = elements.get("worker-type-profile");
|
|
type.value = "remote";
|
|
type.dispatchEvent();
|
|
expect(group.style.display).toBe("none");
|
|
type.value = "serverless";
|
|
type.dispatchEvent();
|
|
expect(group.style.display).toBe("flex");
|
|
expect(select.value).toBe("comfy-media-v1");
|
|
});
|
|
|
|
it.each(["legacy-image-v1", "comfy-media-v1"])("round-trips an explicit %s selection through the save payload and reload", async (profile) => {
|
|
const worker = { id: "roundtrip", name: "Runpod", type: "serverless", endpoint_id: "ep_123", enabled: false };
|
|
let persisted;
|
|
const extension = makeSaveExtension(worker, vi.fn(async (id, payload) => {
|
|
persisted = JSON.parse(JSON.stringify({ id, ...payload }));
|
|
return {};
|
|
}));
|
|
const ui = createFormUi();
|
|
const form = createWorkerSettingsForm(ui, extension, worker);
|
|
elements.get("media-profile-roundtrip").value = profile;
|
|
const saveButton = form.children.at(-1).children.find((button) => button.textContent === "Save");
|
|
await saveButton.onclick();
|
|
expect(persisted.media_profile).toBe(profile);
|
|
expect(worker.media_profile).toBe(profile);
|
|
expect(persisted).not.toHaveProperty("api_key");
|
|
expect(persisted).not.toHaveProperty("concurrency");
|
|
createWorkerSettingsForm(ui, extension, persisted);
|
|
expect(elements.get("media-profile-roundtrip").value).toBe(profile);
|
|
});
|
|
|
|
it.each([undefined, "comfy-media-v1"])("restores the saved profile on cancel (%s)", (profile) => {
|
|
const worker = { id: "cancel", name: "Runpod", type: "serverless", media_profile: profile };
|
|
const extension = makeSaveExtension(worker);
|
|
createWorkerSettingsForm(createFormUi(), extension, worker);
|
|
elements.get("media-profile-cancel").value = profile ? "legacy-image-v1" : "comfy-media-v1";
|
|
cancelWorkerSettings(extension, worker.id);
|
|
expect(elements.get("media-profile-cancel").value).toBe(profile ?? "legacy-image-v1");
|
|
expect(extension.api.updateWorker).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps the saved profile after a busy-guard error and rejects invalid selections", async () => {
|
|
const worker = { id: "busy", name: "Runpod", type: "serverless", endpoint_id: "ep_123", media_profile: "comfy-media-v1" };
|
|
const extension = makeSaveExtension(worker, vi.fn().mockRejectedValue(new Error("Finish or reconcile unresolved serverless jobs")));
|
|
createWorkerSettingsForm(createFormUi(), extension, worker);
|
|
elements.get("media-profile-busy").value = "legacy-image-v1";
|
|
await saveWorkerSettings(extension, worker.id);
|
|
expect(worker.media_profile).toBe("comfy-media-v1");
|
|
expect(extension.app.extensionManager.toast.add).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ summary: "Save Failed", detail: expect.stringMatching(/unresolved/) }),
|
|
);
|
|
elements.get("media-profile-busy").value = "auto";
|
|
await saveWorkerSettings(extension, worker.id);
|
|
expect(extension.api.updateWorker).toHaveBeenCalledTimes(1);
|
|
expect(extension.app.extensionManager.toast.add).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ summary: "Validation Error", detail: expect.stringMatching(/media profile/) }),
|
|
);
|
|
});
|
|
|
|
it("preserves a saved cloud port on first render and applies defaults only on type changes", () => {
|
|
vi.spyOn(document, "getElementById").mockReturnValue(null);
|
|
createWorkerSettingsForm(createFormUi(), {}, {
|
|
id: "cloud", name: "Cloud", type: "cloud", host: "worker.example", port: 8443,
|
|
});
|
|
const type = elements.get("worker-type-cloud");
|
|
expect(type.value).toBe("cloud");
|
|
expect(elements.get("port-cloud").value).toBe("8443");
|
|
expect(elements.get("host-cloud").value).toBe("worker.example");
|
|
expect(elements.get("runpod-text-cloud").style.display).toBe("block");
|
|
|
|
type.value = "remote";
|
|
type.dispatchEvent();
|
|
type.value = "cloud";
|
|
type.dispatchEvent();
|
|
expect(elements.get("port-cloud").value).toBe("443");
|
|
});
|
|
|
|
it("shows only serverless settings and keeps credentials write-only", () => {
|
|
const worker = {
|
|
id: "w1", name: "Runpod", type: "remote", host: "worker.example", port: 8188,
|
|
cuda_device: 0, extra_args: "--test", api_key_configured: true,
|
|
};
|
|
const form = createWorkerSettingsForm(createFormUi(), {}, worker);
|
|
const type = elements.get("worker-type-w1");
|
|
type.value = "serverless";
|
|
type.dispatchEvent();
|
|
|
|
expect(type.children.map((option) => [option.value, option.textContent])).toContainEqual([
|
|
"serverless", "Serverless (Runpod)",
|
|
]);
|
|
for (const id of ["host-group-w1", "port-group-w1", "cuda-group-w1", "args-group-w1"]) {
|
|
expect(elements.get(id).style.display).toBe("none");
|
|
}
|
|
for (const id of ["endpoint-id-w1", "api-key-w1", "jobs-per-run-w1", "timeout-seconds-w1"]) {
|
|
expect(elements.has(id)).toBe(true);
|
|
}
|
|
expect(elements.has("concurrency-w1")).toBe(false);
|
|
for (const id of ["endpoint-id-group-w1", "api-key-group-w1", "clear-api-key-group-w1", "jobs-per-run-group-w1", "timeout-seconds-group-w1", "serverless-test-group-w1"]) {
|
|
expect(elements.get(id).style.display).toBe("flex");
|
|
}
|
|
expect(elements.has("serverless-help-w1")).toBe(false);
|
|
expect(elements.get("api-key-w1").type).toBe("password");
|
|
expect(elements.get("api-key-w1").value).toBe("");
|
|
expect(elements.get("api-key-w1").placeholder).toMatch(/leave blank/i);
|
|
expect(elements.get("jobs-per-run-w1").value).toBe("1");
|
|
expect(elements.get("jobs-per-run-w1").min).toBe("1");
|
|
expect(elements.get("jobs-per-run-w1").max).toBe("16");
|
|
expect(elements.get("jobs-per-run-w1").title).toBe(
|
|
"Separate Runpod jobs submitted each run, all at once. Runpod's max workers decides how many GPUs actually run them.",
|
|
);
|
|
expect(elements.get("timeout-seconds-w1").value).toBe("1800");
|
|
expect(elements.get("timeout-seconds-w1").min).toBe("30");
|
|
expect(elements.get("timeout-seconds-w1").max).toBe("86400");
|
|
expect(form.children.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("disables Clear saved API key while the worker is enabled and explains why", () => {
|
|
vi.spyOn(document, "getElementById").mockReturnValue(null);
|
|
const base = { name: "Runpod", type: "serverless", endpoint_id: "ep_123" };
|
|
createWorkerSettingsForm(createFormUi(), {}, { ...base, id: "on", enabled: true, api_key_configured: true });
|
|
createWorkerSettingsForm(createFormUi(), {}, { ...base, id: "off", enabled: false, api_key_configured: true });
|
|
createWorkerSettingsForm(createFormUi(), {}, { ...base, id: "nokey", enabled: false, api_key_configured: false });
|
|
|
|
expect(elements.get("clear-api-key-on").disabled).toBe(true);
|
|
expect(elements.get("clear-api-key-group-on").title).toMatch(/turn this worker off/i);
|
|
expect(elements.get("clear-api-key-off").disabled).toBe(false);
|
|
expect(elements.get("clear-api-key-nokey").disabled).toBe(true);
|
|
expect(elements.get("clear-api-key-group-nokey").title).toMatch(/no api key is saved/i);
|
|
for (const id of ["on", "off", "nokey"]) {
|
|
expect(elements.get(`clear-api-key-group-${id}`).title).not.toMatch(/pool/i);
|
|
}
|
|
});
|
|
|
|
it("saves fixed Runpod settings without enabling or retaining the API key", async () => {
|
|
const worker = { id: "w2", name: "Old", type: "remote", enabled: false, api_key_configured: false };
|
|
const extension = makeSaveExtension(worker);
|
|
elements.set("name-w2", { value: "Runpod worker" });
|
|
elements.set("worker-type-w2", { value: "serverless" });
|
|
elements.set("endpoint-id-w2", { value: "ep_123" });
|
|
elements.set("media-profile-w2", { value: "legacy-image-v1" });
|
|
elements.set("api-key-w2", { value: "secret-token" });
|
|
elements.set("clear-api-key-w2", { checked: false });
|
|
elements.set("jobs-per-run-w2", { value: "4" });
|
|
elements.set("timeout-seconds-w2", { value: "2400" });
|
|
|
|
await saveWorkerSettings(extension, "w2");
|
|
|
|
expect(extension.api.updateWorker).toHaveBeenCalledWith("w2", {
|
|
name: "Runpod worker", type: "serverless", provider: "runpod", endpoint_id: "ep_123",
|
|
media_profile: "legacy-image-v1",
|
|
api_key: "secret-token", jobs_per_run: 4, timeout_seconds: 2400, enabled: false,
|
|
});
|
|
expect(worker).toMatchObject({ type: "serverless", provider: "runpod", enabled: false, api_key_configured: true });
|
|
expect(worker).not.toHaveProperty("api_key");
|
|
expect(JSON.stringify(extension.config)).not.toContain("secret-token");
|
|
expect(elements.get("api-key-w2").value).toBe("");
|
|
expect(extension.state.updateWorker).toHaveBeenCalledWith("w2", { enabled: false });
|
|
expect(JSON.stringify(extension.state.updateWorker.mock.calls)).not.toContain("secret-token");
|
|
});
|
|
|
|
it("labels the single parallelism setting Images per run and drops a legacy concurrency value", async () => {
|
|
const worker = { id: "legacy", name: "Runpod", type: "serverless", provider: "runpod", enabled: false,
|
|
api_key_configured: true, jobs_per_run: 1, concurrency: 1 };
|
|
const extension = makeSaveExtension(worker);
|
|
elements.set("name-legacy", { value: "Runpod" });
|
|
elements.set("worker-type-legacy", { value: "serverless" });
|
|
elements.set("endpoint-id-legacy", { value: "ep_123" });
|
|
elements.set("media-profile-legacy", { value: "legacy-image-v1" });
|
|
elements.set("api-key-legacy", { value: "" });
|
|
elements.set("clear-api-key-legacy", { checked: false });
|
|
elements.set("jobs-per-run-legacy", { value: "2" });
|
|
elements.set("timeout-seconds-legacy", { value: "1800" });
|
|
|
|
await saveWorkerSettings(extension, "legacy");
|
|
|
|
const payload = extension.api.updateWorker.mock.calls[0][1];
|
|
expect(payload.jobs_per_run).toBe(2);
|
|
expect(payload).not.toHaveProperty("concurrency");
|
|
expect(worker.jobs_per_run).toBe(2);
|
|
expect(worker).not.toHaveProperty("concurrency");
|
|
|
|
elements.set("jobs-per-run-legacy", { value: "17" });
|
|
await saveWorkerSettings(extension, "legacy");
|
|
expect(extension.api.updateWorker).toHaveBeenCalledTimes(1);
|
|
expect(extension.app.extensionManager.toast.add).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ detail: "Images per run must be a whole number from 1 to 16" }),
|
|
);
|
|
});
|
|
|
|
it("trims a pasted Runpod URL to the endpoint ID and explains other invalid IDs", async () => {
|
|
expect(normalizeRunpodEndpointId(" abc123xyz ")).toBe("abc123xyz");
|
|
expect(normalizeRunpodEndpointId("https://api.runpod.ai/v2/abc123xyz/run")).toBe("abc123xyz");
|
|
expect(normalizeRunpodEndpointId("api.runpod.ai/v2/abc_12-3")).toBe("abc_12-3");
|
|
expect(normalizeRunpodEndpointId("https://www.runpod.io/console/serverless/user/endpoint/abc")).toBe(
|
|
"https://www.runpod.io/console/serverless/user/endpoint/abc",
|
|
);
|
|
|
|
const worker = { id: "url", name: "Runpod", type: "serverless", provider: "runpod", enabled: false, api_key_configured: true };
|
|
const extension = makeSaveExtension(worker);
|
|
elements.set("name-url", { value: "Runpod" });
|
|
elements.set("worker-type-url", { value: "serverless" });
|
|
elements.set("endpoint-id-url", { value: "https://api.runpod.ai/v2/abc123xyz/runsync" });
|
|
elements.set("media-profile-url", { value: "legacy-image-v1" });
|
|
elements.set("api-key-url", { value: "" });
|
|
elements.set("clear-api-key-url", { checked: false });
|
|
elements.set("jobs-per-run-url", { value: "1" });
|
|
elements.set("timeout-seconds-url", { value: "1800" });
|
|
await saveWorkerSettings(extension, "url");
|
|
expect(extension.api.updateWorker.mock.calls[0][1].endpoint_id).toBe("abc123xyz");
|
|
|
|
elements.set("endpoint-id-url", { value: "https://www.runpod.io/console/serverless/endpoint/abc" });
|
|
await saveWorkerSettings(extension, "url");
|
|
expect(extension.api.updateWorker).toHaveBeenCalledTimes(1);
|
|
expect(extension.app.extensionManager.toast.add).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ detail: expect.stringMatching(/just the Runpod endpoint ID/) }),
|
|
);
|
|
});
|
|
|
|
it("asks before a type change deletes the saved API key, and cancels cleanly", async () => {
|
|
const worker = { id: "conv", name: "Runpod", type: "serverless", provider: "runpod", enabled: false, api_key_configured: true, media_profile: "comfy-media-v1" };
|
|
const extension = makeSaveExtension(worker);
|
|
elements.set("name-conv", { value: "Runpod" });
|
|
elements.set("worker-type-conv", { value: "remote" });
|
|
elements.set("host-conv", { value: "192.168.1.20" });
|
|
elements.set("port-conv", { value: "8188" });
|
|
const confirmSpy = vi.fn().mockReturnValue(false);
|
|
globalThis.confirm = confirmSpy;
|
|
try {
|
|
await saveWorkerSettings(extension, "conv");
|
|
expect(confirmSpy).toHaveBeenCalledWith(expect.stringMatching(/saved Runpod API key will be deleted/));
|
|
expect(extension.api.updateWorker).not.toHaveBeenCalled();
|
|
expect(worker.type).toBe("serverless");
|
|
expect(worker.media_profile).toBe("comfy-media-v1");
|
|
|
|
confirmSpy.mockReturnValue(true);
|
|
await saveWorkerSettings(extension, "conv");
|
|
expect(extension.api.updateWorker).toHaveBeenCalledTimes(1);
|
|
expect(extension.api.updateWorker.mock.calls[0][1].type).toBe("remote");
|
|
expect(worker).not.toHaveProperty("media_profile");
|
|
} finally {
|
|
delete globalThis.confirm;
|
|
}
|
|
});
|
|
|
|
it("does not ask for confirmation when a serverless worker without a key changes type", async () => {
|
|
const worker = { id: "nokey2", name: "Runpod", type: "serverless", provider: "runpod", enabled: false, api_key_configured: false };
|
|
const extension = makeSaveExtension(worker);
|
|
elements.set("name-nokey2", { value: "Runpod" });
|
|
elements.set("worker-type-nokey2", { value: "remote" });
|
|
elements.set("host-nokey2", { value: "192.168.1.20" });
|
|
elements.set("port-nokey2", { value: "8188" });
|
|
const confirmSpy = vi.fn().mockReturnValue(false);
|
|
globalThis.confirm = confirmSpy;
|
|
try {
|
|
await saveWorkerSettings(extension, "nokey2");
|
|
expect(confirmSpy).not.toHaveBeenCalled();
|
|
expect(extension.api.updateWorker).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
delete globalThis.confirm;
|
|
}
|
|
});
|
|
|
|
it("omits a blank API key so the saved key is preserved", async () => {
|
|
const worker = { id: "w-blank", name: "Runpod", type: "serverless", provider: "runpod", enabled: true, api_key_configured: true };
|
|
const extension = makeSaveExtension(worker);
|
|
elements.set("name-w-blank", { value: "Runpod" });
|
|
elements.set("worker-type-w-blank", { value: "serverless" });
|
|
elements.set("endpoint-id-w-blank", { value: "ep_123" });
|
|
elements.set("media-profile-w-blank", { value: "legacy-image-v1" });
|
|
elements.set("api-key-w-blank", { value: "" });
|
|
elements.set("clear-api-key-w-blank", { checked: false });
|
|
elements.set("jobs-per-run-w-blank", { value: "1" });
|
|
elements.set("timeout-seconds-w-blank", { value: "1800" });
|
|
|
|
await saveWorkerSettings(extension, "w-blank");
|
|
|
|
const payload = extension.api.updateWorker.mock.calls[0][1];
|
|
expect(payload).not.toHaveProperty("api_key");
|
|
expect(payload).not.toHaveProperty("clear_api_key");
|
|
expect(worker.api_key_configured).toBe(true);
|
|
expect(worker).not.toHaveProperty("api_key");
|
|
});
|
|
|
|
it("omits a blank key to preserve it and sends an explicit clear flag when requested", async () => {
|
|
const worker = { id: "w3", name: "Runpod", type: "serverless", provider: "runpod", enabled: true, api_key_configured: true };
|
|
const extension = makeSaveExtension(worker);
|
|
elements.set("name-w3", { value: "Runpod" });
|
|
elements.set("worker-type-w3", { value: "serverless" });
|
|
elements.set("endpoint-id-w3", { value: "ep_123" });
|
|
elements.set("media-profile-w3", { value: "legacy-image-v1" });
|
|
elements.set("api-key-w3", { value: "" });
|
|
elements.set("clear-api-key-w3", { checked: true });
|
|
elements.set("jobs-per-run-w3", { value: "1" });
|
|
elements.set("timeout-seconds-w3", { value: "1800" });
|
|
|
|
await saveWorkerSettings(extension, "w3");
|
|
|
|
expect(extension.api.updateWorker).toHaveBeenCalledWith("w3", {
|
|
name: "Runpod", type: "serverless", provider: "runpod", endpoint_id: "ep_123",
|
|
media_profile: "legacy-image-v1",
|
|
clear_api_key: true, jobs_per_run: 1, timeout_seconds: 1800, enabled: true,
|
|
});
|
|
expect(worker.api_key_configured).toBe(false);
|
|
expect(worker).not.toHaveProperty("api_key");
|
|
});
|
|
|
|
it.each([
|
|
[undefined, "Checking status…"],
|
|
[{ ok: true, queueRemaining: 0, jobs: [] }, "Ready"],
|
|
[{ ok: true, queueRemaining: 1, jobs: [{ status: "IN_QUEUE" }] }, "Queued"],
|
|
[{ ok: true, jobs: [{ status: "IN_PROGRESS" }] }, "Running"],
|
|
[{ ok: true, jobs: [{ status: "RUNNING" }, { status: "IN_QUEUE" }] }, "Running"],
|
|
[{ ok: true, jobs: [{ status: "RUNNING" }, { status: "IN_PROGRESS" }] }, "Running · 2 jobs"],
|
|
[{ ok: true, jobs: [{ status: "DOWNLOADING" }] }, "Downloading result"],
|
|
[{ ok: true, jobs: [{ status: "CANCEL_REQUESTED" }] }, "Cancelling"],
|
|
[{ ok: true, queueRemaining: 1 }, "Active · 1 job"],
|
|
[{ ok: true, queueRemaining: 3 }, "Active · 3 jobs"],
|
|
[{ ok: true, jobs: [{ status: "COMPLETED" }] }, "Ready"],
|
|
[{ ok: true, jobs: [{ status: "FAILED" }] }, "Last job failed"],
|
|
[{ ok: false, message: "Check the saved endpoint ID and API key" }, "Unavailable"],
|
|
])("formats provider state as one plain-language summary: %j", (status, expected) => {
|
|
expect(formatServerlessStatus(status)).toBe(expected);
|
|
});
|
|
|
|
it("keeps status above a consistent endpoint info box and retains expanded settings", () => {
|
|
const ui = new DistributedUI();
|
|
const worker = {
|
|
id: "compact", name: "Used Car Inventory", type: "serverless", enabled: true,
|
|
endpoint_id: "ep_private_id", jobs_per_run: 1, concurrency: 1,
|
|
};
|
|
const extension = {
|
|
config: { workers: [worker] },
|
|
state: {
|
|
isWorkerExpanded: () => false,
|
|
getWorker: () => ({ serverlessStatus: { ok: true, queueRemaining: 1, jobs: [{ status: "IN_QUEUE" }] } }),
|
|
},
|
|
};
|
|
const card = ui.renderEntityCard("worker", worker, extension);
|
|
const collect = (element) => [element, ...(element.children || []).flatMap(collect)];
|
|
const info = collect(card).find((element) => element.classList.values.has("dist-worker-info"));
|
|
expect(info.innerHTML).not.toContain("Runpod Serverless");
|
|
expect(info.innerHTML).not.toMatch(/ep_private_id|jobs\/run|slots/);
|
|
expect(elements.get("serverless-status-compact").textContent).toBe("Queued");
|
|
expect(elements.get("endpoint-id-compact").value).toBe("ep_private_id");
|
|
expect(Number(elements.get("jobs-per-run-compact").value)).toBe(1);
|
|
expect(elements.has("concurrency-compact")).toBe(false);
|
|
const box = elements.get("controls-compact").children[0].children[0];
|
|
expect(box.textContent).toBe("Runpod Serverless endpoint");
|
|
expect(box.classList.values.has("dist-info-box")).toBe(true);
|
|
expect(box.style.cssText).toBe(ui.styles.infoBox);
|
|
expect(elements.get("status-compact").style.cssText).toContain("flex: 0 0 10px");
|
|
});
|
|
|
|
it("uses provider status instead of a direct ComfyUI URL probe", async () => {
|
|
const statusElement = { textContent: "" };
|
|
elements.set("serverless-status-w4", statusElement);
|
|
const status = {
|
|
ok: true, queueRemaining: 0, message: "Ready to scale",
|
|
jobs: [{ job_id: "job-1", status: "RUNNING", worker_id: "w4", execution_id: "exec-1" }],
|
|
};
|
|
const extension = {
|
|
config: { workers: [{ id: "w4", name: "Runpod", type: "serverless", enabled: true }] },
|
|
api: { getServerlessStatus: vi.fn().mockResolvedValue(status), probeWorker: vi.fn() },
|
|
statusCheckAbortController: null,
|
|
state: {
|
|
setWorkerStatus: vi.fn(),
|
|
updateWorker: vi.fn(),
|
|
getWorkerStatus: vi.fn().mockReturnValue({ online: true, processing: false, queueCount: 0 }),
|
|
getWorker: vi.fn().mockReturnValue({}),
|
|
isWorkerLaunching: vi.fn().mockReturnValue(false),
|
|
},
|
|
updateWorkerCard: vi.fn().mockReturnValue(true),
|
|
log: vi.fn(),
|
|
};
|
|
|
|
await checkWorkerStatus(extension, extension.config.workers[0]);
|
|
|
|
expect(extension.api.getServerlessStatus).toHaveBeenCalledWith("w4");
|
|
expect(extension.api.probeWorker).not.toHaveBeenCalled();
|
|
expect(extension.state.setWorkerStatus).toHaveBeenCalledWith("w4", {
|
|
online: true, processing: true, queueCount: 0,
|
|
});
|
|
expect(statusElement.textContent).toBe("Running");
|
|
expect(statusElement.title).toBe("Ready to scale");
|
|
});
|
|
|
|
it("considers a healthy zero-worker Runpod pool ready and never builds a worker URL", async () => {
|
|
const worker = { id: "w-ready", name: "Runpod", type: "serverless", enabled: true };
|
|
const extension = {
|
|
api: {
|
|
getServerlessStatus: vi.fn().mockResolvedValue({ ok: true, queueRemaining: 0, message: "Ready to scale" }),
|
|
probeWorker: vi.fn(),
|
|
},
|
|
log: vi.fn(),
|
|
};
|
|
|
|
await expect(performPreflightCheck(extension, [worker])).resolves.toEqual([worker]);
|
|
expect(extension.api.getServerlessStatus).toHaveBeenCalledWith("w-ready");
|
|
expect(extension.api.probeWorker).not.toHaveBeenCalled();
|
|
expect(getWorkerUrl(extension, worker)).toBeNull();
|
|
});
|
|
|
|
it("does not render or invoke direct ComfyUI launch, stop, or log controls", async () => {
|
|
const ui = new DistributedUI();
|
|
const worker = { id: "w-controls", name: "Runpod", type: "serverless", enabled: true };
|
|
const controls = ui.createControlsSection({ dynamic: true }, worker, {}, true);
|
|
|
|
const extension = {
|
|
config: { workers: [worker] },
|
|
api: {
|
|
launchWorker: vi.fn(), stopWorker: vi.fn(), getRemoteWorkerLog: vi.fn(),
|
|
},
|
|
};
|
|
|
|
const box = controls.children[0].children[0];
|
|
expect(box.textContent).toBe("Runpod Serverless endpoint");
|
|
expect(box.tagName).toBe("div");
|
|
expect(controls.children[0].children).toHaveLength(1);
|
|
await launchWorker(extension, worker.id);
|
|
await stopWorker(extension, worker.id);
|
|
await viewWorkerLog(extension, worker.id, true);
|
|
expect(extension.api.launchWorker).not.toHaveBeenCalled();
|
|
expect(extension.api.stopWorker).not.toHaveBeenCalled();
|
|
expect(extension.api.getRemoteWorkerLog).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses the serverless status, test, and cancel routes with the frozen request shapes", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
const statusPayload = {
|
|
ok: true,
|
|
queueRemaining: 0,
|
|
message: "Ready to scale",
|
|
jobs: [{ job_id: "job-1", status: "RUNNING", worker_id: "w5", execution_id: "exec-1" }],
|
|
};
|
|
globalThis.fetch = vi.fn()
|
|
.mockResolvedValueOnce({ ok: true, status: 200, json: vi.fn().mockResolvedValue({ ok: true, message: "Endpoint test passed" }) })
|
|
.mockResolvedValueOnce({ ok: true, status: 200, json: vi.fn().mockResolvedValue(statusPayload) })
|
|
.mockResolvedValueOnce({ ok: true, status: 200, json: vi.fn().mockResolvedValue({ ok: true }) });
|
|
try {
|
|
const client = createApiClient("http://localhost:8188");
|
|
await client.testServerlessWorker("w5");
|
|
await expect(client.getServerlessStatus("w5")).resolves.toEqual(statusPayload);
|
|
await client.cancelServerlessWorker("w5");
|
|
|
|
expect(globalThis.fetch).toHaveBeenNthCalledWith(1, "http://localhost:8188/distributed/serverless/test", expect.objectContaining({
|
|
method: "POST", body: JSON.stringify({ worker_id: "w5" }),
|
|
}));
|
|
expect(globalThis.fetch).toHaveBeenNthCalledWith(2, "http://localhost:8188/distributed/serverless/status/w5", expect.any(Object));
|
|
expect(globalThis.fetch).toHaveBeenNthCalledWith(3, "http://localhost:8188/distributed/serverless/cancel", expect.objectContaining({
|
|
method: "POST", body: JSON.stringify({ worker_id: "w5" }),
|
|
}));
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
it("tests only the saved Runpod endpoint and reports the backend message", async () => {
|
|
const extension = {
|
|
api: {
|
|
testServerlessWorker: vi.fn().mockResolvedValue({ ok: true, message: "Endpoint reachable" }),
|
|
queueDistributed: vi.fn(),
|
|
},
|
|
app: { extensionManager: { toast: { add: vi.fn() } } },
|
|
};
|
|
const worker = { id: "w6", name: "Runpod", type: "serverless" };
|
|
extension.config = { workers: [worker] };
|
|
createWorkerSettingsForm(createFormUi(), extension, worker);
|
|
const button = elements.get("test-serverless-w6");
|
|
|
|
await expect(testServerlessWorker(extension, "w6")).resolves.toEqual({ ok: true, message: "Endpoint reachable" });
|
|
|
|
expect(worker).not.toHaveProperty("media_profile");
|
|
expect(elements.get("media-profile-w6").value).toBe("legacy-image-v1");
|
|
expect(extension.api.testServerlessWorker).toHaveBeenCalledWith("w6");
|
|
expect(extension.api.queueDistributed).not.toHaveBeenCalled();
|
|
expect(extension.app.extensionManager.toast.add).toHaveBeenCalledWith(expect.objectContaining({
|
|
severity: "success", detail: "Endpoint reachable",
|
|
}));
|
|
expect(button.textContent).toBe("Test Saved Endpoint");
|
|
expect(button.disabled).toBe(false);
|
|
});
|
|
|
|
it("redacts API-key-bearing worker update errors from logs and user-visible exceptions", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
const secret = "never-log-this-runpod-key";
|
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 400,
|
|
json: vi.fn().mockResolvedValue({ message: `Rejected key ${secret}` }),
|
|
});
|
|
try {
|
|
const client = createApiClient("http://localhost:8188");
|
|
await expect(client.updateWorker("w-secret", { api_key: secret })).rejects.toThrow("[redacted]");
|
|
expect(logSpy.mock.calls.flat().join(" ")).not.toContain(secret);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
it("never sends VRAM operations to serverless workers", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = vi.fn();
|
|
vi.useFakeTimers();
|
|
const button = {
|
|
textContent: "Clear Worker VRAM", style: { cssText: "" }, disabled: false,
|
|
classList: { names: new Set(), add(...names) { names.forEach((name) => this.names.add(name)); }, remove(...names) { names.forEach((name) => this.names.delete(name)); } },
|
|
};
|
|
const extension = { enabledWorkers: [{ id: "s-vram", name: "Runpod", type: "serverless", enabled: true }] };
|
|
try {
|
|
await handleClearMemory(extension, button);
|
|
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
} finally {
|
|
vi.runOnlyPendingTimers();
|
|
vi.useRealTimers();
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
it("cancels each enabled serverless worker once without changing normal worker interrupts", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true });
|
|
vi.useFakeTimers();
|
|
const button = {
|
|
textContent: "Interrupt Workers", style: { cssText: "" }, disabled: false,
|
|
classList: { names: new Set(), add(...names) { names.forEach((name) => this.names.add(name)); }, remove(...names) { names.forEach((name) => this.names.delete(name)); } },
|
|
};
|
|
const workers = [
|
|
{ id: "s1", name: "Serverless 1", type: "serverless", enabled: true },
|
|
{ id: "s2", name: "Serverless 2", type: "serverless", enabled: true },
|
|
{ id: "s3", name: "Disabled", type: "serverless", enabled: false },
|
|
{ id: "local", name: "Local", type: "local", enabled: true, port: 8189 },
|
|
];
|
|
const extension = {
|
|
enabledWorkers: workers.filter((worker) => worker.enabled),
|
|
api: { cancelServerlessWorker: vi.fn().mockResolvedValue({ ok: true }) },
|
|
log: vi.fn(),
|
|
};
|
|
try {
|
|
await handleInterruptWorkers(extension, button);
|
|
|
|
expect(extension.api.cancelServerlessWorker).toHaveBeenCalledTimes(2);
|
|
expect(extension.api.cancelServerlessWorker).toHaveBeenNthCalledWith(1, "s1");
|
|
expect(extension.api.cancelServerlessWorker).toHaveBeenNthCalledWith(2, "s2");
|
|
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
expect(globalThis.fetch.mock.calls[0][0]).toBe("http://localhost:8189/interrupt");
|
|
} finally {
|
|
vi.runOnlyPendingTimers();
|
|
vi.useRealTimers();
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
});
|