Files
Will Miao 8f9e7e54ad feat(init): structured byte-weighted scan progress broadcasts
The initialization page computed its ETA by regex-parsing the English
details strings ("Found N", "Processing x/y") and the progress bar
advanced per file, so one 7 GB LoRA and one 100 KB embedding moved it
by the same amount.

The scanner progress callbacks now carry the collected file sizes end
to end: process_models/count_models broadcasts include structured
processed/total/bytes_done/bytes_total fields, and the bar percentage
is weighted by bytes (falling back to file counts when sizes are
unavailable). initialization.js consumes the structured fields
directly - no more string parsing - and computes the remaining-time
estimate per byte, keeping the file-count path as a fallback.
2026-10-25 09:24:30 +08:00

188 lines
5.9 KiB
JavaScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
vi.mock('../../../static/js/core.js', () => ({
appCore: {
initialize: vi.fn(() => Promise.resolve()),
},
}));
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
getSessionItem: vi.fn(() => null),
setSessionItem: vi.fn(),
}));
class MockWebSocket {
static instances = [];
static OPEN = 1;
constructor(url) {
this.url = url;
this.readyState = MockWebSocket.OPEN;
MockWebSocket.instances.push(this);
}
close() {
this.readyState = 3;
}
}
function setupDom({ serverError = null } = {}) {
document.body.innerHTML = `
<div class="initialization-container" id="initializationContainer">
<div class="loading-content"${serverError ? ' hidden' : ''}>
<div class="loading-status" id="progressStatus"></div>
<div class="init-progress-bar" id="initProgressBar"></div>
<span id="progressPercentage">0%</span>
<span id="remainingTime"></span>
</div>
<div class="init-error" id="initError"${serverError ? '' : ' hidden'}>
<p id="initErrorText">${serverError ?? ''}</p>
<button type="button" id="initRetryBtn"></button>
</div>
<div class="tip-item"></div>
<span class="tip-dot"></span>
</div>
`;
}
function jsonResponse(payload) {
return Promise.resolve({ json: () => Promise.resolve(payload) });
}
describe('InitializationManager', () => {
let initManager;
beforeEach(async () => {
vi.useFakeTimers();
MockWebSocket.instances = [];
vi.stubGlobal('WebSocket', MockWebSocket);
({ initManager } = await import('../../../static/js/components/initialization.js'));
initManager.websocket = null;
initManager.pollingActive = false;
initManager.progress = 0;
initManager.pageType = null;
initManager.tipInterval = null;
});
afterEach(() => {
initManager.cleanup();
vi.clearAllTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('stays on a server-rendered error instead of polling into a reload loop', () => {
setupDom({ serverError: 'corrupt snapshot' });
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
initManager.initialize();
expect(fetchSpy).not.toHaveBeenCalled();
expect(MockWebSocket.instances).toHaveLength(0);
expect(initManager.tipInterval).toBeNull();
});
it('scopes the polling fallback to the current page type', () => {
setupDom();
initManager.pageType = 'loras';
const fetchSpy = vi.fn(() => jsonResponse({ status: 'initializing' }));
vi.stubGlobal('fetch', fetchSpy);
initManager.pollProgress();
expect(fetchSpy).toHaveBeenCalledWith('/api/lm/init-status?page=loras');
});
it('stops polling when the server reports an error', async () => {
setupDom();
initManager.pageType = 'checkpoints';
const fetchSpy = vi.fn(() =>
jsonResponse({ status: 'error', details: 'Checkpoint cache initialization failed: boom' })
);
vi.stubGlobal('fetch', fetchSpy);
initManager.pollProgress();
await vi.advanceTimersByTimeAsync(0);
// The error state is shown...
expect(document.getElementById('initError').hidden).toBe(false);
expect(document.getElementById('initErrorText').textContent)
.toContain('boom');
// ...and no further poll is scheduled.
await vi.advanceTimersByTimeAsync(10000);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('keeps polling while initialization is in progress', async () => {
setupDom();
initManager.pageType = 'loras';
const fetchSpy = vi.fn(() => jsonResponse({ status: 'initializing' }));
vi.stubGlobal('fetch', fetchSpy);
initManager.pollProgress();
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(2000);
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it('tracks structured progress fields without parsing the details string', () => {
setupDom();
initManager.handleProgressUpdate({
stage: 'count_models',
progress: 1,
details: 'deliberately unparseable',
total: 10,
bytes_total: 1000,
});
expect(initManager.totalFilesCount).toBe(10);
expect(initManager.totalBytes).toBe(1000);
initManager.handleProgressUpdate({
stage: 'process_models',
progress: 25,
details: 'deliberately unparseable',
processed: 2,
total: 10,
bytes_done: 250,
bytes_total: 1000,
});
expect(initManager.processedFilesCount).toBe(2);
expect(initManager.bytesDone).toBe(250);
});
it('estimates remaining time from byte progress when sizes are known', async () => {
setupDom();
initManager.handleProgressUpdate({
stage: 'process_models',
progress: 10,
processed: 1,
total: 4,
bytes_done: 100,
bytes_total: 1000,
});
await vi.advanceTimersByTimeAsync(1000);
initManager.handleProgressUpdate({
stage: 'process_models',
progress: 20,
processed: 2,
total: 4,
bytes_done: 200,
bytes_total: 1000,
});
// 200 bytes in ~1s -> the remaining 800 bytes need ~4s more.
expect(initManager.averageTimePerByte).not.toBeNull();
expect(document.getElementById('remainingTime').textContent)
.toBe('Less than a minute remaining');
});
});