Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

217 lines
8.2 KiB
JavaScript

/**
* Tests for services/theme.js
*
* Tests theme resolution, validation, cycling, and storage.
* Network calls (server sync) are not tested here as they
* require a running backend.
*/
// Theme metadata must be set BEFORE the module IIFE runs.
// ES module imports are hoisted, so we use dynamic import in beforeAll.
let theme;
beforeAll(async () => {
// Set up theme metadata before loading the module
window.LDR_THEME_METADATA = {
'hashed': { label: 'Hashed', icon: 'fa-hashtag', group: 'core' },
'light': { label: 'Light', icon: 'fa-sun', group: 'core' },
'sepia': { label: 'Sepia', icon: 'fa-book', group: 'research' },
'nord': { label: 'Nord', icon: 'fa-snowflake', group: 'dev' },
'dracula': { label: 'Dracula', icon: 'fa-ghost', group: 'dev' },
'system': { label: 'System', icon: 'fa-desktop', group: 'system' },
};
// Stub fetch for server sync calls
globalThis.fetch = vi.fn(() =>
Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) })
);
// Stub api for CSRF
window.api = { getCsrfToken: () => '' };
await import('@js/services/theme.js');
theme = window.themeService;
});
describe('themeService', () => {
afterEach(() => {
localStorage.clear();
});
describe('VALID_THEMES is derived from injected metadata', () => {
it('contains every key from window.LDR_THEME_METADATA', () => {
// Catches a regression where the derivation is removed/hardcoded.
const metadataKeys = Object.keys(window.LDR_THEME_METADATA);
for (const key of metadataKeys) {
expect(theme.VALID_THEMES).toContain(key);
}
});
});
describe('getEffectiveTheme', () => {
it('resolves system to sepia when prefers-color-scheme is light', () => {
// happy-dom matchMedia returns false for dark mode by default
expect(theme.getEffectiveTheme('system')).toBe('sepia');
});
it('returns the theme itself for non-system themes', () => {
expect(theme.getEffectiveTheme('hashed')).toBe('hashed');
expect(theme.getEffectiveTheme('light')).toBe('light');
expect(theme.getEffectiveTheme('nord')).toBe('nord');
});
});
describe('getCurrentTheme / setTheme', () => {
it('defaults to system when no theme stored', () => {
localStorage.clear();
expect(theme.getCurrentTheme()).toBe('system');
});
it('stores and retrieves theme', () => {
theme.setTheme('nord', false);
expect(theme.getCurrentTheme()).toBe('nord');
});
it('falls back to hashed for invalid theme', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
theme.setTheme('nonexistent-theme', false);
expect(theme.getCurrentTheme()).toBe('hashed');
warnSpy.mockRestore();
});
});
describe('applyTheme', () => {
it('sets data-theme attribute on documentElement', () => {
theme.applyTheme('nord');
expect(document.documentElement.getAttribute('data-theme')).toBe('nord');
});
it('resolves system theme before applying', () => {
theme.applyTheme('system');
const applied = document.documentElement.getAttribute('data-theme');
expect(['sepia', 'hashed']).toContain(applied);
});
it('dispatches themechange event', () => {
const handler = vi.fn();
window.addEventListener('themechange', handler);
theme.applyTheme('light');
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0].detail.theme).toBe('light');
window.removeEventListener('themechange', handler);
});
});
describe('cycleTheme', () => {
it('cycles through THEME_CYCLE order', () => {
theme.setTheme('hashed', false);
const next = theme.cycleTheme();
expect(next).toBe('light');
});
it('wraps around to first theme after last', () => {
theme.setTheme('dracula', false);
const next = theme.cycleTheme();
expect(next).toBe('hashed');
});
it('starts from beginning when current theme is not in cycle', () => {
theme.setTheme('sepia', false);
const next = theme.cycleTheme();
// sepia is not in cycle, index=-1 → becomes 0 → 'hashed'
expect(next).toBe('hashed');
});
});
describe('clearTheme', () => {
it('removes theme from localStorage', () => {
theme.setTheme('nord', false);
expect(theme.getCurrentTheme()).toBe('nord');
theme.clearTheme();
expect(theme.getCurrentTheme()).toBe('system');
});
});
describe('clearAllThemes', () => {
it('removes all theme keys from localStorage', () => {
theme.setTheme('nord', false);
localStorage.setItem('ldr-theme-user2', 'light');
theme.clearAllThemes();
expect(localStorage.getItem('ldr-theme-user2')).toBeNull();
});
});
describe('populateThemeDropdown', () => {
it('populates a select element with theme options', () => {
const select = document.createElement('select');
theme.populateThemeDropdown(select);
const optgroups = select.querySelectorAll('optgroup');
expect(optgroups.length).toBeGreaterThan(0);
const options = select.querySelectorAll('option');
expect(options.length).toBe(Object.keys(window.LDR_THEME_METADATA).length);
});
it('does nothing when element is null', () => {
expect(() => theme.populateThemeDropdown(null)).not.toThrow();
});
});
describe('saveThemeToServer', () => {
// Restore each test's fetch mock and CSRF stub so we don't leak
// assertions across the outer describe block's later tests.
let originalGetCsrfToken;
beforeEach(() => {
originalGetCsrfToken = window.api.getCsrfToken;
});
afterEach(() => {
window.api.getCsrfToken = originalGetCsrfToken;
});
it('returns Promise.resolve() without calling fetch when CSRF is missing', async () => {
window.api.getCsrfToken = () => '';
const fetchSpy = vi.fn();
globalThis.fetch = fetchSpy;
await theme.saveThemeToServer('nord');
expect(fetchSpy).not.toHaveBeenCalled();
});
it('PUTs to /settings/api/app.theme with CSRF header and JSON body', async () => {
window.api.getCsrfToken = () => 'csrf-test-123';
globalThis.fetch = vi.fn(() =>
Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({}) })
);
await theme.saveThemeToServer('nord');
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url, init] = globalThis.fetch.mock.calls[0];
expect(url).toBe('/settings/api/app.theme');
expect(init.method).toBe('PUT');
expect(init.headers['Content-Type']).toBe('application/json');
expect(init.headers['X-CSRFToken']).toBe('csrf-test-123');
expect(JSON.parse(init.body)).toEqual({ value: 'nord' });
});
it('swallows non-ok responses without rejecting the caller', async () => {
window.api.getCsrfToken = () => 'csrf-test-123';
globalThis.fetch = vi.fn(() =>
Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) })
);
// Should not throw — the .catch in saveThemeToServer turns the
// HTTP 404 into a warning log.
await expect(theme.saveThemeToServer('nord')).resolves.toBeUndefined();
});
it('swallows network errors without rejecting the caller', async () => {
window.api.getCsrfToken = () => 'csrf-test-123';
globalThis.fetch = vi.fn(() => Promise.reject(new Error('offline')));
await expect(theme.saveThemeToServer('nord')).resolves.toBeUndefined();
});
});
});