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
433 lines
17 KiB
JavaScript
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:');
|
|
});
|
|
});
|