coming together

This commit is contained in:
HotSwapp
2025-08-13 18:53:35 -05:00
parent acc5155bf7
commit 5111079149
51 changed files with 14457 additions and 588 deletions

View 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);
});
});

View 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('&lt;div&gt;');
expect(out).toContain('&amp;');
expect(out).toContain('&quot;');
expect(out).toContain('&#39;');
expect(esc('Tom & Jerry')).toBe('Tom &amp; 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('&lt;b&gt;');
expect(result).toMatch(/<strong>John<\/strong>/i);
expect(result).toMatch(/<strong>Smith<\/strong>/i);
// Ampersand must be escaped
expect(result).toContain('&amp; 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>');
});
});

View 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);
});
});