Files
Artificial-Sweetener-Simple…/web/tests/settings.test.ts
T

511 lines
18 KiB
TypeScript

// SimpleSyrup - workflow-focused ComfyUI extensions for image generation
// Copyright (C) 2026 Artificial Sweetener and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
import { describe, expect, it, vi } from "vitest";
import {
SIMPLE_SYRUP_SETTING_ID,
SIMPLE_SYRUP_SETTING_DESCRIPTION,
SIMPLE_SYRUP_SETTING_LABEL
} from "../src/downloadableModelsSetting";
import {
EXTERNAL_LLM_API_KEY_SETTING_ID,
EXTERNAL_LLM_ENDPOINT_SETTING_ID
} from "../src/externalLlmSettings";
import {
registerSimpleSyrupSettings
} from "../src/settingsRegistration";
import type { SimpleSyrupSettingsApi } from "../src/settingsRegistration";
import { QUANT_CACHE_SETTING_ID } from "../src/quantCacheSetting";
import { createFakeComfyApp } from "./testUtils";
describe("Comfy settings registration", () => {
it("registers the ShowDownloadableModels setting from backend state", async () => {
const app = createFakeComfyApp();
const api = fakeSettingsApi(false);
await registerSimpleSyrupSettings(app, api);
expect(app.ui.settings.definitions).toHaveLength(4);
expect(app.ui.settings.definitions[0]).toMatchObject({
id: SIMPLE_SYRUP_SETTING_ID,
name: SIMPLE_SYRUP_SETTING_LABEL,
type: "boolean",
defaultValue: false,
tooltip: SIMPLE_SYRUP_SETTING_DESCRIPTION
});
expect(SIMPLE_SYRUP_SETTING_DESCRIPTION).toContain("WD14 tagger");
expect(SIMPLE_SYRUP_SETTING_DESCRIPTION).toContain("Ultralytics");
expect(app.ui.settings.settings[0]?.value).toBe(false);
expect(app.ui.settings.definitions[1]).toMatchObject({
id: QUANT_CACHE_SETTING_ID,
sortOrder: 321
});
expect(typeof app.ui.settings.definitions[1]?.type).toBe("function");
expect(app.ui.settings.definitions[2]).toMatchObject({
id: EXTERNAL_LLM_ENDPOINT_SETTING_ID,
sortOrder: 320
});
expect(typeof app.ui.settings.definitions[2]?.type).toBe("function");
expect(app.ui.settings.definitions[3]).toMatchObject({
id: EXTERNAL_LLM_API_KEY_SETTING_ID,
sortOrder: 319
});
expect(typeof app.ui.settings.definitions[3]?.type).toBe("function");
});
it("saves setting changes to the backend", async () => {
const app = createFakeComfyApp();
const refreshComboInNodes = vi.fn().mockResolvedValue(undefined);
app.refreshComboInNodes = refreshComboInNodes;
const saveSettings = vi
.fn<SimpleSyrupSettingsApi["saveSettings"]>()
.mockResolvedValue({
show_downloadable_models: true,
quant_cache_limit_gib: 20
});
const api: SimpleSyrupSettingsApi = {
getSettings: vi.fn().mockResolvedValue({
show_downloadable_models: false,
quant_cache_limit_gib: 20
}),
saveSettings,
getQuantCacheStatus: vi.fn().mockResolvedValue(defaultQuantCacheStatus()),
enforceQuantCacheLimit: vi.fn().mockResolvedValue(defaultQuantCacheStatus()),
clearQuantCache: vi.fn().mockResolvedValue(defaultQuantCacheStatus()),
getExternalLLMSettings: vi.fn().mockResolvedValue(defaultExternalLLMSettings()),
saveExternalLLMSettings: vi.fn().mockResolvedValue(defaultExternalLLMSettings()),
saveExternalLLMApiKey: vi.fn().mockResolvedValue(defaultExternalLLMSettings())
};
await registerSimpleSyrupSettings(app, api);
await app.ui.settings.definitions[0]?.onChange?.(true);
expect(saveSettings).toHaveBeenCalledWith({
show_downloadable_models: true,
quant_cache_limit_gib: 20
});
expect(app.ui.settings.settings[0]?.value).toBe(true);
expect(refreshComboInNodes).toHaveBeenCalledOnce();
});
it("falls back to the default and warns when backend load fails", async () => {
const app = createFakeComfyApp();
const logger = { warn: vi.fn() };
const api: SimpleSyrupSettingsApi = {
getSettings: vi.fn().mockRejectedValue(new Error("offline")),
saveSettings: vi.fn().mockResolvedValue({
show_downloadable_models: true,
quant_cache_limit_gib: 20
}),
getQuantCacheStatus: vi.fn().mockResolvedValue(defaultQuantCacheStatus()),
enforceQuantCacheLimit: vi.fn().mockResolvedValue(defaultQuantCacheStatus()),
clearQuantCache: vi.fn().mockResolvedValue(defaultQuantCacheStatus()),
getExternalLLMSettings: vi.fn().mockResolvedValue(defaultExternalLLMSettings()),
saveExternalLLMSettings: vi.fn().mockResolvedValue(defaultExternalLLMSettings()),
saveExternalLLMApiKey: vi.fn().mockResolvedValue(defaultExternalLLMSettings())
};
await registerSimpleSyrupSettings(app, api, logger);
expect(app.ui.settings.settings[0]?.value).toBe(true);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("Could not load SimpleSyrup settings"),
expect.any(Error)
);
});
it("warns and restores the previous value when backend save fails", async () => {
const app = createFakeComfyApp();
const logger = { warn: vi.fn() };
const saveSettings = vi
.fn<SimpleSyrupSettingsApi["saveSettings"]>()
.mockResolvedValueOnce({
show_downloadable_models: true,
quant_cache_limit_gib: 20
})
.mockRejectedValueOnce(new Error("rejected"));
const api: SimpleSyrupSettingsApi = {
getSettings: vi.fn().mockResolvedValue({
show_downloadable_models: false,
quant_cache_limit_gib: 20
}),
saveSettings,
getQuantCacheStatus: vi.fn().mockResolvedValue(defaultQuantCacheStatus()),
enforceQuantCacheLimit: vi.fn().mockResolvedValue(defaultQuantCacheStatus()),
clearQuantCache: vi.fn().mockResolvedValue(defaultQuantCacheStatus()),
getExternalLLMSettings: vi.fn().mockResolvedValue(defaultExternalLLMSettings()),
saveExternalLLMSettings: vi.fn().mockResolvedValue(defaultExternalLLMSettings()),
saveExternalLLMApiKey: vi.fn().mockResolvedValue(defaultExternalLLMSettings())
};
await registerSimpleSyrupSettings(app, api, logger);
await app.ui.settings.definitions[0]?.onChange?.(true);
await app.ui.settings.definitions[0]?.onChange?.(false);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("Could not save SimpleSyrup settings"),
expect.any(Error)
);
expect(app.ui.settings.settings[0]?.value).toBe(true);
});
it("keeps a saved setting when live model-choice refresh fails", async () => {
const app = createFakeComfyApp();
const logger = { warn: vi.fn() };
app.refreshComboInNodes = vi.fn().mockRejectedValue(new Error("offline"));
const api = fakeSettingsApi(false);
await registerSimpleSyrupSettings(app, api, logger);
await app.ui.settings.definitions[0]?.onChange?.(true);
expect(app.ui.settings.settings[0]?.value).toBe(true);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("Could not refresh Comfy loader model choices"),
expect.any(Error)
);
});
it("shows global quant cache usage and saves its GiB limit", async () => {
const app = createFakeComfyApp();
const api = fakeSettingsApi(true);
const saveSettings = vi
.fn<SimpleSyrupSettingsApi["saveSettings"]>()
.mockResolvedValue({
show_downloadable_models: true,
quant_cache_limit_gib: 30
});
api.saveSettings = saveSettings;
await registerSimpleSyrupSettings(app, api);
const control = renderSetting(getDefinition(app, 1));
const input = requiredInput(control, "input[type=number]");
const saveButton = requiredButton(control, "button");
expect(input.value).toBe("20");
expect(control.textContent).toContain("models/SyrupQuants");
input.value = "30";
saveButton.click();
await flushPromises();
expect(saveSettings).toHaveBeenCalledWith({
show_downloadable_models: true,
quant_cache_limit_gib: 30
});
expect(input.value).toBe("30");
});
it("clears inactive quant artifacts and refreshes cache status", async () => {
const app = createFakeComfyApp();
const api = fakeSettingsApi(true);
const clearQuantCache = vi.fn().mockResolvedValue({
...defaultQuantCacheStatus(),
removed_artifacts: 2,
removed_bytes: 1024
});
api.clearQuantCache = clearQuantCache;
await registerSimpleSyrupSettings(app, api);
const control = renderSetting(getDefinition(app, 1));
const buttons = control.querySelectorAll("button");
const clearButton = buttons[1];
if (!(clearButton instanceof HTMLButtonElement)) {
throw new Error("Expected quant cache clear button.");
}
clearButton.click();
await flushPromises();
expect(clearQuantCache).toHaveBeenCalledOnce();
expect(control.textContent).toContain("0.00 GiB used");
});
it("restores the previous quant limit when backend saving fails", async () => {
const app = createFakeComfyApp();
const logger = { warn: vi.fn() };
const api = fakeSettingsApi(true);
api.saveSettings = vi.fn().mockRejectedValue(new Error("rejected"));
await registerSimpleSyrupSettings(app, api, logger);
const control = renderSetting(getDefinition(app, 1));
const input = requiredInput(control, "input[type=number]");
const saveButton = requiredButton(control, "button");
input.value = "30";
saveButton.click();
await flushPromises();
expect(input.value).toBe("20");
expect(control.textContent).toContain("was not saved");
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("quant cache limit"),
expect.any(Error)
);
});
it("saves external LLM endpoint changes to the backend", async () => {
const app = createFakeComfyApp();
const refreshComboInNodes = vi.fn().mockResolvedValue(undefined);
app.refreshComboInNodes = refreshComboInNodes;
const saveExternalLLMSettings =
vi.fn<SimpleSyrupSettingsApi["saveExternalLLMSettings"]>().mockResolvedValue({
...defaultExternalLLMSettings(),
base_url: "https://provider.example/v1"
});
const api = fakeSettingsApi(true);
api.saveExternalLLMSettings = saveExternalLLMSettings;
await registerSimpleSyrupSettings(app, api);
const control = renderSetting(getDefinition(app, 2));
const input = requiredInput(control, "input");
const button = requiredButton(control, "button");
input.value = "https://provider.example/v1";
button.click();
await flushPromises();
expect(saveExternalLLMSettings).toHaveBeenCalledWith({
base_url: "https://provider.example/v1",
default_model: ""
});
expect(refreshComboInNodes).toHaveBeenCalledOnce();
expect(control.textContent).toContain("Endpoint saved.");
});
it("keeps partial endpoint text visible while the user is typing", async () => {
const app = createFakeComfyApp();
const saveExternalLLMSettings =
vi.fn<SimpleSyrupSettingsApi["saveExternalLLMSettings"]>();
const api = fakeSettingsApi(true);
api.saveExternalLLMSettings = saveExternalLLMSettings;
await registerSimpleSyrupSettings(app, api);
const control = renderSetting(getDefinition(app, 2));
const input = requiredInput(control, "input");
const button = requiredButton(control, "button");
input.value = "https://";
button.click();
await Promise.resolve();
expect(saveExternalLLMSettings).not.toHaveBeenCalled();
expect(input.value).toBe("https://");
});
it("saves API keys through an add dialog without showing the stored key", async () => {
const app = createFakeComfyApp();
const refreshComboInNodes = vi.fn().mockResolvedValue(undefined);
app.refreshComboInNodes = refreshComboInNodes;
const saveExternalLLMApiKey =
vi.fn<SimpleSyrupSettingsApi["saveExternalLLMApiKey"]>().mockResolvedValue({
...configuredExternalLLMSettings(),
has_api_key: true
});
const api = fakeSettingsApi(true);
api.getExternalLLMSettings = vi
.fn()
.mockResolvedValue(configuredExternalLLMSettings());
api.saveExternalLLMApiKey = saveExternalLLMApiKey;
await registerSimpleSyrupSettings(app, api);
const control = renderSetting(getDefinition(app, 3));
const addButton = requiredButton(control, "button");
expect(addButton.textContent).toBe("Add API Key");
addButton.click();
const dialogInput = requiredInput(document.body, ".simple-syrup-dialog input");
const submitButton = requiredButton(
document.body,
".simple-syrup-dialog button"
);
dialogInput.value = "secret";
submitButton.click();
await flushPromises();
expect(saveExternalLLMApiKey).toHaveBeenCalledWith({ api_key: "secret" });
expect(refreshComboInNodes).toHaveBeenCalledOnce();
expect(document.body.querySelector(".simple-syrup-dialog")).toBeNull();
expect(control.textContent).toContain("API key remembered.");
expect(control.textContent).not.toContain("secret");
});
it("keeps save success visible if model choice refresh fails", async () => {
const app = createFakeComfyApp();
const logger = { warn: vi.fn() };
app.refreshComboInNodes = vi.fn().mockRejectedValue(new Error("offline"));
const saveExternalLLMSettings =
vi.fn<SimpleSyrupSettingsApi["saveExternalLLMSettings"]>().mockResolvedValue({
...defaultExternalLLMSettings(),
base_url: "https://provider.example/v1"
});
const api = fakeSettingsApi(true);
api.saveExternalLLMSettings = saveExternalLLMSettings;
await registerSimpleSyrupSettings(app, api, logger);
const control = renderSetting(getDefinition(app, 2));
const input = requiredInput(control, "input");
const button = requiredButton(control, "button");
input.value = "https://provider.example/v1";
button.click();
await flushPromises();
expect(control.textContent).toContain("Endpoint saved.");
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("Could not refresh Comfy node definitions"),
expect.any(Error)
);
});
it("requires a saved endpoint before opening the API key dialog", async () => {
const app = createFakeComfyApp();
const api = fakeSettingsApi(true);
const saveExternalLLMApiKey = vi.fn<
SimpleSyrupSettingsApi["saveExternalLLMApiKey"]
>();
api.saveExternalLLMApiKey = saveExternalLLMApiKey;
await registerSimpleSyrupSettings(app, api);
const control = renderSetting(getDefinition(app, 3));
const addButton = requiredButton(control, "button");
addButton.click();
expect(document.body.querySelector(".simple-syrup-dialog")).toBeNull();
expect(control.textContent).toContain("Save endpoint first.");
expect(saveExternalLLMApiKey).not.toHaveBeenCalled();
});
it("shows backend API key save errors in the setting row", async () => {
const app = createFakeComfyApp();
const api = fakeSettingsApi(true);
api.getExternalLLMSettings = vi
.fn()
.mockResolvedValue(configuredExternalLLMSettings());
api.saveExternalLLMApiKey = vi
.fn<SimpleSyrupSettingsApi["saveExternalLLMApiKey"]>()
.mockRejectedValue(new Error("Configure an external LLM endpoint first."));
await registerSimpleSyrupSettings(app, api);
const control = renderSetting(getDefinition(app, 3));
const addButton = requiredButton(control, "button");
addButton.click();
const dialogInput = requiredInput(document.body, ".simple-syrup-dialog input");
const submitButton = requiredButton(
document.body,
".simple-syrup-dialog button"
);
dialogInput.value = "secret";
submitButton.click();
await Promise.resolve();
await Promise.resolve();
expect(control.textContent).toContain("Save endpoint first.");
expect(control.textContent).not.toContain("secret");
});
it("offers replacement when an API key is remembered", async () => {
const app = createFakeComfyApp();
const api = fakeSettingsApi(true);
api.getExternalLLMSettings = vi
.fn()
.mockResolvedValue({ ...defaultExternalLLMSettings(), has_api_key: true });
await registerSimpleSyrupSettings(app, api);
const control = renderSetting(getDefinition(app, 3));
const button = control.querySelector("button");
expect(button?.textContent).toBe("Replace API Key");
expect(control.textContent).toContain("API key remembered.");
});
});
function fakeSettingsApi(
showDownloadableModels: boolean
): SimpleSyrupSettingsApi {
return {
getSettings: vi.fn().mockResolvedValue({
show_downloadable_models: showDownloadableModels,
quant_cache_limit_gib: 20
}),
saveSettings: vi.fn().mockImplementation((settings) => Promise.resolve(settings)),
getQuantCacheStatus: vi.fn().mockResolvedValue(defaultQuantCacheStatus()),
enforceQuantCacheLimit: vi.fn().mockResolvedValue(defaultQuantCacheStatus()),
clearQuantCache: vi.fn().mockResolvedValue(defaultQuantCacheStatus()),
getExternalLLMSettings: vi.fn().mockResolvedValue(defaultExternalLLMSettings()),
saveExternalLLMSettings: vi
.fn()
.mockImplementation((settings) =>
Promise.resolve({ ...defaultExternalLLMSettings(), ...settings })
),
saveExternalLLMApiKey: vi.fn().mockResolvedValue(defaultExternalLLMSettings())
};
}
function defaultQuantCacheStatus() {
return {
path: "models/SyrupQuants",
usage_bytes: 0,
limit_bytes: 20 * 1024 ** 3,
artifact_count: 0,
active_artifact_count: 0
};
}
function defaultExternalLLMSettings() {
return {
base_url: "",
cached_models: [],
default_model: "",
has_api_key: false
};
}
function configuredExternalLLMSettings() {
return {
base_url: "https://provider.example/v1",
cached_models: [],
default_model: "",
has_api_key: false
};
}
function renderSetting(definition: {
type: "boolean" | "text" | (() => HTMLElement);
}): HTMLElement {
expect(typeof definition.type).toBe("function");
return (definition.type as () => HTMLElement)();
}
function getDefinition(app: ReturnType<typeof createFakeComfyApp>, index: number) {
const definition = app.ui.settings.definitions[index];
if (!definition) {
throw new Error(`Expected setting definition at index ${String(index)}.`);
}
return definition;
}
function requiredInput(parent: ParentNode, selector: string): HTMLInputElement {
const input = parent.querySelector(selector);
if (!(input instanceof HTMLInputElement)) {
throw new Error(`Expected input for selector ${selector}.`);
}
return input;
}
function requiredButton(parent: ParentNode, selector: string): HTMLButtonElement {
const button = parent.querySelector(selector);
if (!(button instanceof HTMLButtonElement)) {
throw new Error(`Expected button for selector ${selector}.`);
}
return button;
}
async function flushPromises(): Promise<void> {
for (let index = 0; index < 6; index += 1) {
await Promise.resolve();
}
}