mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-05 17:35:33 -03:00
End-to-end verification against the live site found the price capture broken for a whole class of users: the civitai page hosts are not interchangeable, and the user's civitai_host preference was silently fatal. With civitai_host=civitai.red the update DB held zero prices even with tracking enabled. - civitai.red refuses non-browser HTTP clients outright (Cloudflare challenge, 403 for any User-Agent, aiohttp and httpx alike), while civitai.com and civitai.green answer normally for anonymously visible models and 404 for mature ones. An earlier manual check with curl passed on TLS fingerprint luck, which is why this was missed. - get_model_prices now tries the configured host first, then the others, and takes the first parseable payload. The host that worked is remembered, and a host that refuses outright is parked for 15 minutes so a library full of mature models does not pay three requests each; a 404 is model-specific and does not park the host. Links keep using the configured host, which is where the user's own browser has clearance. - Mature models still have no price source anywhere, so that is now stated instead of silent: price_check_attempted_at separates "tried and unreadable" from "never looked", gated versions show a muted "Price unavailable" badge, and the alerts panel reports unavailableCount. - Failures are logged at warning level, once per host per TTL, with the per-host reason, instead of only at debug level. - The recorded alternatives (internal tRPC with the user's API key, or an extension-assisted fetch from the user's browser) and the strengthened upstream ask for a public price field are documented in the plan.
379 lines
11 KiB
JavaScript
379 lines
11 KiB
JavaScript
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
|
|
|
const {
|
|
MODEL_VERSIONS_MODULE,
|
|
API_FACTORY_MODULE,
|
|
DOWNLOAD_MANAGER_MODULE,
|
|
UI_HELPERS_MODULE,
|
|
STATE_MODULE,
|
|
I18N_HELPERS_MODULE,
|
|
UTILS_MODULE,
|
|
} = vi.hoisted(() => ({
|
|
MODEL_VERSIONS_MODULE: new URL('../../../static/js/components/shared/ModelVersionsTab.js', import.meta.url).pathname,
|
|
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
|
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
|
|
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
|
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
|
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
|
UTILS_MODULE: new URL('../../../static/js/components/shared/utils.js', import.meta.url).pathname,
|
|
}));
|
|
|
|
const downloadVersionWithDefaults = vi.fn();
|
|
const openFileSelectionForVersion = vi.fn();
|
|
|
|
vi.mock(DOWNLOAD_MANAGER_MODULE, () => ({
|
|
downloadManager: {
|
|
downloadVersionWithDefaults,
|
|
openFileSelectionForVersion,
|
|
},
|
|
}));
|
|
|
|
vi.mock(UI_HELPERS_MODULE, () => ({
|
|
showToast: vi.fn(),
|
|
openCivitaiUrl: vi.fn(),
|
|
}));
|
|
|
|
const stateMock = {
|
|
global: {
|
|
settings: {
|
|
autoplay_on_hover: false,
|
|
version_grouping: 'any',
|
|
},
|
|
},
|
|
};
|
|
vi.mock(STATE_MODULE, () => ({
|
|
state: stateMock,
|
|
}));
|
|
|
|
vi.mock(I18N_HELPERS_MODULE, () => ({
|
|
translate: vi.fn((_, __, fallback) => fallback ?? ''),
|
|
}));
|
|
|
|
vi.mock(UTILS_MODULE, () => ({
|
|
formatFileSize: vi.fn(() => '1 MB'),
|
|
}));
|
|
|
|
vi.mock(API_FACTORY_MODULE, () => ({
|
|
getModelApiClient: vi.fn(),
|
|
}));
|
|
|
|
function buildRecord(versions) {
|
|
return {
|
|
success: true,
|
|
record: {
|
|
shouldIgnore: false,
|
|
inLibraryVersionIds: versions.filter(v => v.isInLibrary).map(v => v.versionId),
|
|
versions,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function renderVersions(record) {
|
|
const { initVersionsTab } = await import(MODEL_VERSIONS_MODULE);
|
|
const controller = initVersionsTab({
|
|
modalId: 'model-versions-modal',
|
|
modelType: 'loras',
|
|
modelId: 123,
|
|
currentVersionId: null,
|
|
});
|
|
await controller.load();
|
|
}
|
|
|
|
function downloadButtonFor(versionId) {
|
|
return document.querySelector(
|
|
`.model-version-row[data-version-id="${versionId}"] [data-version-action="download"]`
|
|
);
|
|
}
|
|
|
|
function filesBadgeFor(versionId) {
|
|
return document.querySelector(
|
|
`.model-version-row[data-version-id="${versionId}"] [data-version-files]`
|
|
);
|
|
}
|
|
|
|
describe('ModelVersionsTab download button visibility', () => {
|
|
let getModelApiClient;
|
|
let fetchModelUpdateVersions;
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
downloadVersionWithDefaults.mockReset();
|
|
downloadVersionWithDefaults.mockResolvedValue(true);
|
|
openFileSelectionForVersion.mockReset();
|
|
openFileSelectionForVersion.mockResolvedValue(undefined);
|
|
document.body.innerHTML = `
|
|
<div id="model-versions-modal">
|
|
<div id="versions-tab">
|
|
<div class="model-versions-tab"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
({ getModelApiClient } = await import(API_FACTORY_MODULE));
|
|
fetchModelUpdateVersions = vi.fn();
|
|
getModelApiClient.mockReturnValue({
|
|
fetchModelUpdateVersions,
|
|
fetchModelRoots: vi.fn(),
|
|
setModelUpdateIgnore: vi.fn(),
|
|
setVersionUpdateIgnore: vi.fn(),
|
|
deleteModel: vi.fn(),
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = '';
|
|
});
|
|
|
|
it('hides the download button for a single-file in-library version', async () => {
|
|
fetchModelUpdateVersions.mockResolvedValue(buildRecord([
|
|
{
|
|
versionId: 10,
|
|
name: 'v1.0',
|
|
baseModel: 'Illustrious',
|
|
isInLibrary: true,
|
|
shouldIgnore: false,
|
|
filePath: '/models/loras/file.safetensors',
|
|
fileCount: 1,
|
|
},
|
|
]));
|
|
|
|
await renderVersions();
|
|
|
|
expect(downloadButtonFor(10)).toBeFalsy();
|
|
expect(filesBadgeFor(10)).toBeFalsy();
|
|
// The delete affordance must remain for in-library versions.
|
|
expect(document.querySelector(
|
|
'.model-version-row[data-version-id="10"] [data-version-action="delete"]'
|
|
)).toBeTruthy();
|
|
});
|
|
|
|
it('shows the files badge instead of a download button for a multi-file in-library version', async () => {
|
|
fetchModelUpdateVersions.mockResolvedValue(buildRecord([
|
|
{
|
|
versionId: 10,
|
|
name: 'v1.0',
|
|
baseModel: 'Illustrious',
|
|
isInLibrary: true,
|
|
shouldIgnore: false,
|
|
filePath: '/models/loras/file.safetensors',
|
|
fileCount: 3,
|
|
},
|
|
]));
|
|
|
|
await renderVersions();
|
|
|
|
expect(downloadButtonFor(10)).toBeFalsy();
|
|
const badge = filesBadgeFor(10);
|
|
expect(badge).toBeTruthy();
|
|
expect(badge.textContent).toContain('3 files');
|
|
expect(badge.getAttribute('title')).toBe('Choose which files to download');
|
|
|
|
badge.click();
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
|
|
expect(openFileSelectionForVersion).toHaveBeenCalledWith('loras', 123, 10);
|
|
});
|
|
|
|
it('hides the download button when fileCount is unknown for an in-library version', async () => {
|
|
fetchModelUpdateVersions.mockResolvedValue(buildRecord([
|
|
{
|
|
versionId: 10,
|
|
name: 'v1.0',
|
|
baseModel: 'Illustrious',
|
|
isInLibrary: true,
|
|
shouldIgnore: false,
|
|
filePath: '/models/loras/file.safetensors',
|
|
},
|
|
]));
|
|
|
|
await renderVersions();
|
|
|
|
expect(downloadButtonFor(10)).toBeFalsy();
|
|
expect(filesBadgeFor(10)).toBeFalsy();
|
|
});
|
|
|
|
it('shows the download button for versions not in the library', async () => {
|
|
fetchModelUpdateVersions.mockResolvedValue(buildRecord([
|
|
{
|
|
versionId: 11,
|
|
name: 'v1.1',
|
|
baseModel: 'Illustrious',
|
|
isInLibrary: false,
|
|
shouldIgnore: false,
|
|
fileCount: 1,
|
|
},
|
|
{
|
|
versionId: 12,
|
|
name: 'v1.2',
|
|
baseModel: 'Illustrious',
|
|
isInLibrary: false,
|
|
shouldIgnore: false,
|
|
},
|
|
]));
|
|
|
|
await renderVersions();
|
|
|
|
expect(downloadButtonFor(11)).toBeTruthy();
|
|
expect(downloadButtonFor(12)).toBeTruthy();
|
|
// Single-file and unknown-count versions get no files badge.
|
|
expect(filesBadgeFor(11)).toBeFalsy();
|
|
expect(filesBadgeFor(12)).toBeFalsy();
|
|
});
|
|
|
|
it('keeps the default-file download button and offers the files badge for a multi-file version not in the library', async () => {
|
|
fetchModelUpdateVersions.mockResolvedValue(buildRecord([
|
|
{
|
|
versionId: 11,
|
|
name: 'v1.1',
|
|
baseModel: 'Illustrious',
|
|
isInLibrary: false,
|
|
shouldIgnore: false,
|
|
fileCount: 2,
|
|
},
|
|
]));
|
|
|
|
await renderVersions();
|
|
|
|
// The Download button stays bound to the default (primary) file.
|
|
const button = downloadButtonFor(11);
|
|
expect(button).toBeTruthy();
|
|
expect(button.getAttribute('title')).toBe('Download this version');
|
|
|
|
button.click();
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
|
|
expect(downloadVersionWithDefaults).toHaveBeenCalledWith(
|
|
'loras', 123, 11,
|
|
expect.objectContaining({ versionName: 'v1.1' })
|
|
);
|
|
expect(openFileSelectionForVersion).not.toHaveBeenCalled();
|
|
|
|
// The badge is the advanced entry into the file-selection step.
|
|
const badge = filesBadgeFor(11);
|
|
expect(badge).toBeTruthy();
|
|
expect(badge.textContent).toContain('2 files');
|
|
|
|
badge.click();
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
|
|
expect(openFileSelectionForVersion).toHaveBeenCalledWith('loras', 123, 11);
|
|
});
|
|
|
|
it('keeps the direct default download for a single-file version not in the library', async () => {
|
|
fetchModelUpdateVersions.mockResolvedValue(buildRecord([
|
|
{
|
|
versionId: 11,
|
|
name: 'v1.1',
|
|
baseModel: 'Illustrious',
|
|
isInLibrary: false,
|
|
shouldIgnore: false,
|
|
fileCount: 1,
|
|
},
|
|
]));
|
|
|
|
await renderVersions();
|
|
|
|
expect(filesBadgeFor(11)).toBeFalsy();
|
|
downloadButtonFor(11).click();
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
|
|
expect(downloadVersionWithDefaults).toHaveBeenCalledWith(
|
|
'loras', 123, 11,
|
|
expect.objectContaining({ versionName: 'v1.1' })
|
|
);
|
|
expect(openFileSelectionForVersion).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('ModelVersionsTab price badges', () => {
|
|
let getModelApiClient;
|
|
let fetchModelUpdateVersions;
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
document.body.innerHTML = `
|
|
<div id="model-versions-modal">
|
|
<div id="versions-tab">
|
|
<div class="model-versions-tab"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
({ getModelApiClient } = await import(API_FACTORY_MODULE));
|
|
fetchModelUpdateVersions = vi.fn();
|
|
getModelApiClient.mockReturnValue({
|
|
fetchModelUpdateVersions,
|
|
fetchModelRoots: vi.fn(),
|
|
setModelUpdateIgnore: vi.fn(),
|
|
setVersionUpdateIgnore: vi.fn(),
|
|
deleteModel: vi.fn(),
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = '';
|
|
});
|
|
|
|
function rowFor(versionId) {
|
|
return document.querySelector(`.model-version-row[data-version-id="${versionId}"]`);
|
|
}
|
|
|
|
it('shows the price for a gated version whose price was captured', async () => {
|
|
fetchModelUpdateVersions.mockResolvedValue(buildRecord([
|
|
{
|
|
versionId: 20,
|
|
name: 'Alpha',
|
|
isInLibrary: false,
|
|
shouldIgnore: false,
|
|
isPaid: true,
|
|
paidAccess: { permanent: true, endsAt: null },
|
|
priceBuzz: 5000,
|
|
listPriceBuzz: 5000,
|
|
priceCheckedAt: 1791039694.5,
|
|
priceAttemptedAt: 1791039694.5,
|
|
},
|
|
]));
|
|
|
|
await renderVersions();
|
|
|
|
expect(rowFor(20).textContent).toContain('5,000 Buzz');
|
|
});
|
|
|
|
it('marks a gated version with no readable price as unavailable', async () => {
|
|
// The mature-model case: we looked, no host would serve the page.
|
|
fetchModelUpdateVersions.mockResolvedValue(buildRecord([
|
|
{
|
|
versionId: 21,
|
|
name: 'Beta',
|
|
isInLibrary: false,
|
|
shouldIgnore: false,
|
|
isPaid: true,
|
|
paidAccess: { permanent: true, endsAt: null },
|
|
priceBuzz: null,
|
|
priceAttemptedAt: 1791039694.5,
|
|
},
|
|
]));
|
|
|
|
await renderVersions();
|
|
|
|
expect(rowFor(21).textContent).toContain('Price unavailable');
|
|
});
|
|
|
|
it('stays quiet when the price was never looked up', async () => {
|
|
fetchModelUpdateVersions.mockResolvedValue(buildRecord([
|
|
{
|
|
versionId: 22,
|
|
name: 'Gamma',
|
|
isInLibrary: false,
|
|
shouldIgnore: false,
|
|
isPaid: true,
|
|
paidAccess: { permanent: true, endsAt: null },
|
|
priceBuzz: null,
|
|
priceAttemptedAt: null,
|
|
},
|
|
]));
|
|
|
|
await renderVersions();
|
|
|
|
expect(rowFor(22).textContent).not.toContain('Price unavailable');
|
|
});
|
|
});
|