coming together
This commit is contained in:
133
static/js/__tests__/alerts.ui.test.js
Normal file
133
static/js/__tests__/alerts.ui.test.js
Normal file
@@ -0,0 +1,133 @@
|
||||
/** @jest-environment jsdom */
|
||||
|
||||
const path = require('path');
|
||||
|
||||
// Load sanitizer and alerts modules (IIFE attaches to window)
|
||||
require(path.join(__dirname, '..', 'sanitizer.js'));
|
||||
require(path.join(__dirname, '..', 'alerts.js'));
|
||||
|
||||
// Polyfill requestAnimationFrame for jsdom
|
||||
beforeAll(() => {
|
||||
if (!global.requestAnimationFrame) {
|
||||
global.requestAnimationFrame = (cb) => setTimeout(cb, 0);
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('alerts.show UI behavior', () => {
|
||||
it('creates a container and renders a notification', () => {
|
||||
const wrapper = window.alerts.show('Hello world', 'info', { duration: 0 });
|
||||
const container = document.getElementById('notification-container');
|
||||
expect(container).toBeTruthy();
|
||||
expect(container.contains(wrapper)).toBe(true);
|
||||
expect(wrapper.className).toMatch(/alert-notification/);
|
||||
});
|
||||
|
||||
it('applies styling based on type and aliases', () => {
|
||||
const s = window.alerts.show('ok', 'success', { duration: 0 });
|
||||
const e = window.alerts.show('bad', 'error', { duration: 0 }); // alias to danger
|
||||
const w = window.alerts.show('warn', 'warning', { duration: 0 });
|
||||
const i = window.alerts.show('info', 'info', { duration: 0 });
|
||||
|
||||
expect(s.className).toContain('bg-green-50');
|
||||
expect(e.className).toContain('bg-red-50');
|
||||
expect(w.className).toContain('bg-yellow-50');
|
||||
expect(i.className).toContain('bg-blue-50');
|
||||
});
|
||||
|
||||
it('renders title when provided', () => {
|
||||
const wrapper = window.alerts.show('Body', 'info', { title: 'My Title', duration: 0 });
|
||||
const titleEl = wrapper.querySelector('p.text-sm.font-bold');
|
||||
expect(titleEl).toBeTruthy();
|
||||
expect(titleEl.textContent).toBe('My Title');
|
||||
});
|
||||
|
||||
it('sanitizes HTML when html option is true', () => {
|
||||
const wrapper = window.alerts.show('<img src=x onerror=alert(1)><script>evil()</script><p>hi</p>', 'info', { html: true, duration: 0 });
|
||||
const textEl = wrapper.querySelector('.text-sm.mt-1.font-semibold');
|
||||
expect(textEl).toBeTruthy();
|
||||
const html = textEl.innerHTML;
|
||||
expect(html).toContain('<img');
|
||||
expect(html).toContain('<p>hi</p>');
|
||||
expect(html).not.toMatch(/<script/i);
|
||||
expect(html).not.toMatch(/onerror/i);
|
||||
});
|
||||
|
||||
it('supports Node message content without sanitization', () => {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = 'node message';
|
||||
const wrapper = window.alerts.show(span, 'info', { duration: 0 });
|
||||
const textEl = wrapper.querySelector('.text-sm.mt-1.font-semibold');
|
||||
expect(textEl.textContent).toContain('node message');
|
||||
});
|
||||
|
||||
it('is dismissible by default and calls onClose', () => {
|
||||
const onClose = jest.fn();
|
||||
const wrapper = window.alerts.show('dismiss me', 'info', { onClose, duration: 0 });
|
||||
const btn = wrapper.querySelector('button[aria-label="Close"]');
|
||||
expect(btn).toBeTruthy();
|
||||
btn.click();
|
||||
expect(document.body.contains(wrapper)).toBe(false);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('can be non-dismissible', () => {
|
||||
const wrapper = window.alerts.show('stay', 'info', { dismissible: false, duration: 0 });
|
||||
const btn = wrapper.querySelector('button[aria-label="Close"]');
|
||||
expect(btn).toBeNull();
|
||||
});
|
||||
|
||||
it('auto-closes after duration', () => {
|
||||
jest.useFakeTimers();
|
||||
const wrapper = window.alerts.show('timeout', 'info', { duration: 50 });
|
||||
expect(document.body.contains(wrapper)).toBe(true);
|
||||
jest.advanceTimersByTime(400);
|
||||
expect(document.body.contains(wrapper)).toBe(false);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders actions and handles clicks (autoClose true by default)', () => {
|
||||
const onClick = jest.fn();
|
||||
const wrapper = window.alerts.show('with action', 'info', {
|
||||
duration: 0,
|
||||
actions: [
|
||||
{ label: 'Retry', onClick }
|
||||
]
|
||||
});
|
||||
const buttons = Array.from(wrapper.querySelectorAll('button'));
|
||||
const retryBtn = buttons.find((b) => b.textContent === 'Retry');
|
||||
expect(retryBtn).toBeTruthy();
|
||||
retryBtn.click();
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
expect(document.body.contains(wrapper)).toBe(false);
|
||||
});
|
||||
|
||||
it('respects action.autoClose = false', () => {
|
||||
const onClick = jest.fn();
|
||||
const wrapper = window.alerts.show('stay open', 'info', {
|
||||
duration: 0,
|
||||
actions: [
|
||||
{ label: 'Stay', onClick, autoClose: false }
|
||||
]
|
||||
});
|
||||
const buttons = Array.from(wrapper.querySelectorAll('button'));
|
||||
const stayBtn = buttons.find((b) => b.textContent === 'Stay');
|
||||
expect(stayBtn).toBeTruthy();
|
||||
stayBtn.click();
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
expect(document.body.contains(wrapper)).toBe(true);
|
||||
});
|
||||
|
||||
it('supports custom containerId and element id', () => {
|
||||
const wrapper = window.alerts.show('custom', 'info', { duration: 0, containerId: 'alt-container', id: 'toast-1' });
|
||||
const container = document.getElementById('alt-container');
|
||||
expect(container).toBeTruthy();
|
||||
expect(wrapper.id).toBe('toast-1');
|
||||
expect(container.contains(wrapper)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
79
static/js/__tests__/highlight.test.js
Normal file
79
static/js/__tests__/highlight.test.js
Normal file
@@ -0,0 +1,79 @@
|
||||
/** @jest-environment jsdom */
|
||||
|
||||
// Load dependencies used by highlight utils
|
||||
require('../sanitizer.js');
|
||||
require('../highlight.js');
|
||||
|
||||
describe('highlightUtils', () => {
|
||||
const { buildTokens, highlight, escape: esc, formatSnippet } = window.highlightUtils;
|
||||
|
||||
test('buildTokens normalizes punctuation, trims non-alphanumerics, and dedupes', () => {
|
||||
const tokens = buildTokens(' John, Smith; "Smith" (J.) ');
|
||||
// Expect order preserved except deduping
|
||||
expect(tokens).toEqual(['John', 'Smith', 'J']);
|
||||
const empty = buildTokens(' , ; : ');
|
||||
expect(empty).toEqual([]);
|
||||
});
|
||||
|
||||
test('escape encodes special characters safely', () => {
|
||||
const out = esc('<div> & "quotes" and \'apostrophes\'');
|
||||
expect(out).toContain('<div>');
|
||||
expect(out).toContain('&');
|
||||
expect(out).toContain('"');
|
||||
expect(out).toContain(''');
|
||||
expect(esc('Tom & Jerry')).toBe('Tom & Jerry');
|
||||
});
|
||||
|
||||
test('highlight wraps tokens in <strong> and does not break HTML by escaping first', () => {
|
||||
const tokens = buildTokens('john smith');
|
||||
const result = highlight('Hello <b>John</b> Smith & Sons', tokens);
|
||||
// Should escape original tags and then apply strong
|
||||
expect(result).toContain('<b>');
|
||||
expect(result).toMatch(/<strong>John<\/strong>/i);
|
||||
expect(result).toMatch(/<strong>Smith<\/strong>/i);
|
||||
// Ampersand must be escaped
|
||||
expect(result).toContain('& Sons');
|
||||
});
|
||||
|
||||
test('highlight handles overlapping tokens by sequential replacement', () => {
|
||||
const tokens = buildTokens('ann anna');
|
||||
const out = highlight('Anna and Ann went', tokens);
|
||||
// Both tokens should appear highlighted; order of replacement should not remove prior highlights
|
||||
const strongCount = (out.match(/<strong>/g) || []).length;
|
||||
expect(strongCount).toBeGreaterThanOrEqual(2);
|
||||
expect(out).toMatch(/<strong>Anna<\/strong> and <strong>Ann<\/strong> went/i);
|
||||
});
|
||||
|
||||
test('formatSnippet uses server-provided strong tags if present', () => {
|
||||
const tokens = buildTokens('alpha');
|
||||
const serverSnippet = 'Value: <strong>Alpha</strong> beta';
|
||||
const html = formatSnippet(serverSnippet, tokens);
|
||||
// Should preserve strong from server
|
||||
expect(html).toContain('<strong>Alpha</strong>');
|
||||
// Should be sanitized and not double-escaped
|
||||
expect(html).toContain('Value: ');
|
||||
});
|
||||
|
||||
test('formatSnippet applies client-side bold when server snippet is plain text', () => {
|
||||
const tokens = buildTokens('delta');
|
||||
const plain = 'Gamma delta epsilon';
|
||||
const html = formatSnippet(plain, tokens);
|
||||
expect(html).toMatch(/Gamma <strong>delta<\/strong> epsilon/i);
|
||||
});
|
||||
|
||||
test('highlight is case-insensitive and preserves original text casing', () => {
|
||||
const tokens = buildTokens('joHN smiTH');
|
||||
const out = highlight('John Smith', tokens);
|
||||
// Must wrap both tokens and preserve the original casing from the source text
|
||||
expect(out).toBe('<strong>John</strong> <strong>Smith</strong>');
|
||||
});
|
||||
|
||||
test('formatSnippet highlights with mixed-case query tokens but keeps snippet casing', () => {
|
||||
const tokens = buildTokens('doE');
|
||||
const html = formatSnippet('Hello Doe', tokens);
|
||||
// Exact casing from snippet should be preserved inside <strong>
|
||||
expect(html).toContain('Hello <strong>Doe</strong>');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
123
static/js/__tests__/upload.ui.test.js
Normal file
123
static/js/__tests__/upload.ui.test.js
Normal file
@@ -0,0 +1,123 @@
|
||||
/** @jest-environment jsdom */
|
||||
|
||||
const path = require('path');
|
||||
|
||||
// Install a fetch mock BEFORE loading the fetch-wrapper so it captures our mock
|
||||
const fetchMock = jest.fn();
|
||||
global.fetch = fetchMock;
|
||||
|
||||
// Load sanitizer and alerts (IIFE attaches to window)
|
||||
require(path.join(__dirname, '..', 'sanitizer.js'));
|
||||
require(path.join(__dirname, '..', 'alerts.js'));
|
||||
// Load fetch wrapper (captures current global fetch as originalFetch)
|
||||
require(path.join(__dirname, '..', 'fetch-wrapper.js'));
|
||||
|
||||
// Polyfill requestAnimationFrame for jsdom animations in alerts
|
||||
beforeAll(() => {
|
||||
if (!global.requestAnimationFrame) {
|
||||
global.requestAnimationFrame = (cb) => setTimeout(cb, 0);
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
// Minimal UI helper that simulates the upload UI error handling
|
||||
async function uploadFileUI() {
|
||||
// Attempt an upload; on failure, surface the envelope via alerts.error with a sanitized HTML message
|
||||
const resp = await window.http.wrappedFetch('/api/documents/upload/FILE-123', {
|
||||
method: 'POST',
|
||||
body: new FormData(),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await window.http.toError(resp, 'Upload failed');
|
||||
const msg = window.http.formatAlert(err, 'Upload failed');
|
||||
// html: true to allow basic markup from server but sanitized; duration 0 so it stays for assertions
|
||||
return window.alerts.error(msg, { html: true, duration: 0, id: 'upload-error' });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function makeErrorResponse({ status = 400, envelope, headerCid = null }) {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
headers: {
|
||||
get: (name) => {
|
||||
if (name && name.toLowerCase() === 'x-correlation-id') return headerCid || null;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
clone() {
|
||||
return this;
|
||||
},
|
||||
async json() {
|
||||
return envelope;
|
||||
},
|
||||
async text() {
|
||||
try { return JSON.stringify(envelope); } catch (_) { return ''; }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('Upload UI error handling', () => {
|
||||
it('displays server error via alerts.error, sanitizes HTML, and includes correlation ID', async () => {
|
||||
const cid = 'cid-abc123';
|
||||
const envelope = {
|
||||
success: false,
|
||||
error: { status: 400, code: 'http_error', message: 'Invalid <b>file</b> <script>evil()</script><img src=x onerror="alert(1)">' },
|
||||
correlation_id: cid,
|
||||
};
|
||||
fetchMock.mockResolvedValueOnce(makeErrorResponse({ status: 400, envelope, headerCid: cid }));
|
||||
|
||||
const wrapper = await uploadFileUI();
|
||||
expect(wrapper).toBeTruthy();
|
||||
expect(wrapper.id).toBe('upload-error');
|
||||
|
||||
const content = wrapper.querySelector('.text-sm.mt-1.font-semibold');
|
||||
expect(content).toBeTruthy();
|
||||
const html = content.innerHTML;
|
||||
// Preserves safe markup
|
||||
expect(html).toContain('<b>file</b>');
|
||||
// Scripts and event handlers removed
|
||||
expect(html).not.toMatch(/<script/i);
|
||||
expect(html).not.toMatch(/onerror=/i);
|
||||
// Correlation reference present
|
||||
expect(html).toMatch(/Ref: cid-abc123/);
|
||||
});
|
||||
|
||||
it('uses correlation ID from header when both header and envelope provide values', async () => {
|
||||
const headerCid = 'cid-header';
|
||||
const bodyCid = 'cid-body';
|
||||
const envelope = {
|
||||
success: false,
|
||||
error: { status: 400, code: 'http_error', message: 'Invalid type' },
|
||||
correlation_id: bodyCid,
|
||||
};
|
||||
fetchMock.mockResolvedValueOnce(makeErrorResponse({ status: 400, envelope, headerCid }));
|
||||
|
||||
const wrapper = await uploadFileUI();
|
||||
const html = wrapper.querySelector('.text-sm.mt-1.font-semibold').innerHTML;
|
||||
expect(html).toMatch(/Ref: cid-header/);
|
||||
expect(html).not.toMatch(/cid-body/);
|
||||
});
|
||||
|
||||
it('falls back to basic text if alerts module is missing but our alerts is present; ensures container exists', async () => {
|
||||
const cid = 'cid-xyz';
|
||||
const envelope = {
|
||||
success: false,
|
||||
error: { status: 400, code: 'http_error', message: 'Bad <em>upload</em>' },
|
||||
correlation_id: cid,
|
||||
};
|
||||
fetchMock.mockResolvedValueOnce(makeErrorResponse({ status: 400, envelope, headerCid: cid }));
|
||||
|
||||
const wrapper = await uploadFileUI();
|
||||
const container = document.getElementById('notification-container');
|
||||
expect(container).toBeTruthy();
|
||||
expect(container.contains(wrapper)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user