import { expect, fixture, html, elementUpdated } from '@open-wc/testing'; import './nile-rte-link'; import type { NileRteLink } from './nile-rte-link'; function createEditor(content = '

hello world

'): HTMLElement { const el = document.createElement('article'); el.setAttribute('contenteditable', 'true'); el.innerHTML = content; document.body.appendChild(el); return el; } function selectText(node: Node, start: number, end: number) { const sel = document.getSelection()!; sel.removeAllRanges(); const range = document.createRange(); const textNode = node.firstChild!; range.setStart(textNode, start); range.setEnd(textNode, end); sel.addRange(range); return range; } function collapseAt(node: Node, offset: number) { const sel = document.getSelection()!; sel.removeAllRanges(); const range = document.createRange(); const textNode = node.firstChild!; range.setStart(textNode, offset); range.collapse(true); sel.addRange(range); return range; } function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)); } describe('NileRteLink', () => { let editor: HTMLElement; afterEach(() => { editor?.remove(); }); // ── Rendering and Defaults ── it('1. renders the component', async () => { editor = createEditor(); const el = await fixture( html`` ); expect(el).to.exist; }); it('2. tag name is nile-rte-link', async () => { editor = createEditor(); const el = await fixture( html`` ); expect(el.tagName.toLowerCase()).to.equal('nile-rte-link'); }); it('3. default newTab is false', async () => { editor = createEditor(); const el = await fixture( html`` ); expect(el.newTab).to.be.false; }); it('4. default placeholder is "Type or paste link here"', async () => { editor = createEditor(); const el = await fixture( html`` ); expect(el.placeholder).to.equal('Type or paste link here'); }); it('5. default label is "Link"', async () => { editor = createEditor(); const el = await fixture( html`` ); expect(el.label).to.equal('Link'); }); it('6. renders a link icon button', async () => { editor = createEditor(); const el = await fixture( html`` ); const btn = el.querySelector('nile-button'); expect(btn).to.exist; const icon = el.querySelector('nile-icon'); expect(icon).to.exist; expect(icon!.getAttribute('name')).to.equal('link_2'); }); it('7. renders a popover element', async () => { editor = createEditor(); const el = await fixture( html`` ); const pop = el.querySelector('nile-popover'); expect(pop).to.exist; }); it('8. custom element is defined in registry', async () => { expect(customElements.get('nile-rte-link')).to.exist; }); // ── Property Binding ── it('9. set newTab to true', async () => { editor = createEditor(); const el = await fixture( html`` ); expect(el.newTab).to.be.true; }); it('10. set custom placeholder', async () => { editor = createEditor(); const el = await fixture( html`` ); expect(el.placeholder).to.equal('Enter URL'); }); it('11. set custom label', async () => { editor = createEditor(); const el = await fixture( html`` ); expect(el.label).to.equal('Add Link'); }); it('12. set editorEl reference', async () => { editor = createEditor(); const el = await fixture( html`` ); expect(el.editorEl).to.equal(editor); }); // ── URL Normalization ── it('13. plain domain gets https:// prepended', async () => { editor = createEditor(); const el = await fixture( html`` ); const normalize = (el as any).normalizeUrl.bind(el); expect(normalize('example.com')).to.equal('https://example.com'); }); it('14. http:// URL stays unchanged', async () => { editor = createEditor(); const el = await fixture( html`` ); const normalize = (el as any).normalizeUrl.bind(el); expect(normalize('http://example.com')).to.equal('http://example.com'); }); it('15. https:// URL stays unchanged', async () => { editor = createEditor(); const el = await fixture( html`` ); const normalize = (el as any).normalizeUrl.bind(el); expect(normalize('https://example.com')).to.equal('https://example.com'); }); it('16. mailto: URL stays unchanged', async () => { editor = createEditor(); const el = await fixture( html`` ); const normalize = (el as any).normalizeUrl.bind(el); expect(normalize('mailto:a@b.com')).to.equal('mailto:a@b.com'); }); it('17. tel: URL stays unchanged', async () => { editor = createEditor(); const el = await fixture( html`` ); const normalize = (el as any).normalizeUrl.bind(el); expect(normalize('tel:+123')).to.equal('tel:+123'); }); it('18. displayUrl returns URL as-is', async () => { editor = createEditor(); const el = await fixture( html`` ); const display = (el as any).displayUrl.bind(el); expect(display('https://example.com')).to.equal('https://example.com'); expect(display('http://example.com/path')).to.equal('http://example.com/path'); }); // ── Link Insertion (collapsed selection) ── it('19. inserts a new tag with URL as text when selection is collapsed', async () => { editor = createEditor('

text

'); const el = await fixture( html`` ); const p = editor.querySelector('p')!; collapseAt(p, 4); (el as any).selectionRange = document.getSelection()!.getRangeAt(0).cloneRange(); (el as any).linkValue = 'https://example.com'; (el as any).applyLink(); const a = editor.querySelector('a'); expect(a).to.exist; expect(a!.href).to.include('example.com'); expect(a!.textContent).to.include('example.com'); }); it('20. inserts link with target and rel when newTab is true', async () => { editor = createEditor('

text

'); const el = await fixture( html`` ); const p = editor.querySelector('p')!; collapseAt(p, 4); (el as any).selectionRange = document.getSelection()!.getRangeAt(0).cloneRange(); (el as any).linkValue = 'https://example.com'; (el as any).applyLink(); const a = editor.querySelector('a'); expect(a).to.exist; expect(a!.target).to.equal('_blank'); expect(a!.rel).to.equal('noopener noreferrer'); }); it('21. does not set target/rel when newTab is false', async () => { editor = createEditor('

text

'); const el = await fixture( html`` ); const p = editor.querySelector('p')!; collapseAt(p, 4); (el as any).selectionRange = document.getSelection()!.getRangeAt(0).cloneRange(); (el as any).linkValue = 'https://example.com'; (el as any).applyLink(); const a = editor.querySelector('a'); expect(a).to.exist; expect(a!.hasAttribute('target')).to.be.false; expect(a!.hasAttribute('rel')).to.be.false; }); // ── Link Insertion (text selected) ── it('22. wraps selected text in an
tag', async () => { editor = createEditor('

hello world

'); const el = await fixture( html`` ); const p = editor.querySelector('p')!; selectText(p, 0, 5); (el as any).selectionRange = document.getSelection()!.getRangeAt(0).cloneRange(); (el as any).linkValue = 'https://example.com'; (el as any).applyLink(); const a = editor.querySelector('a'); expect(a).to.exist; expect(a!.href).to.include('example.com'); }); it('23. preserves selected text content after wrapping', async () => { editor = createEditor('

hello world

'); const el = await fixture( html`` ); const p = editor.querySelector('p')!; selectText(p, 0, 5); (el as any).selectionRange = document.getSelection()!.getRangeAt(0).cloneRange(); (el as any).linkValue = 'https://example.com'; (el as any).applyLink(); const a = editor.querySelector('a'); expect(a!.textContent).to.equal('hello'); }); // ── Link Editing ── it('24. detects existing anchor and shows edit/unlink buttons', async () => { editor = createEditor('

link text

'); const el = await fixture( html`` ); const a = editor.querySelector('a')!; collapseAt(a, 2); (el as any).onOpen(); await elementUpdated(el); expect((el as any).hasActiveLink).to.be.true; expect((el as any).activeAnchor).to.equal(a); }); it('25. pre-fills input with existing link URL', async () => { editor = createEditor('

link

'); const el = await fixture( html`` ); const a = editor.querySelector('a')!; collapseAt(a, 2); (el as any).onOpen(); await elementUpdated(el); expect((el as any).linkValue).to.equal(a.href); }); it('26. updates existing anchor href on apply', async () => { editor = createEditor('

link

'); const el = await fixture( html`` ); const a = editor.querySelector('a')!; collapseAt(a, 2); (el as any).onOpen(); await elementUpdated(el); (el as any).linkValue = 'https://new.com'; (el as any).applyLink(); expect(a.href).to.include('new.com'); }); it('27. toggling newTab on edit updates target/rel attributes', async () => { editor = createEditor('

link

'); const el = await fixture( html`` ); const a = editor.querySelector('a')!; collapseAt(a, 2); (el as any).onOpen(); await elementUpdated(el); (el as any).linkValue = 'https://example.com'; (el as any).applyLink(); expect(a.target).to.equal('_blank'); expect(a.rel).to.equal('noopener noreferrer'); }); // ── Link Removal ── it('28. unlink removes the tag', async () => { editor = createEditor('

link text

'); const el = await fixture( html`` ); const a = editor.querySelector('a')!; collapseAt(a, 2); (el as any).onOpen(); await elementUpdated(el); (el as any).onRemove(); expect(editor.querySelector('a')).to.be.null; }); it('29. unlink preserves the text content', async () => { editor = createEditor('

link text

'); const el = await fixture( html`` ); const a = editor.querySelector('a')!; collapseAt(a, 2); (el as any).onOpen(); await elementUpdated(el); (el as any).onRemove(); expect(editor.querySelector('p')!.textContent).to.equal('link text'); }); it('30. closes popover after unlink', async () => { editor = createEditor('

link text

'); const el = await fixture( html`` ); const a = editor.querySelector('a')!; collapseAt(a, 2); (el as any).onOpen(); await elementUpdated(el); (el as any).onRemove(); expect((el as any).selectionRange).to.be.null; expect((el as any).hasActiveLink).to.be.false; }); // ── Popover Behavior ── it('31. opens popover on button click', async () => { editor = createEditor('

text

'); const el = await fixture( html`` ); const p = editor.querySelector('p')!; collapseAt(p, 2); (el as any).onOpen(); await sleep(50); const pop = el.querySelector('nile-popover') as any; expect(pop?.isShow).to.be.true; }); it('32. closes popover on Escape key', async () => { editor = createEditor('

text

'); const el = await fixture( html`` ); const p = editor.querySelector('p')!; collapseAt(p, 2); (el as any).onOpen(); await sleep(50); const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }); (el as any).onInputKeydown(event); expect((el as any).selectionRange).to.be.null; }); it('33. applies link on Enter key', async () => { editor = createEditor('

text

'); const el = await fixture( html`` ); const p = editor.querySelector('p')!; collapseAt(p, 4); (el as any).selectionRange = document.getSelection()!.getRangeAt(0).cloneRange(); (el as any).linkValue = 'https://example.com'; const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }); (el as any).onInputKeydown(event); const a = editor.querySelector('a'); expect(a).to.exist; }); it('34. closes popover on input blur', async () => { editor = createEditor('

text

'); const el = await fixture( html`` ); const p = editor.querySelector('p')!; collapseAt(p, 2); (el as any).onOpen(); await sleep(50); (el as any).ignoreBlur = false; (el as any).isApplying = false; (el as any).onInputBlur(); await sleep(50); expect((el as any).selectionRange).to.be.null; }); it('35. does not close on blur when ignoreBlur is set', async () => { editor = createEditor('

text

'); const el = await fixture( html`` ); const p = editor.querySelector('p')!; collapseAt(p, 2); (el as any).onOpen(); await sleep(50); (el as any).ignoreBlur = true; (el as any).onInputBlur(); expect((el as any).selectionRange).to.not.be.null; }); // ── Event Dispatching ── it('36. fires nile-link-changed with href on link apply', async () => { editor = createEditor('

text

'); const el = await fixture( html`` ); const p = editor.querySelector('p')!; collapseAt(p, 4); let detail: any = null; el.addEventListener('nile-link-changed', ((e: CustomEvent) => { detail = e.detail; }) as EventListener); (el as any).selectionRange = document.getSelection()!.getRangeAt(0).cloneRange(); (el as any).linkValue = 'https://example.com'; (el as any).applyLink(); expect(detail).to.exist; expect(detail.href).to.include('example.com'); }); it('37. fires nile-link-changed with empty href on unlink', async () => { editor = createEditor('

link

'); const el = await fixture( html`` ); const a = editor.querySelector('a')!; collapseAt(a, 2); let detail: any = null; el.addEventListener('nile-link-changed', ((e: CustomEvent) => { detail = e.detail; }) as EventListener); (el as any).onOpen(); await elementUpdated(el); (el as any).onRemove(); expect(detail).to.exist; expect(detail.href).to.equal(''); }); it('38. event bubbles and is composed', async () => { editor = createEditor('

text

'); const el = await fixture( html`` ); const p = editor.querySelector('p')!; collapseAt(p, 4); let bubbles = false; let composed = false; el.addEventListener('nile-link-changed', ((e: CustomEvent) => { bubbles = e.bubbles; composed = e.composed; }) as EventListener); (el as any).selectionRange = document.getSelection()!.getRangeAt(0).cloneRange(); (el as any).linkValue = 'https://example.com'; (el as any).applyLink(); expect(bubbles).to.be.true; expect(composed).to.be.true; }); // ── Validation ── it('39. does not apply empty URL', async () => { editor = createEditor('

text

'); const el = await fixture( html`` ); const p = editor.querySelector('p')!; collapseAt(p, 4); (el as any).selectionRange = document.getSelection()!.getRangeAt(0).cloneRange(); (el as any).linkValue = ''; (el as any).applyLink(); expect(editor.querySelector('a')).to.be.null; }); it('40. rejects unsupported protocols', async () => { editor = createEditor('

text

'); const el = await fixture( html`` ); const p = editor.querySelector('p')!; collapseAt(p, 4); (el as any).selectionRange = document.getSelection()!.getRangeAt(0).cloneRange(); (el as any).linkValue = 'javascript:alert(1)'; (el as any).applyLink(); expect(editor.querySelector('a')).to.be.null; }); // ── Integration with Parent RTE ── it('41. parent receives nile-link-changed event and updates content', async () => { editor = createEditor('

text

'); const container = await fixture(html`
`); const el = container.querySelector('nile-rte-link')! as NileRteLink; let received = false; container.addEventListener('nile-link-changed', () => { received = true; }); const p = editor.querySelector('p')!; collapseAt(p, 4); (el as any).selectionRange = document.getSelection()!.getRangeAt(0).cloneRange(); (el as any).linkValue = 'https://example.com'; (el as any).applyLink(); expect(received).to.be.true; }); it('42. toolbar link button shows active state when cursor is inside a link', async () => { editor = createEditor('

link text

'); const el = await fixture( html`` ); const a = editor.querySelector('a')!; collapseAt(a, 2); (el as any).onOpen(); await elementUpdated(el); expect((el as any).hasActiveLink).to.be.true; }); it('43. toolbar link button loses active state when cursor moves outside a link', async () => { editor = createEditor('

normal link text

'); const el = await fixture( html`` ); const p = editor.querySelector('p')!; const textNode = p.firstChild!; const sel = document.getSelection()!; sel.removeAllRanges(); const range = document.createRange(); range.setStart(textNode, 2); range.collapse(true); sel.addRange(range); (el as any).onOpen(); await elementUpdated(el); expect((el as any).hasActiveLink).to.be.false; expect((el as any).activeAnchor).to.be.null; }); // ── CSS Injection ── it('44. injects style tag with data-rte-link-style attribute', async () => { editor = createEditor(); const el = await fixture( html`` ); const styleTag = el.querySelector('style[data-rte-link-style]'); expect(styleTag).to.exist; }); it('45. does not inject duplicate styles on reconnect', async () => { editor = createEditor(); const el = await fixture( html`` ); const parent = el.parentElement!; parent.removeChild(el); parent.appendChild(el); await elementUpdated(el); const styleTags = el.querySelectorAll('style[data-rte-link-style]'); expect(styleTags.length).to.equal(1); }); });