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

433 lines
17 KiB
JavaScript

/**
* Tests for services/ui.js
*
* Tests UI utility functions: progress bars, spinners, error display,
* inline errors, escapeHtmlFallback, and notification system.
*/
import '@js/security/xss-protection.js';
import '@js/services/ui.js';
const ui = window.ui;
describe('ui service', () => {
describe('updateProgressBar', () => {
let fill, pct;
beforeEach(() => {
fill = document.createElement('div');
fill.id = 'test-fill';
pct = document.createElement('span');
pct.id = 'test-pct';
document.body.appendChild(fill);
document.body.appendChild(pct);
});
afterEach(() => {
fill.remove();
pct.remove();
});
it('sets width and text for percentage', () => {
ui.updateProgressBar(fill, pct, 42);
expect(fill.style.width).toBe('42%');
expect(pct.textContent).toBe('42%');
});
it('clamps percentage to 0-100', () => {
ui.updateProgressBar(fill, pct, -10);
expect(fill.style.width).toBe('0%');
expect(pct.textContent).toBe('0%');
ui.updateProgressBar(fill, pct, 150);
expect(fill.style.width).toBe('100%');
expect(pct.textContent).toBe('100%');
});
it('handles null/undefined percentage as 0', () => {
ui.updateProgressBar(fill, pct, null);
expect(fill.style.width).toBe('0%');
});
it('adds ldr-complete class at 100%', () => {
ui.updateProgressBar(fill, pct, 100);
expect(fill.classList.contains('ldr-complete')).toBe(true);
});
it('removes ldr-complete class when below 100%', () => {
fill.classList.add('ldr-complete');
ui.updateProgressBar(fill, pct, 50);
expect(fill.classList.contains('ldr-complete')).toBe(false);
});
it('accepts string IDs instead of elements', () => {
ui.updateProgressBar('test-fill', 'test-pct', 75);
expect(fill.style.width).toBe('75%');
expect(pct.textContent).toBe('75%');
});
it('rounds percentage text', () => {
ui.updateProgressBar(fill, pct, 33.7);
expect(pct.textContent).toBe('34%');
});
});
describe('showSpinner / hideSpinner', () => {
let container;
beforeEach(() => {
container = document.createElement('div');
container.id = 'spinner-container';
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it('creates a spinner element in the container', () => {
ui.showSpinner(container, 'Loading data...');
expect(container.querySelector('.ldr-loading-spinner')).not.toBeNull();
});
it('displays the message text', () => {
ui.showSpinner(container, 'Please wait');
expect(container.textContent).toContain('Please wait');
});
it('escapes HTML in the message', () => {
ui.showSpinner(container, '<script>alert(1)</script>');
expect(container.innerHTML).not.toContain('<script>');
});
it('hideSpinner removes the spinner', () => {
ui.showSpinner(container);
expect(container.querySelector('.ldr-loading-spinner')).not.toBeNull();
ui.hideSpinner(container);
expect(container.querySelector('.ldr-loading-spinner')).toBeNull();
});
it('hideSpinner does nothing when no spinner exists', () => {
expect(() => ui.hideSpinner(container)).not.toThrow();
});
it('accepts string IDs', () => {
ui.showSpinner('spinner-container', 'Test');
expect(container.querySelector('.ldr-loading-spinner')).not.toBeNull();
ui.hideSpinner('spinner-container');
expect(container.querySelector('.ldr-loading-spinner')).toBeNull();
});
});
describe('showError', () => {
let container;
beforeEach(() => {
container = document.createElement('div');
container.id = 'error-container';
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it('shows error message in container', () => {
ui.showError(container, 'Something went wrong');
expect(container.textContent).toContain('Something went wrong');
});
it('escapes HTML in error message', () => {
ui.showError(container, '<img onerror="xss">');
expect(container.innerHTML).not.toContain('<img');
});
it('creates error element with icon', () => {
ui.showError(container, 'Error');
expect(container.querySelector('.ldr-error-message')).not.toBeNull();
expect(container.querySelector('.fa-exclamation-circle')).not.toBeNull();
});
});
describe('showInlineError / clearInlineError', () => {
let container;
beforeEach(() => {
container = document.createElement('div');
container.id = 'inline-error-container';
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it('creates an inline error element', () => {
const el = ui.showInlineError(container, 'Field is required');
expect(el).not.toBeNull();
expect(el.classList.contains('ldr-inline-error')).toBe(true);
expect(el.textContent).toContain('Field is required');
});
it('sets role="alert" for accessibility', () => {
const el = ui.showInlineError(container, 'Error');
expect(el.getAttribute('role')).toBe('alert');
});
it('adds dismiss button by default', () => {
const el = ui.showInlineError(container, 'Error');
const closeBtn = el.querySelector('.ldr-inline-error-close');
expect(closeBtn).not.toBeNull();
expect(closeBtn.getAttribute('aria-label')).toBe('Dismiss error');
});
it('dismiss button removes error', () => {
const el = ui.showInlineError(container, 'Error');
const closeBtn = el.querySelector('.ldr-inline-error-close');
closeBtn.click();
expect(container.querySelector('.ldr-inline-error')).toBeNull();
});
it('replaces existing inline error', () => {
ui.showInlineError(container, 'First error');
ui.showInlineError(container, 'Second error');
const errors = container.querySelectorAll('.ldr-inline-error');
expect(errors.length).toBe(1);
expect(errors[0].textContent).toContain('Second error');
});
it('clearInlineError removes all errors', () => {
ui.showInlineError(container, 'Error 1');
ui.clearInlineError(container);
expect(container.querySelector('.ldr-inline-error')).toBeNull();
});
it('returns null for non-existent container', () => {
expect(ui.showInlineError('#nonexistent', 'Error')).toBeNull();
});
it('accepts string ID for container', () => {
const el = ui.showInlineError('inline-error-container', 'Test');
expect(el).not.toBeNull();
});
it('uses textContent (not innerHTML) for message', () => {
const el = ui.showInlineError(container, '<script>xss</script>');
const span = el.querySelector('span');
expect(span.textContent).toBe('<script>xss</script>');
expect(span.innerHTML).not.toContain('<script>');
});
});
describe('showMessage', () => {
afterEach(() => {
// Persistent banners stay in the DOM by design; clean up
// between tests so each test starts fresh.
document
.getElementById('notification-banner-polite')
?.remove();
document
.getElementById('notification-banner-assertive')
?.remove();
});
it('shows the polite banner for success messages', () => {
ui.showMessage('Saved!', 'success');
const polite = document.getElementById(
'notification-banner-polite',
);
expect(polite).not.toBeNull();
expect(polite.textContent).toContain('Saved!');
expect(polite.getAttribute('role')).toBe('status');
expect(polite.getAttribute('aria-live')).toBe('polite');
});
it('shows the assertive banner for error messages', () => {
ui.showMessage('Error occurred', 'error');
const assertive = document.getElementById(
'notification-banner-assertive',
);
expect(assertive).not.toBeNull();
expect(assertive.textContent).toContain('Error occurred');
expect(assertive.getAttribute('role')).toBe('alert');
expect(assertive.getAttribute('aria-live')).toBe('assertive');
});
it('uses the assertive banner for warnings', () => {
ui.showMessage('Heads up', 'warning');
const assertive = document.getElementById(
'notification-banner-assertive',
);
expect(assertive.textContent).toContain('Heads up');
});
it('uses the polite banner for info messages', () => {
ui.showMessage('FYI', 'info');
const polite = document.getElementById(
'notification-banner-polite',
);
expect(polite.textContent).toContain('FYI');
});
it('escapes HTML by using textContent', () => {
ui.showMessage('<img src=x onerror=alert(1)>', 'info');
const polite = document.getElementById(
'notification-banner-polite',
);
// textContent is set, not innerHTML, so the markup is text.
expect(polite.textContent).toContain(
'<img src=x onerror=alert(1)>',
);
expect(polite.querySelector('img')).toBeNull();
});
it('reuses the same banner element across calls', () => {
ui.showMessage('First', 'success');
const firstNode = document.getElementById(
'notification-banner-polite',
);
ui.showMessage('Second', 'info');
const secondNode = document.getElementById(
'notification-banner-polite',
);
// Same DOM node — live regions must persist for the
// screen reader to announce subsequent updates.
expect(secondNode).toBe(firstNode);
expect(secondNode.textContent).toContain('Second');
expect(secondNode.textContent).not.toContain('First');
});
it('switches between banners when type changes', () => {
ui.showMessage('Saved', 'success');
ui.showMessage('Boom', 'error');
const polite = document.getElementById(
'notification-banner-polite',
);
const assertive = document.getElementById(
'notification-banner-assertive',
);
expect(assertive.textContent).toContain('Boom');
// Polite is hidden via transform but its prior text
// is still in the DOM; the visible banner is assertive.
expect(polite.style.transform).toContain('-100%');
});
});
});
describe('renderMarkdown', () => {
it('returns warning HTML for null/empty input', () => {
const result = ui.renderMarkdown(null);
expect(result).toContain('No content available');
});
it('returns plaintext fallback when marked is unavailable', () => {
// happy-dom doesn't have `marked` loaded
const result = ui.renderMarkdown('# Hello World');
// Should escape HTML and show as plaintext
expect(result).toContain('Hello World');
});
});
describe('renderMarkdown with KaTeX math', () => {
// Configure a real marked + KaTeX + DOMPurify pipeline for this block only.
// The setup mirrors app.js so renderMarkdown takes the marked-rendering
// branch instead of falling through to the plaintext-escape fallback.
beforeAll(async () => {
const { Marked } = await import('marked');
const markedKatex = (await import('marked-katex-extension')).default;
const DOMPurify = (await import('dompurify')).default;
const m = new Marked();
// Mirror app.js so the test exercises the production config.
m.use(markedKatex({ throwOnError: false, errorColor: 'currentColor' }));
globalThis.marked = m;
globalThis.DOMPurify = DOMPurify;
});
afterAll(() => {
delete globalThis.marked;
delete globalThis.DOMPurify;
});
// happy-dom does not parse <math> into the MathML namespace, so DOMPurify
// strips the MathML accessibility subtree (annotation, semantics, mrow, ...)
// even though those elements survive in real browsers. We assert on the
// KaTeX HTML rendering branch instead — that is the visible math layer
// and the proof that the extension actually ran.
it('renders inline math as KaTeX HTML', () => {
const result = ui.renderMarkdown('The equation $E=mc^2$ is famous');
expect(result).toContain('class="katex"');
expect(result).toContain('class="katex-html"');
expect(result).toContain('mord mathnormal');
});
it('renders display math as a katex-display block', () => {
const result = ui.renderMarkdown('$$\\sum_{i=1}^n i$$');
expect(result).toContain('class="katex-display"');
expect(result).toContain('op-symbol');
});
it('renders math alongside other markdown', () => {
// The leading "Intro." paragraph is a deliberate sacrificial node.
// DOMPurify >=3.4.8 reads element names through the cached
// Node.prototype.nodeName getter (anti-clobbering hardening). happy-dom's
// getter returns "" when invoked that way, so the first top-level node of
// the sanitized fragment is treated as an unknown tag and unwrapped — its
// tags dropped, text kept (a leading `<h1>Title</h1>` collapses to
// `Title`). Real browsers return the correct tag name and are unaffected,
// so this is a test-env-only artifact, tracked upstream at
// https://github.com/capricorn86/happy-dom/issues/2182. Prefixing a
// throwaway paragraph keeps the heading off the first slot so we can still
// assert on its `<h1>` rendering here. See also the MathML note above.
const result = ui.renderMarkdown('Intro.\n\n# Title\n\nSome text $x^2$ more text\n\n- list item');
expect(result).toContain('<h1');
expect(result).toContain('<li>');
expect(result).toContain('class="katex"');
});
it('does not treat adjacent dollar amounts as math (standard mode)', () => {
// With marked-katex-extension default (no nonStandard flag),
// "$10 and $20" must not be parsed as math: the `$` delimiters
// require whitespace boundaries.
const result = ui.renderMarkdown('It costs $10 and $20 per unit.');
expect(result).not.toContain('class="katex"');
});
it('renders multi-line display math ($$\\n…\\n$$)', () => {
// Canonical block form — what LLMs and textbooks typically emit.
// This pattern caused display math to be silently dropped from PDFs
// before commit 2c47b529e; locking in the rendering behavior here
// guards against a related regression in the markdown path.
const result = ui.renderMarkdown('Para 1\n\n$$\n\\sum_{i=1}^n i\n$$\n\nPara 2');
expect(result).toContain('class="katex-display"');
expect(result).toContain('Para 1');
expect(result).toContain('Para 2');
});
it('does not render math inside inline code spans', () => {
// `$x^2$` inside backticks must stay as literal code, not be parsed
// as math. This is both a correctness and a defense-in-depth concern
// (an LLM documenting LaTeX syntax should not have its examples
// silently rendered).
const result = ui.renderMarkdown('Use `$x^2$` to write x squared.');
expect(result).not.toContain('class="katex"');
expect(result).toContain('<code>$x^2$</code>');
});
it('does not render math inside fenced code blocks', () => {
const result = ui.renderMarkdown('```\n$E=mc^2$\n```');
expect(result).not.toContain('class="katex"');
expect(result).toContain('$E=mc^2$');
});
it('renders invalid LaTeX without throwing (throwOnError: false)', () => {
// app.js configures the extension with throwOnError: false so a
// malformed formula in LLM output degrades gracefully rather than
// breaking the whole render. Verify the wrapper doesn't blow up.
expect(() => ui.renderMarkdown('Bad math: $\\frac{a$')).not.toThrow();
const result = ui.renderMarkdown('Bad math: $\\frac{a$');
// Either the error span renders or KaTeX recovers — either way the
// surrounding text must come through.
expect(result).toContain('Bad math:');
});
});