7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
836 lines
40 KiB
JavaScript
836 lines
40 KiB
JavaScript
/**
|
|
* Tests for collections_manager.js — the Collections page.
|
|
*
|
|
* Covers the index-status surface and reindex action added in the
|
|
* #3939/#4627 follow-up, plus the background-sweep toggle persistence:
|
|
* - indexStatusMarkup(): "<indexed> of <total> indexed" string + the
|
|
* "<pending> pending indexing" badge, with numeric coercion / clamping.
|
|
* - triggerReindex(): POSTs to the index/start endpoint, polls status to a
|
|
* terminal state, keeps the button disabled in-flight, refreshes counts,
|
|
* and (critically) the card is an <a> so the click must NOT navigate.
|
|
* - loadBackgroundSweepSetting()/saveBackgroundSweepSetting(): read/write the
|
|
* document_scheduler.sweep_library_collections setting.
|
|
*/
|
|
|
|
// --- Global stubs the module reaches for at call time ----------------------
|
|
window.URLS = {
|
|
LIBRARY_API: {
|
|
COLLECTIONS: '/library/api/collections',
|
|
COLLECTION_INDEX_START: '/library/api/collections/{id}/index/start',
|
|
COLLECTION_INDEX_STATUS: '/library/api/collections/{id}/index/status',
|
|
},
|
|
};
|
|
// escapeHtml is captured at module load via `window.escapeHtml || fallback`.
|
|
window.escapeHtml = (s) => String(s);
|
|
window.api = { getCsrfToken: () => 'test-csrf' };
|
|
globalThis.alert = () => {};
|
|
|
|
// safeFetchWithAuth is the global the module calls (auth-aware fetch wrapper); default to a benign stub so the
|
|
// import-time DOMContentLoaded handler (if it fires) doesn't explode.
|
|
globalThis.safeFetchWithAuth = vi.fn(() =>
|
|
Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, collections: [] }) })
|
|
);
|
|
|
|
let indexStatusMarkup;
|
|
let triggerReindex;
|
|
let bindReindexButtons;
|
|
let collectionApiUrl;
|
|
let loadBackgroundSweepSetting;
|
|
let saveBackgroundSweepSetting;
|
|
|
|
beforeAll(async () => {
|
|
await import('@js/collections_manager.js');
|
|
indexStatusMarkup = window.indexStatusMarkup;
|
|
triggerReindex = window.triggerReindex;
|
|
bindReindexButtons = window.bindReindexButtons;
|
|
collectionApiUrl = window.collectionApiUrl;
|
|
loadBackgroundSweepSetting = window.loadBackgroundSweepSetting;
|
|
saveBackgroundSweepSetting = window.saveBackgroundSweepSetting;
|
|
});
|
|
|
|
beforeEach(() => {
|
|
globalThis.safeFetchWithAuth.mockReset();
|
|
});
|
|
|
|
describe('indexStatusMarkup', () => {
|
|
it('reports "<indexed> of <total> indexed" and a pending badge when work remains', () => {
|
|
const html = indexStatusMarkup({ document_count: 5, indexed_document_count: 2 });
|
|
expect(html).toContain('2 of 5 indexed');
|
|
expect(html).toContain('3 pending indexing');
|
|
expect(html).toContain('ldr-pending-index-badge');
|
|
});
|
|
|
|
it('omits the pending badge when everything is indexed', () => {
|
|
const html = indexStatusMarkup({ document_count: 4, indexed_document_count: 4 });
|
|
expect(html).toContain('4 of 4 indexed');
|
|
expect(html).not.toContain('pending indexing');
|
|
// Fully-indexed gets the success styling, not the warning badge.
|
|
expect(html).toContain('fa-check-circle');
|
|
});
|
|
|
|
it('shows "No documents" for an empty collection (no divide-by-zero / no badge)', () => {
|
|
const html = indexStatusMarkup({ document_count: 0, indexed_document_count: 0 });
|
|
expect(html).toContain('No documents');
|
|
expect(html).not.toContain('pending indexing');
|
|
});
|
|
|
|
it('clamps indexed to total and coerces non-numeric counts to integers', () => {
|
|
// indexed > total (stale payload) must not produce a negative pending.
|
|
const html = indexStatusMarkup({ document_count: 3, indexed_document_count: 9 });
|
|
expect(html).toContain('3 of 3 indexed');
|
|
expect(html).not.toContain('pending indexing');
|
|
|
|
// Garbage values coerce to 0, not NaN.
|
|
const html2 = indexStatusMarkup({ document_count: 'x', indexed_document_count: 'y' });
|
|
expect(html2).toContain('No documents');
|
|
expect(html2).not.toContain('NaN');
|
|
});
|
|
});
|
|
|
|
describe('collectionApiUrl', () => {
|
|
it('substitutes {id} with an encoded collection id', () => {
|
|
expect(collectionApiUrl('/a/{id}/b', 'c d')).toBe('/a/c%20d/b');
|
|
});
|
|
});
|
|
|
|
describe('triggerReindex', () => {
|
|
function makeButton(id) {
|
|
// Build via the DOM API (not innerHTML interpolation) so the
|
|
// no-unsanitized lint rule stays clean. Mirrors renderCollections():
|
|
// a wrapper <div> holds the clickable card <a> plus a SIBLING actions
|
|
// row that contains the Reindex button (never nested in the anchor).
|
|
document.body.innerHTML = '';
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'ldr-collection-card-wrapper';
|
|
wrapper.setAttribute('data-id', id);
|
|
|
|
const link = document.createElement('a');
|
|
link.href = `/library/collections/${encodeURIComponent(id)}`;
|
|
link.className = 'ldr-collection-card';
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = 'ldr-collection-card-actions';
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'ldr-reindex-btn';
|
|
btn.setAttribute('data-reindex-id', id);
|
|
const icon = document.createElement('i');
|
|
icon.className = 'fas fa-sync-alt';
|
|
const label = document.createElement('span');
|
|
label.className = 'ldr-reindex-label';
|
|
label.textContent = 'Reindex';
|
|
btn.append(icon, label);
|
|
actions.appendChild(btn);
|
|
|
|
wrapper.append(link, actions);
|
|
document.body.appendChild(wrapper);
|
|
return btn;
|
|
}
|
|
|
|
// triggerReindex() ends with loadCollections(), which writes to
|
|
// #collections-container / #no-collections-message. The bounded-poll tests
|
|
// below assert "no error alert", so those elements must exist or
|
|
// loadCollections() throws and spuriously fires showError('Failed to load
|
|
// collections'). Add them and re-parent the card wrapper into the
|
|
// container (so the loadCollections success path is exercised, not an
|
|
// empty-DOM crash).
|
|
function withCollectionsContainer(btn) {
|
|
const wrapper = btn.closest('.ldr-collection-card-wrapper');
|
|
const container = document.createElement('div');
|
|
container.id = 'collections-container';
|
|
container.appendChild(wrapper);
|
|
const noMsg = document.createElement('div');
|
|
noMsg.id = 'no-collections-message';
|
|
document.body.append(container, noMsg);
|
|
}
|
|
|
|
it('POSTs to the start endpoint, polls to completion, and refreshes counts', async () => {
|
|
const btn = makeButton('coll-1');
|
|
const calls = [];
|
|
globalThis.safeFetchWithAuth.mockImplementation((url, opts) => {
|
|
calls.push({ url, method: opts && opts.method });
|
|
if (url.includes('/index/start')) {
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, task_id: 't1' }) });
|
|
}
|
|
if (url.includes('/index/status')) {
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ status: 'completed' }) });
|
|
}
|
|
// loadCollections() refresh
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, collections: [] }) });
|
|
});
|
|
|
|
await triggerReindex(btn);
|
|
|
|
const start = calls.find(c => c.url.includes('/index/start'));
|
|
expect(start).toBeTruthy();
|
|
expect(start.method).toBe('POST');
|
|
expect(start.url).toBe('/library/api/collections/coll-1/index/start');
|
|
// Polled status at least once.
|
|
expect(calls.some(c => c.url.includes('/index/status'))).toBe(true);
|
|
// Refreshed the collection list afterwards.
|
|
expect(calls.some(c => c.url === '/library/api/collections')).toBe(true);
|
|
// Button re-enabled after completion.
|
|
expect(btn.disabled).toBe(false);
|
|
});
|
|
|
|
it('keeps the button disabled while indexing is in flight', async () => {
|
|
const btn = makeButton('coll-2');
|
|
let resolveStatus;
|
|
globalThis.safeFetchWithAuth.mockImplementation((url) => {
|
|
if (url.includes('/index/start')) {
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true }) });
|
|
}
|
|
if (url.includes('/index/status')) {
|
|
// First status call hangs until we resolve it — simulates in-flight.
|
|
return new Promise((res) => { resolveStatus = () => res({ ok: true, status: 200, json: () => Promise.resolve({ status: 'completed' }) }); });
|
|
}
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, collections: [] }) });
|
|
});
|
|
|
|
const promise = triggerReindex(btn);
|
|
// Let the start POST + first status fetch be issued.
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(btn.disabled).toBe(true);
|
|
|
|
resolveStatus();
|
|
await promise;
|
|
expect(btn.disabled).toBe(false);
|
|
});
|
|
|
|
it('does not navigate when the Reindex button is clicked (button is a sibling of the card <a>)', async () => {
|
|
const btn = makeButton('coll-3');
|
|
const container = document.createElement('div');
|
|
container.id = 'collections-container';
|
|
// Move the whole card wrapper into a real container so delegation matches.
|
|
container.appendChild(btn.closest('.ldr-collection-card-wrapper'));
|
|
document.body.appendChild(container);
|
|
|
|
globalThis.safeFetchWithAuth.mockImplementation((url) => {
|
|
if (url.includes('/index/start')) {
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true }) });
|
|
}
|
|
if (url.includes('/index/status')) {
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ status: 'idle' }) });
|
|
}
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, collections: [] }) });
|
|
});
|
|
|
|
bindReindexButtons(container);
|
|
|
|
const event = new window.MouseEvent('click', { bubbles: true, cancelable: true });
|
|
btn.dispatchEvent(event);
|
|
|
|
// The delegated handler must have prevented default navigation.
|
|
expect(event.defaultPrevented).toBe(true);
|
|
});
|
|
|
|
it('surfaces an error to the user when indexing fails during polling', async () => {
|
|
const btn = makeButton('coll-4');
|
|
// showError() routes through alert(); spy on it.
|
|
const alertSpy = vi.spyOn(globalThis, 'alert').mockImplementation(() => {});
|
|
globalThis.safeFetchWithAuth.mockImplementation((url) => {
|
|
if (url.includes('/index/start')) {
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true }) });
|
|
}
|
|
if (url.includes('/index/status')) {
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ status: 'failed', error_message: 'embedding model missing' }) });
|
|
}
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, collections: [] }) });
|
|
});
|
|
|
|
await triggerReindex(btn);
|
|
|
|
// The terminal failed status must reach the user, not just the log.
|
|
expect(alertSpy).toHaveBeenCalled();
|
|
const messages = alertSpy.mock.calls.map(c => String(c[0])).join(' ');
|
|
expect(messages).toContain('embedding model missing');
|
|
// Button re-enabled even though it failed.
|
|
expect(btn.disabled).toBe(false);
|
|
alertSpy.mockRestore();
|
|
});
|
|
|
|
// --- Start-endpoint failure handling -----------------------------------
|
|
// The /index/start POST can come back non-OK. The handler distinguishes a
|
|
// generic start failure (any non-409) — surface the error, skip polling,
|
|
// re-enable the button — from a 409 "already running" (fall THROUGH and
|
|
// poll the in-flight task to completion). The mocks above always return a
|
|
// 200/success start, so neither branch is exercised by them.
|
|
|
|
it('on a generic start failure: surfaces the error, does NOT poll, and re-enables the button', async () => {
|
|
const btn = makeButton('coll-start-fail');
|
|
withCollectionsContainer(btn);
|
|
const alertSpy = vi.spyOn(globalThis, 'alert').mockImplementation(() => {});
|
|
const logSpy = vi.spyOn(globalThis.SafeLogger, 'error');
|
|
const calls = [];
|
|
globalThis.safeFetchWithAuth.mockImplementation((url) => {
|
|
calls.push({ url });
|
|
if (url.includes('/index/start')) {
|
|
// Non-OK, non-409 start: e.g. the worker is busy / a 500.
|
|
return Promise.resolve({ ok: false, status: 500, json: () => Promise.resolve({ success: false, error: 'busy' }) });
|
|
}
|
|
if (url.includes('/index/status')) {
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ status: 'completed' }) });
|
|
}
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, collections: [] }) });
|
|
});
|
|
|
|
await triggerReindex(btn);
|
|
|
|
// The start error reaches the user (showError → alert) with the message.
|
|
expect(alertSpy).toHaveBeenCalled();
|
|
const messages = alertSpy.mock.calls.map(c => String(c[0])).join(' ');
|
|
expect(messages).toContain('busy');
|
|
expect(logSpy).toHaveBeenCalled();
|
|
// The early return skips polling entirely — the status endpoint is never hit.
|
|
expect(calls.some(c => c.url.includes('/index/status'))).toBe(false);
|
|
// Button re-enabled and label restored so the user can retry.
|
|
expect(btn.disabled).toBe(false);
|
|
expect(btn.querySelector('.ldr-reindex-label').textContent).toBe('Reindex');
|
|
logSpy.mockRestore();
|
|
alertSpy.mockRestore();
|
|
});
|
|
|
|
it('on a 409 "already running" start: does NOT early-return — falls through and polls to completion', async () => {
|
|
const btn = makeButton('coll-start-409');
|
|
withCollectionsContainer(btn);
|
|
const alertSpy = vi.spyOn(globalThis, 'alert').mockImplementation(() => {});
|
|
const calls = [];
|
|
globalThis.safeFetchWithAuth.mockImplementation((url) => {
|
|
calls.push({ url });
|
|
if (url.includes('/index/start')) {
|
|
// 409 = indexing is already in flight for this collection.
|
|
return Promise.resolve({ ok: false, status: 409, json: () => Promise.resolve({ success: false, error: 'already running' }) });
|
|
}
|
|
if (url.includes('/index/status')) {
|
|
// Drive a terminal status so the fall-through poll resolves.
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ status: 'completed' }) });
|
|
}
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, collections: [] }) });
|
|
});
|
|
|
|
await triggerReindex(btn);
|
|
|
|
// 409 must fall through to polling — the status endpoint IS hit, unlike
|
|
// the generic-failure case above which returns before polling.
|
|
expect(calls.some(c => c.url.includes('/index/status'))).toBe(true);
|
|
// The 409 itself is not surfaced as an error; completion is clean.
|
|
expect(alertSpy).not.toHaveBeenCalled();
|
|
// Button re-enabled once the in-flight task is tracked to completion.
|
|
expect(btn.disabled).toBe(false);
|
|
alertSpy.mockRestore();
|
|
});
|
|
|
|
// --- Bounded-poll loop coverage ----------------------------------------
|
|
// The status mocks above all resolve TERMINAL on the FIRST poll, so the
|
|
// loop's bounding logic (the 2s setTimeout re-poll, the consecutive-error
|
|
// counter + its reset-on-success, the POLL_MAX_ERRORS bail, the
|
|
// POLL_MAX_ATTEMPTS timeout cap, and the !response.ok HTTP-error guard) is
|
|
// never executed by them. These tests drive the loop across multiple polls
|
|
// using fake timers so mutations to that logic fail.
|
|
|
|
it('polls across the 2s interval through several "processing" responses before completing', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const btn = makeButton('coll-poll-1');
|
|
withCollectionsContainer(btn);
|
|
let statusCalls = 0;
|
|
globalThis.safeFetchWithAuth.mockImplementation((url) => {
|
|
if (url.includes('/index/start')) {
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true }) });
|
|
}
|
|
if (url.includes('/index/status')) {
|
|
statusCalls += 1;
|
|
// Stay "processing" for the first 3 polls, then complete.
|
|
const status = statusCalls >= 4 ? 'completed' : 'processing';
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ status }) });
|
|
}
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, collections: [] }) });
|
|
});
|
|
|
|
const promise = triggerReindex(btn);
|
|
// Each non-terminal poll re-arms a 2s setTimeout; advance enough to
|
|
// walk through all of them.
|
|
await vi.advanceTimersByTimeAsync(2000 * 5);
|
|
await promise;
|
|
|
|
// Must have polled more than once (the loop actually iterated).
|
|
expect(statusCalls).toBeGreaterThanOrEqual(4);
|
|
expect(btn.disabled).toBe(false);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('retries through consecutive fetch errors (error counter resets on success) and completes', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const btn = makeButton('coll-poll-errreset');
|
|
withCollectionsContainer(btn);
|
|
const alertSpy = vi.spyOn(globalThis, 'alert').mockImplementation(() => {});
|
|
let statusCalls = 0;
|
|
globalThis.safeFetchWithAuth.mockImplementation((url) => {
|
|
if (url.includes('/index/start')) {
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true }) });
|
|
}
|
|
if (url.includes('/index/status')) {
|
|
statusCalls += 1;
|
|
// Pattern: 4 errors, success(processing), 4 errors,
|
|
// success(completed). 8 transient errors total, but never 5
|
|
// IN A ROW. This is mutation-sensitive: if the reset-on-
|
|
// success (`errorCount = 0;`) is removed, the counter
|
|
// accumulates past POLL_MAX_ERRORS (5) on the 5th cumulative
|
|
// error → the poll bails (resolve null), the loop never
|
|
// reaches the completed status, and statusCalls stops at 5.
|
|
// With the reset, each group of 4 stays under the limit and
|
|
// the poll reaches completion.
|
|
if (statusCalls >= 1 && statusCalls <= 4) {
|
|
return Promise.reject(new Error('network down'));
|
|
}
|
|
if (statusCalls === 5) {
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ status: 'processing' }) });
|
|
}
|
|
if (statusCalls >= 6 && statusCalls <= 9) {
|
|
return Promise.reject(new Error('network down'));
|
|
}
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ status: 'completed' }) });
|
|
}
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, collections: [] }) });
|
|
});
|
|
|
|
const promise = triggerReindex(btn);
|
|
await vi.advanceTimersByTimeAsync(2000 * 12);
|
|
await promise;
|
|
|
|
// Reached the terminal completed status (statusCall #10) despite 8
|
|
// transient errors — only possible because errorCount resets on the
|
|
// intervening success.
|
|
expect(statusCalls).toBeGreaterThanOrEqual(10);
|
|
// Completion is not a failure → no error alert.
|
|
expect(alertSpy).not.toHaveBeenCalled();
|
|
expect(btn.disabled).toBe(false);
|
|
alertSpy.mockRestore();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('bails (resolve null, no error alert) after POLL_MAX_ERRORS consecutive fetch errors', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const btn = makeButton('coll-poll-errbail');
|
|
withCollectionsContainer(btn);
|
|
const alertSpy = vi.spyOn(globalThis, 'alert').mockImplementation(() => {});
|
|
let statusCalls = 0;
|
|
globalThis.safeFetchWithAuth.mockImplementation((url) => {
|
|
if (url.includes('/index/start')) {
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true }) });
|
|
}
|
|
if (url.includes('/index/status')) {
|
|
statusCalls += 1;
|
|
return Promise.reject(new Error('network down'));
|
|
}
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, collections: [] }) });
|
|
});
|
|
|
|
const promise = triggerReindex(btn);
|
|
await vi.advanceTimersByTimeAsync(2000 * 10);
|
|
await promise;
|
|
|
|
// Bails after exactly POLL_MAX_ERRORS (5) consecutive errors —
|
|
// does NOT keep polling forever.
|
|
expect(statusCalls).toBe(5);
|
|
// Bail resolves null → triggerReindex shows no error alert.
|
|
expect(alertSpy).not.toHaveBeenCalled();
|
|
// Button re-enabled.
|
|
expect(btn.disabled).toBe(false);
|
|
alertSpy.mockRestore();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('caps at POLL_MAX_ATTEMPTS, resolves {status:"timeout"}, and shows a timeout error', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const btn = makeButton('coll-poll-timeout');
|
|
withCollectionsContainer(btn);
|
|
const alertSpy = vi.spyOn(globalThis, 'alert').mockImplementation(() => {});
|
|
let statusCalls = 0;
|
|
globalThis.safeFetchWithAuth.mockImplementation((url) => {
|
|
if (url.includes('/index/start')) {
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true }) });
|
|
}
|
|
if (url.includes('/index/status')) {
|
|
statusCalls += 1;
|
|
// Never terminal → must hit the POLL_MAX_ATTEMPTS cap.
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ status: 'processing' }) });
|
|
}
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, collections: [] }) });
|
|
});
|
|
|
|
const promise = triggerReindex(btn);
|
|
// POLL_MAX_ATTEMPTS = 300 at 2s/poll ≈ 600s; advance generously.
|
|
await vi.advanceTimersByTimeAsync(2000 * 305);
|
|
await promise;
|
|
|
|
// Stopped at the 300-attempt cap, not forever.
|
|
expect(statusCalls).toBe(300);
|
|
// timeout status → showError fires the "taking longer" message.
|
|
expect(alertSpy).toHaveBeenCalled();
|
|
const messages = alertSpy.mock.calls.map(c => String(c[0])).join(' ');
|
|
expect(messages).toContain('taking longer than expected');
|
|
expect(btn.disabled).toBe(false);
|
|
alertSpy.mockRestore();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('treats a transient HTTP 500 as retryable (not terminal) and completes without a false failure alert', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const btn = makeButton('coll-poll-http500');
|
|
withCollectionsContainer(btn);
|
|
const alertSpy = vi.spyOn(globalThis, 'alert').mockImplementation(() => {});
|
|
let statusCalls = 0;
|
|
globalThis.safeFetchWithAuth.mockImplementation((url) => {
|
|
if (url.includes('/index/start')) {
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true }) });
|
|
}
|
|
if (url.includes('/index/status')) {
|
|
statusCalls += 1;
|
|
if (statusCalls === 1) {
|
|
// The endpoint's except path returns 500 {status:'error'}.
|
|
// Without the !response.ok guard this 'error' body is
|
|
// TERMINAL and would fire a false "reindex failed" alert.
|
|
return Promise.resolve({ ok: false, status: 500, json: () => Promise.resolve({ status: 'error' }) });
|
|
}
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ status: 'completed' }) });
|
|
}
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, collections: [] }) });
|
|
});
|
|
|
|
const promise = triggerReindex(btn);
|
|
await vi.advanceTimersByTimeAsync(2000 * 3);
|
|
await promise;
|
|
|
|
// Retried after the 500 and reached completion.
|
|
expect(statusCalls).toBeGreaterThanOrEqual(2);
|
|
// No false "failed" alert — the transient 500 was not treated as terminal.
|
|
expect(alertSpy).not.toHaveBeenCalled();
|
|
expect(btn.disabled).toBe(false);
|
|
alertSpy.mockRestore();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('background-sweep toggle', () => {
|
|
function makeToggle() {
|
|
// Mirror the template: the checkbox lives inside a wrapper row that
|
|
// starts hidden and is only revealed once the backend setting exists.
|
|
document.body.innerHTML =
|
|
'<label id="background-sweep-toggle-row" style="display: none;">' +
|
|
'<input type="checkbox" id="background-sweep-toggle"></label>';
|
|
return document.getElementById('background-sweep-toggle');
|
|
}
|
|
|
|
function getRow() {
|
|
return document.getElementById('background-sweep-toggle-row');
|
|
}
|
|
|
|
it('loads the toggle state from document_scheduler.sweep_library_collections', async () => {
|
|
const toggle = makeToggle();
|
|
globalThis.safeFetchWithAuth.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ value: true }),
|
|
});
|
|
|
|
await loadBackgroundSweepSetting();
|
|
|
|
expect(globalThis.safeFetchWithAuth).toHaveBeenCalledWith(
|
|
'/settings/api/document_scheduler.sweep_library_collections'
|
|
);
|
|
expect(toggle.checked).toBe(true);
|
|
// Setting exists → the row is revealed.
|
|
expect(getRow().style.display).toBe('flex');
|
|
});
|
|
|
|
it('leaves the toggle unchecked when the setting value is false', async () => {
|
|
const toggle = makeToggle();
|
|
toggle.checked = true; // start checked to prove it flips off
|
|
globalThis.safeFetchWithAuth.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ value: false }),
|
|
});
|
|
|
|
await loadBackgroundSweepSetting();
|
|
|
|
expect(toggle.checked).toBe(false);
|
|
expect(getRow().style.display).toBe('flex');
|
|
});
|
|
|
|
it('treats a stringified "true" value as checked', async () => {
|
|
const toggle = makeToggle();
|
|
globalThis.safeFetchWithAuth.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ value: 'true' }),
|
|
});
|
|
|
|
await loadBackgroundSweepSetting();
|
|
|
|
expect(toggle.checked).toBe(true);
|
|
expect(getRow().style.display).toBe('flex');
|
|
});
|
|
|
|
it('hides the row when the setting is not registered (404 / not ok)', async () => {
|
|
// Before #4627 merges, the setting GET returns 404 — the toggle must be
|
|
// hidden rather than shown as a dead, always-unchecked control.
|
|
makeToggle();
|
|
getRow().style.display = 'flex'; // pretend it was visible
|
|
globalThis.safeFetchWithAuth.mockResolvedValue({
|
|
ok: false,
|
|
status: 404,
|
|
json: () => Promise.resolve({ error: 'not found' }),
|
|
});
|
|
|
|
await loadBackgroundSweepSetting();
|
|
|
|
expect(getRow().style.display).toBe('none');
|
|
});
|
|
|
|
it('persists a toggle change via PUT to the same setting key', async () => {
|
|
const toggle = makeToggle();
|
|
toggle.checked = true;
|
|
globalThis.safeFetchWithAuth.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ value: true }),
|
|
});
|
|
|
|
await saveBackgroundSweepSetting();
|
|
|
|
const [url, opts] = globalThis.safeFetchWithAuth.mock.calls[0];
|
|
expect(url).toBe('/settings/api/document_scheduler.sweep_library_collections');
|
|
expect(opts.method).toBe('PUT');
|
|
expect(JSON.parse(opts.body)).toEqual({ value: true });
|
|
// Save succeeded, so the optimistic UI state is preserved.
|
|
expect(toggle.checked).toBe(true);
|
|
});
|
|
|
|
it('reverts the toggle when the save fails', async () => {
|
|
const toggle = makeToggle();
|
|
toggle.checked = true;
|
|
globalThis.safeFetchWithAuth.mockResolvedValue({
|
|
ok: false,
|
|
json: () => Promise.resolve({ error: 'boom' }),
|
|
});
|
|
|
|
await saveBackgroundSweepSetting();
|
|
|
|
expect(toggle.checked).toBe(false);
|
|
});
|
|
|
|
it('shows the toggle ON when only the legacy generate_rag arm is set (effective gate)', async () => {
|
|
// The reconciler runs on (sweep OR generate_rag). An upgraded user with
|
|
// generate_rag=true, sweep=false must NOT see an OFF toggle while the
|
|
// sweep is actively running every tick.
|
|
const toggle = makeToggle();
|
|
globalThis.safeFetchWithAuth
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }) // sweep
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: true }) }); // generate_rag
|
|
|
|
await loadBackgroundSweepSetting();
|
|
|
|
expect(globalThis.safeFetchWithAuth).toHaveBeenNthCalledWith(
|
|
1, '/settings/api/document_scheduler.sweep_library_collections'
|
|
);
|
|
expect(globalThis.safeFetchWithAuth).toHaveBeenNthCalledWith(
|
|
2, '/settings/api/document_scheduler.generate_rag'
|
|
);
|
|
expect(toggle.checked).toBe(true);
|
|
expect(getRow().style.display).toBe('flex');
|
|
});
|
|
|
|
it('does not read the legacy arm when sweep is already ON', async () => {
|
|
const toggle = makeToggle();
|
|
globalThis.safeFetchWithAuth.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ value: true }),
|
|
});
|
|
|
|
await loadBackgroundSweepSetting();
|
|
|
|
// Only the sweep key is read; the legacy arm is irrelevant when sweep is on.
|
|
expect(globalThis.safeFetchWithAuth).toHaveBeenCalledTimes(1);
|
|
expect(toggle.checked).toBe(true);
|
|
});
|
|
|
|
it('turning OFF clears the legacy generate_rag arm WHEN it is set', async () => {
|
|
// Otherwise OFF would leave generate_rag armed and the sweep running.
|
|
const toggle = makeToggle();
|
|
toggle.checked = false; // user just turned it OFF
|
|
globalThis.safeFetchWithAuth
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }) // PUT sweep=false
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: true }) }) // GET generate_rag (set)
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }); // PUT generate_rag=false
|
|
|
|
await saveBackgroundSweepSetting();
|
|
|
|
const calls = globalThis.safeFetchWithAuth.mock.calls;
|
|
expect(calls).toHaveLength(3);
|
|
expect(calls[0][0]).toBe('/settings/api/document_scheduler.sweep_library_collections');
|
|
expect(calls[0][1].method).toBe('PUT');
|
|
expect(JSON.parse(calls[0][1].body)).toEqual({ value: false });
|
|
expect(calls[1][0]).toBe('/settings/api/document_scheduler.generate_rag');
|
|
expect(calls[1][1]).toBeUndefined(); // a GET (read-first), not a PUT
|
|
expect(calls[2][0]).toBe('/settings/api/document_scheduler.generate_rag');
|
|
expect(JSON.parse(calls[2][1].body)).toEqual({ value: false });
|
|
expect(toggle.checked).toBe(false);
|
|
});
|
|
|
|
it('turning OFF does NOT write generate_rag when it is already false', async () => {
|
|
// Read-first: skip the redundant PUT (and the cross-page surprise +
|
|
// extra reschedule) for the common default-install case.
|
|
const toggle = makeToggle();
|
|
toggle.checked = false;
|
|
globalThis.safeFetchWithAuth
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }) // PUT sweep=false
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }); // GET generate_rag (already off)
|
|
|
|
await saveBackgroundSweepSetting();
|
|
|
|
const calls = globalThis.safeFetchWithAuth.mock.calls;
|
|
expect(calls).toHaveLength(2); // sweep PUT + generate_rag GET, no second PUT
|
|
expect(calls[1][1]).toBeUndefined(); // the generate_rag call was a GET
|
|
expect(toggle.checked).toBe(false);
|
|
});
|
|
|
|
it('OFF with a failed generate_rag clear stays ON (still armed via legacy arm)', async () => {
|
|
// sweep=false persisted, but clearing generate_rag failed, so the
|
|
// reconciler is still armed — the honest displayed state is ON, not OFF.
|
|
const toggle = makeToggle();
|
|
toggle.checked = false;
|
|
globalThis.safeFetchWithAuth
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }) // PUT sweep=false
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: true }) }) // GET generate_rag (set)
|
|
.mockResolvedValueOnce({ ok: false, json: () => Promise.resolve({ error: 'boom' }) }); // PUT generate_rag fails
|
|
|
|
await saveBackgroundSweepSetting();
|
|
|
|
expect(toggle.checked).toBe(true);
|
|
});
|
|
|
|
it('a transient failure reading the legacy arm on load does not hide the control', async () => {
|
|
// sweep read succeeds (false); the secondary generate_rag read rejects
|
|
// at the network level. The row must be REVEALED (start hidden, as in
|
|
// the template) and show the sweep-derived state.
|
|
const toggle = makeToggle();
|
|
expect(getRow().style.display).toBe('none'); // starts hidden per template
|
|
globalThis.safeFetchWithAuth
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }) // sweep
|
|
.mockRejectedValueOnce(new Error('network down')); // generate_rag GET rejects
|
|
|
|
await loadBackgroundSweepSetting();
|
|
|
|
expect(getRow().style.display).toBe('flex'); // reveal still fired
|
|
expect(toggle.checked).toBe(false);
|
|
});
|
|
|
|
it('treats a stringified "true" generate_rag value as ON (effective gate)', async () => {
|
|
const toggle = makeToggle();
|
|
globalThis.safeFetchWithAuth
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }) // sweep
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: 'true' }) }); // generate_rag (string)
|
|
|
|
await loadBackgroundSweepSetting();
|
|
|
|
expect(toggle.checked).toBe(true);
|
|
});
|
|
|
|
it('turning OFF still clears generate_rag when its value cannot be read (conservative on unknown)', async () => {
|
|
// The regression the prior fix reopened: a transient generate_rag read
|
|
// failure must NOT be treated as "already off" — OFF must still attempt
|
|
// the clear so the reconciler can never stay silently armed.
|
|
const toggle = makeToggle();
|
|
toggle.checked = false;
|
|
globalThis.safeFetchWithAuth
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }) // PUT sweep=false
|
|
.mockRejectedValueOnce(new Error('network blip')) // GET generate_rag rejects -> null (unknown)
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }); // PUT generate_rag=false
|
|
|
|
await saveBackgroundSweepSetting();
|
|
|
|
const calls = globalThis.safeFetchWithAuth.mock.calls;
|
|
expect(calls).toHaveLength(3);
|
|
expect(calls[2][0]).toBe('/settings/api/document_scheduler.generate_rag');
|
|
expect(calls[2][1].method).toBe('PUT'); // clear was still attempted
|
|
expect(JSON.parse(calls[2][1].body)).toEqual({ value: false });
|
|
expect(toggle.checked).toBe(false); // clear succeeded -> honest OFF
|
|
});
|
|
|
|
it('turning OFF clears generate_rag when the read returns not-ok (unknown, not just reject)', async () => {
|
|
const toggle = makeToggle();
|
|
toggle.checked = false;
|
|
globalThis.safeFetchWithAuth
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }) // PUT sweep=false
|
|
.mockResolvedValueOnce({ ok: false, status: 500, json: () => Promise.resolve({ error: 'x' }) }) // GET generate_rag !ok -> null
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }); // PUT generate_rag=false
|
|
|
|
await saveBackgroundSweepSetting();
|
|
|
|
const calls = globalThis.safeFetchWithAuth.mock.calls;
|
|
expect(calls).toHaveLength(3);
|
|
expect(calls[2][1].method).toBe('PUT'); // unknown -> still cleared
|
|
expect(toggle.checked).toBe(false);
|
|
});
|
|
|
|
it('OFF with unknown legacy read AND a failed clear stays ON (conservative)', async () => {
|
|
const toggle = makeToggle();
|
|
toggle.checked = false;
|
|
globalThis.safeFetchWithAuth
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }) // PUT sweep=false
|
|
.mockRejectedValueOnce(new Error('blip')) // GET generate_rag -> null
|
|
.mockResolvedValueOnce({ ok: false, json: () => Promise.resolve({ error: 'boom' }) }); // PUT generate_rag fails
|
|
|
|
await saveBackgroundSweepSetting();
|
|
|
|
// Couldn't confirm generate_rag is off -> don't claim a false OFF.
|
|
expect(toggle.checked).toBe(true);
|
|
});
|
|
|
|
it('OFF stays ON when the clear PUT rejects at the network level (outer catch)', async () => {
|
|
// The clear-PUT failure must be handled whether it surfaces as {ok:false}
|
|
// or as a thrown network rejection (the outer catch) — both must leave
|
|
// the toggle honestly ON since generate_rag may still be armed.
|
|
const toggle = makeToggle();
|
|
toggle.checked = false;
|
|
globalThis.safeFetchWithAuth
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: false }) }) // PUT sweep=false
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: true }) }) // GET generate_rag (set)
|
|
.mockRejectedValueOnce(new Error('network down')); // PUT generate_rag rejects -> outer catch
|
|
|
|
await saveBackgroundSweepSetting();
|
|
|
|
expect(toggle.checked).toBe(true);
|
|
});
|
|
|
|
it('turning ON writes only sweep=true (does not touch the legacy arm)', async () => {
|
|
const toggle = makeToggle();
|
|
toggle.checked = true; // user just turned it ON
|
|
globalThis.safeFetchWithAuth.mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({ value: true }),
|
|
});
|
|
|
|
await saveBackgroundSweepSetting();
|
|
|
|
const calls = globalThis.safeFetchWithAuth.mock.calls;
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0][0]).toBe('/settings/api/document_scheduler.sweep_library_collections');
|
|
expect(JSON.parse(calls[0][1].body)).toEqual({ value: true });
|
|
expect(toggle.checked).toBe(true);
|
|
});
|
|
});
|