import '../../../dist/zn.min.js'; import {aTimeout, expect, fixture, html, waitUntil} from '@open-wc/testing'; import {EDITOR_ACTIONS, isApple} from './actions'; import {FEATURE_KEYS} from './feature-keys'; import {parse as remarkdParse} from 'remarkd-js'; import type ZnButton from '../button'; import type ZnDropdown from '../dropdown'; import type ZnRemarkdEditor from './remarkd-editor.component'; import type ZnSlashMenu from '../slash-menu'; /** Types into the block being edited, moving the caret to the end as a keystroke would. */ function typeInBlock(el: ZnRemarkdEditor, value: string): HTMLTextAreaElement { const input = el.shadowRoot!.querySelector('.remarkd-editor__input')!; input.value = value; input.setSelectionRange(value.length, value.length); input.dispatchEvent(new Event('input', {bubbles: true})); return input; } describe('', () => { it('should insert source that renders to real output for every action', async () => { // Rendered by the editor's own chrome rather than the parser, or needing surrounding // content to produce output — measured, not assumed: // attributes-title → `.Intro` alone is empty; a title decorates the block below it // reference-list → `{{reflist}}` is empty until something references it // asciidoc-section → `== ` alone is empty; the section needs a body the author types // hardbreaks → `[%hardbreaks]` alone is an attribute line; it needs the paragraph // below it (real fixture: attribute line directly followed by body, // no blank line) that the helper's `\n\n` placeholder trick can't reach const rendersEmpty = ['attributes-title', 'reference-list', 'asciidoc-section', 'hardbreaks']; // Logic actions all render through the editor's own chrome (a variable chip or a // conditional wrapper) rather than the parser — listed explicitly, not by group, so a // future logic action with no chrome of its own falls through and fails this guard. const rendersViaChrome = ['document-attributes', 'conditionals', 'ifndef', 'ifeval', 'iftrue', 'iffalse', 'ifempty', 'ifnempty']; const failures: string[] = []; const el = await fixture(html``); for (const action of EDITOR_ACTIONS) { if (action.opens || action.inline) continue; // The prefix must survive the editor's own block splitter intact — a delimiter pair // with a blank line inside (e.g. Quote's `____`) that splitBlocks does not recognise // fractures into several separately draggable/deletable blocks the instant it lands. if (action.prefix?.includes('\n')) { el.value = action.prefix; await el.updateComplete; const blocks = el.shadowRoot!.querySelectorAll('.remarkd-editor__block').length; if (blocks !== 1) failures.push(`${action.key}: split into ${blocks} blocks`); } if (rendersViaChrome.includes(action.key) || rendersEmpty.includes(action.key)) continue; const source = (action.prefix ?? '').replace(/\n\n/g, '\nplaceholder\n') || 'text'; const rendered = remarkdParse(source); if (rendered.includes('section--empty')) failures.push(`${action.key}: ${JSON.stringify(source)}`); } expect(failures, `actions rendering nothing: ${failures.join(', ')}`).to.be.empty; }); it('should fall back to the raw source when the parser throws', async () => { // The parser's only throw source is `readFileSync`, reached only when // `process.getBuiltinModule` resolves to a real `node:fs` — this harness's `process` shim // does not provide one, so force it to prove `safeParse`'s catch branch actually fires. const proc = window as unknown as {process?: {getBuiltinModule?: (name: string) => unknown}}; const original = proc.process; proc.process = { getBuiltinModule: () => ({ existsSync: () => true, readFileSync: () => { throw Object.assign(new Error('EISDIR: illegal operation on a directory, read'), {code: 'EISDIR'}); }, }), }; try { const el = await fixture(html` `); await el.updateComplete; const rendered = el.shadowRoot!.querySelector('.remarkd-editor__rendered')!; expect(rendered.querySelector('.remarkd-editor__unparsed')).to.exist; expect(rendered.textContent).to.contain('t::partial::'); expect(el.shadowRoot!.querySelectorAll('.remarkd-editor__block').length).to.equal(1); } finally { proc.process = original; } }); it('should not throw for any action, however incomplete', async () => { // In this harness `process.getBuiltinModule` is absent, so remarkd-js's own fallback // ("File not found") fires for `t::partial::` instead of throwing — either way the // block must render without crashing the editor. const el = await fixture(html` `); await el.updateComplete; const rendered = el.shadowRoot!.querySelector('.remarkd-editor__rendered')!; expect(rendered.textContent).to.contain('File not found'); expect(el.shadowRoot!.querySelectorAll('.remarkd-editor__block').length).to.equal(1); }); it('should have an action for every insertable remarkd feature', () => { // Fixtures that describe parser behaviour or combine other features, so they are not actions. const notActions = ['url-formatting-chars', 'inline-advanced', 'object-fallbacks', 'conditionals-advanced', 'smart-quotes', 'typographic-symbols', 'emoji-aliases', 'autolink']; // Real actions, deliberately not offered: they render fine in this editor's TS parser but // not in the Go renderer the user's app ships in production, tested by the user directly. // `id-block` is additionally broken in remarkd itself (its own fixture enshrines the leak). const unsupportedByProductionRenderer = ['id-block', 'table', 'pros-cons', 'accordion']; // Dropped by request: `image-alignment` duplicates the image block's own alignment control, // and comments/highlight are not wanted in the authoring surface. const withdrawn = ['image-alignment', 'comments', 'highlight']; const covered = new Set(EDITOR_ACTIONS.map(a => a.key)); const excluded = [...notActions, ...unsupportedByProductionRenderer, ...withdrawn]; const missing = FEATURE_KEYS.filter(f => !excluded.includes(f) && !covered.has(f)); expect(missing, `features with no action: ${missing.join(', ')}`).to.be.empty; }); it('should render a component', async () => { const el = await fixture(html` `); expect(el).to.exist; }); it('should split the value into rendered remarkd blocks', async () => { const el = await fixture(html` `); const blocks = el.shadowRoot!.querySelectorAll('.remarkd-editor__block'); expect(blocks.length).to.equal(2); expect(blocks[1].innerHTML).to.contain('hint-note'); }); it('should keep fenced content as a single block', async () => { const fenced = '```\n\ncode\n\n```'; const el = await fixture(html` `); expect(el.shadowRoot!.querySelectorAll('.remarkd-editor__block').length).to.equal(1); }); it('should keep a conditional range as a single block', async () => { const source = 'ifdef::flag[]\n\nInside the conditional\n\nendif::[]'; const el = await fixture(html` `); expect(el.shadowRoot!.querySelectorAll('.remarkd-editor__block').length).to.equal(1); expect(el.value).to.equal(source); }); it('should keep nested conditionals in the same block', async () => { const source = 'ifdef::outer[]\nifdef::inner[]\nDeep\nendif::[]\nendif::[]\n\nAfter'; const el = await fixture(html` `); const blocks = el.shadowRoot!.querySelectorAll('.remarkd-editor__block'); expect(blocks.length).to.equal(2); expect(el.value).to.equal(source); }); it('should leave an inline conditional as an ordinary block', async () => { const el = await fixture(html` `); expect(el.shadowRoot!.querySelectorAll('.remarkd-editor__block').length).to.equal(2); }); it('should label a conditional and still render its content', async () => { const el = await fixture(html` `); await el.updateComplete; const wrapper = el.shadowRoot!.querySelector('.remarkd-editor__conditional')!; expect(wrapper, 'no conditional wrapper').to.exist; expect(wrapper.textContent).to.contain('flag'); // The content must survive: evaluating would hide it, since `flag` is undefined. expect(wrapper.textContent).to.contain('Inner content'); }); it('should label a negative conditional differently', async () => { const el = await fixture(html` `); await el.updateComplete; const label = el.shadowRoot!.querySelector('.remarkd-editor__conditional-label')!; expect(label.textContent).to.contain('not defined'); }); it('should render a nested conditional as two nested labelled wrappers, without losing content', async () => { const el = await fixture(html` `); await el.updateComplete; const wrappers = el.shadowRoot!.querySelectorAll('.remarkd-editor__conditional'); expect(wrappers.length, 'expected an outer and an inner wrapper').to.equal(2); expect(wrappers[0].textContent).to.contain('outer'); expect(wrappers[1].textContent).to.contain('inner'); // parse() evaluates a directive handed to it verbatim, which is exactly how 'Deep' used // to get silently dropped: the unstripped `ifdef::inner[]` line parsed to an empty section. expect(wrappers[1].textContent).to.contain('Deep'); }); it('should render content before and after a nested conditional in the same wrapper', async () => { const el = await fixture(html` `); await el.updateComplete; const wrappers = el.shadowRoot!.querySelectorAll('.remarkd-editor__conditional'); expect(wrappers.length).to.equal(2); // Exercises the plain-content-run splitting on both sides of the nested range, not just // a single run either before or after it. expect(wrappers[0].textContent).to.contain('Before'); expect(wrappers[0].textContent).to.contain('Nested'); expect(wrappers[0].textContent).to.contain('After'); }); it('should not lose content from a conditional nested inside an unclosed range', async () => { const el = await fixture(html` `); await el.updateComplete; const wrappers = el.shadowRoot!.querySelectorAll('.remarkd-editor__conditional'); expect(wrappers.length, 'expected an outer and an inner wrapper').to.equal(2); expect(wrappers[0].textContent).to.contain('Before'); // Neither ifdef has a closing endif::[] anywhere in the source; the nested one must // still show its content rather than being evaluated (and blanked) as an unmatched directive. expect(wrappers[1].textContent).to.contain('Deep'); }); it('should mark and render each single-line conditional form, never evaluating it', async () => { // Measured against the real parser: iftrue/iffalse/ifnempty match no rule of its own and // render an empty section, and ifempty is genuinely evaluated — none of that may reach // the reader; the bracket text must render, visibly, inside a labelled wrapper instead. const cases: [string, string][] = [ ['iftrue::flag[Shown]', 'is true'], ['iffalse::flag[Shown]', 'is false'], ['ifempty::flag[Shown]', 'is empty'], ['ifnempty::flag[Shown]', 'is not empty'], ]; for (const [source, expectedLabel] of cases) { const el = await fixture(html` `); await el.updateComplete; const wrapper = el.shadowRoot!.querySelector('.remarkd-editor__conditional'); expect(wrapper, `${source}: no conditional wrapper`).to.exist; expect(wrapper!.textContent, source).to.contain('flag'); expect(wrapper!.textContent, source).to.contain(expectedLabel); expect(wrapper!.textContent, source).to.contain('Shown'); expect(el.value, source).to.equal(source); } }); it('should keep a table with blank rows as a single block', async () => { const table = '.Data\n[striped=true]\n|===\n|Name |Value\n\n|Alpha |1\n\n|Beta |2\n|==='; const el = await fixture(html` `); expect(el.shadowRoot!.querySelectorAll('.remarkd-editor__block').length).to.equal(1); expect(el.value).to.equal(table); // The rows must land in the body; a split table renders them as more headers. const rendered = el.shadowRoot!.querySelector('.remarkd-editor__rendered')!; expect(rendered.querySelectorAll('tbody tr').length).to.equal(2); expect(rendered.querySelectorAll('thead th').length).to.equal(2); }); it('should swap a block to source editing on click and commit on blur', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; const input = el.shadowRoot!.querySelector('.remarkd-editor__input')!; expect(input).to.exist; expect(input.value).to.equal('# Title'); input.value = '# Changed'; input.dispatchEvent(new Event('input', {bubbles: true})); input.dispatchEvent(new Event('blur')); await el.updateComplete; expect(el.value).to.equal('# Changed'); expect(el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.innerHTML).to.contain('Changed'); }); it('should remove a block committed as empty', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelectorAll('.remarkd-editor__rendered')[1].click(); await el.updateComplete; const input = el.shadowRoot!.querySelector('.remarkd-editor__input')!; input.value = ''; input.dispatchEvent(new Event('input', {bubbles: true})); input.dispatchEvent(new Event('blur')); await el.updateComplete; expect(el.value).to.equal('# Title'); expect(el.shadowRoot!.querySelectorAll('.remarkd-editor__block').length).to.equal(1); }); it('should render an always-visible toolbar', async () => { const el = await fixture(html` `); expect(el.shadowRoot!.querySelector('.remarkd-editor__toolbar')).to.exist; }); it('should group the toolbar buttons', async () => { const el = await fixture(html` `); const groups = el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar .toolbar__group'); expect(groups.length).to.be.greaterThan(5); expect([...groups].map(g => g.getAttribute('data-group'))).to.include('admonitions'); // Every non-picker action reachable from the toolbar. const buttons = el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar .toolbar__group zn-button'); expect(buttons.length).to.equal(EDITOR_ACTIONS.length); }); it('should move toolbar groups into an overflow menu when narrow', async () => { const el = await fixture(html` `); await el.updateComplete; await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__toolbar-more'), 'the overflow trigger never appeared'); const collapsed = [...el.shadowRoot!.querySelectorAll('.toolbar__group')] .filter(group => getComputedStyle(group).display === 'none'); expect(collapsed.length, 'nothing collapsed at 240px').to.be.greaterThan(0); // One top-level entry per collapsed group, each holding that group's actions. const groupItems = el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar-more > zn-menu > zn-menu-item'); expect(groupItems.length).to.equal(collapsed.length); const menuItems = el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar-more zn-menu[slot="submenu"] zn-menu-item'); expect(menuItems.length).to.be.greaterThan(0); // The trigger itself must stay on-screen — a group that doesn't fit must not force its // way into the bar and push the trigger past the host's own `overflow: hidden` edge. const toolbar = el.shadowRoot!.querySelector('.remarkd-editor__toolbar')!; const trigger = el.shadowRoot!.querySelector('.remarkd-editor__toolbar-more')!; expect(trigger.getBoundingClientRect().right, 'the overflow trigger is clipped off-screen') .to.be.at.most(toolbar.getBoundingClientRect().right + 1); // Every action is reachable somewhere: either as a visible bar button or a menu item. const reachableButtons = [...el.shadowRoot!.querySelectorAll('.toolbar__group')] .filter(group => getComputedStyle(group).display !== 'none') .flatMap(group => [...group.querySelectorAll('zn-button')]); const expectedCount = EDITOR_ACTIONS.filter(action => action.opens !== 'include' && action.opens !== 'link').length; expect(reachableButtons.length + menuItems.length, 'an action fell through the cracks') .to.equal(expectedCount); }); it('should cap each overflow submenu so it scrolls instead of running off the viewport', async () => { const el = await fixture(html` `); await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__toolbar-more'), 'the overflow trigger never appeared'); const dropdown = el.shadowRoot!.querySelector('.remarkd-editor__toolbar-more')!; await dropdown.show(); // The popup's auto-size pass lands an animation frame after 'zn-after-show' fires — same // wait used for the same reason in expanding-action's drop-panel overflow tests. await new Promise(resolve => requestAnimationFrame(resolve)); // Inline is the longest group, so its submenu is the one that can outgrow the viewport. // The cap lands on zn-menu's inner `.menu` div (via --zn-menu-max-height), not the host: // a constraint on the host leaves `.menu` with nothing to scroll. const inlineItem = [...dropdown.querySelectorAll(':scope > zn-menu > zn-menu-item')] .find(item => item.textContent?.includes('Strong'))!; const menu = inlineItem.querySelector('zn-menu[slot="submenu"]')!; const inner = menu.shadowRoot!.querySelector('.menu')!; expect(getComputedStyle(inner).maxHeight).to.not.equal('none'); expect(getComputedStyle(inner).overflow).to.equal('auto'); }); it('should apply an inline action reached through the overflow menu, keeping the block open', async () => { // At 800px, 'text' (the first group) fits but 'inline' (~638px right after it) does // not, so every inline action — including Strong — lives only in the overflow menu. const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; const input = el.shadowRoot!.querySelector('.remarkd-editor__input')!; input.setSelectionRange(0, 5); await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__toolbar-more'), 'the overflow trigger never appeared'); const dropdown = el.shadowRoot!.querySelector('.remarkd-editor__toolbar-more')!; await dropdown.show(); await new Promise(resolve => requestAnimationFrame(resolve)); // Opening the trigger moves focus off the textarea in real use — zn-dropdown's // handleTriggerClick calls focusOnTrigger() right after show(). A synthetic click on the // trigger wouldn't actually move focus in a test DOM, so the blur is reproduced directly. input.dispatchEvent(new Event('blur')); const strong = [...dropdown.querySelectorAll('zn-menu[slot="submenu"] zn-menu-item')] .find(item => item.textContent?.includes('Strong')); expect(strong, 'Strong should be reachable through the overflow menu').to.exist; strong!.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true})); await el.updateComplete; // If the blur had committed the edit, editingIndex would be null: the menu item would // read as disabled and applyInline would find no textarea to act on. const result = el.shadowRoot!.querySelector('.remarkd-editor__input')!; expect(result.value, 'the block must stay open through the overflow-menu interaction') .to.equal('**hello** world'); }); it('should show every toolbar group when wide', async () => { // The full toolbar (all ~70 actions) measures ~3100px, so 2000px would still collapse // some groups; this width comfortably fits everything with room to spare. const el = await fixture(html` `); await el.updateComplete; await aTimeout(50); const collapsed = [...el.shadowRoot!.querySelectorAll('.toolbar__group')] .filter(group => getComputedStyle(group).display === 'none'); expect(collapsed.length).to.equal(0); expect(el.shadowRoot!.querySelector('.remarkd-editor__toolbar-more')).to.not.exist; }); /** Every icon-only button needs a name of its own — it has no text for a screen reader to read. */ async function expectNamedIconButtons(el: ZnRemarkdEditor, root: ParentNode = el.shadowRoot!) { const iconOnly = [...root.querySelectorAll('zn-button')].filter(b => !b.textContent!.trim()); expect(iconOnly.length).to.be.greaterThan(0); for (const button of iconOnly) { await button.updateComplete; const name = button.shadowRoot!.querySelector('button')!.getAttribute('aria-label'); expect(name, `the ${button.getAttribute('icon')} button has no accessible name`) .to.be.a('string').and.not.empty; } } it('should give every toolbar icon button an accessible name', async () => { const el = await fixture(html` `); const toolbar = el.shadowRoot!.querySelector('.remarkd-editor__toolbar')!; expect(toolbar.querySelectorAll('zn-button').length).to.be.greaterThan(1); await expectNamedIconButtons(el, toolbar); // color-contrast is a pre-existing failure on the placeholder's shared // --zn-color-neutral-400 token, unrelated to the buttons under test. await expect(el).to.be.accessible({ignoredRules: ['color-contrast']}); }); it('should give every block action icon button an accessible name', async () => { const el = await fixture(html` `); await expectNamedIconButtons(el, el.shadowRoot!.querySelector('.remarkd-editor__block')!); }); it('should wrap the selection with an inline action', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; const input = el.shadowRoot!.querySelector('.remarkd-editor__input')!; input.setSelectionRange(0, 5); const strong = [...el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button')] .find(b => b.getAttribute('label') === 'Strong')!; strong.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true})); await el.updateComplete; const result = el.shadowRoot!.querySelector('.remarkd-editor__input')!; expect(result.value).to.equal('**hello** world'); // The wrapped text stays selected, so a second click (or typing) acts on it, not the marks. expect([result.selectionStart, result.selectionEnd]).to.eql([2, 7]); }); describe('keyboard shortcuts', () => { const apple = isApple(); const press = (input: HTMLTextAreaElement, key: string, init: KeyboardEventInit = {}) => { const event = new KeyboardEvent('keydown', {key, bubbles: true, composed: true, cancelable: true, ...init}); input.dispatchEvent(event); return event; }; const mod = (init: KeyboardEventInit = {}): KeyboardEventInit => apple ? {metaKey: true, ...init} : {ctrlKey: true, ...init}; async function editing(value: string, start: number, end: number) { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; const input = el.shadowRoot!.querySelector('.remarkd-editor__input')!; input.setSelectionRange(start, end); return {el, input}; } it('should bold the selection with the platform modifier + B', async () => { const {el, input} = await editing('hello world', 0, 5); const event = press(input, 'b', mod()); await el.updateComplete; expect(event.defaultPrevented).to.be.true; expect(input.value).to.equal('**hello** world'); }); it('should toggle the mark back off on a second press', async () => { const {el, input} = await editing('hello world', 0, 5); press(input, 'b', mod()); await el.updateComplete; press(input, 'b', mod()); await el.updateComplete; expect(input.value).to.equal('hello world'); }); it('should map shift shortcuts, e.g. strikethrough', async () => { const {el, input} = await editing('hello world', 0, 5); press(input, 'X', mod({shiftKey: true})); await el.updateComplete; expect(input.value).to.equal('~~hello~~ world'); }); it('should ignore the other platform\'s modifier', async () => { const {el, input} = await editing('hello world', 0, 5); const event = press(input, 'b', apple ? {ctrlKey: true} : {metaKey: true}); await el.updateComplete; expect(event.defaultPrevented).to.be.false; expect(input.value).to.equal('hello world'); }); it('should ignore AltGr (Ctrl+Alt) combinations', async () => { const {el, input} = await editing('hello world', 0, 5); press(input, 'b', mod({altKey: true})); await el.updateComplete; expect(input.value).to.equal('hello world'); }); it('should show the shortcut in the toolbar tooltip', async () => { const el = await fixture(html``); const strong = [...el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button')] .find(b => b.getAttribute('label') === 'Strong')!; expect(strong.getAttribute('tooltip')).to.equal(apple ? 'Strong (⌘B)' : 'Strong (Ctrl+B)'); }); }); describe('list continuation', () => { const enter = (input: HTMLTextAreaElement) => { const event = new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, composed: true, cancelable: true}); input.dispatchEvent(event); return event; }; it('should continue a numbered list on Enter in the open block', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; const input = el.shadowRoot!.querySelector('.remarkd-editor__input')!; input.setSelectionRange(8, 8); let inputs = 0; el.addEventListener('zn-input', () => inputs++); expect(enter(input).defaultPrevented).to.be.true; await el.updateComplete; expect(input.value).to.equal('1. hello\n2. '); expect(input.selectionStart).to.equal(12); expect(inputs).to.equal(1); }); it('should leave Enter as a plain newline outside a list', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; const input = el.shadowRoot!.querySelector('.remarkd-editor__input')!; input.setSelectionRange(5, 5); expect(enter(input).defaultPrevented).to.be.false; }); /** Opens the first block and replaces its draft with `draft`, caret at the end. */ async function editDraft(draft: string) { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; return {el, input: typeInBlock(el, draft)}; } it('should finish the block on Enter at an empty trailing list item', async () => { const {el, input} = await editDraft('- a\n- '); expect(enter(input).defaultPrevented).to.be.true; await el.updateComplete; expect(el.value).to.equal('- a'); const draft = el.shadowRoot!.querySelector('.remarkd-editor__input')!; expect(draft.value, 'a fresh block is opened below').to.equal(''); }); it('should finish the block on a double Enter after a paragraph', async () => { const {el, input} = await editDraft('hello\n'); expect(enter(input).defaultPrevented).to.be.true; await el.updateComplete; expect(el.value).to.equal('hello'); expect(el.shadowRoot!.querySelector('.remarkd-editor__input')!.value).to.equal(''); }); it('should keep a blank line inside an open container', async () => { const {input} = await editDraft('```\ncode\n'); expect(enter(input).defaultPrevented).to.be.false; }); it('should clear a mid-list empty item without finishing the block', async () => { const {el, input} = await editDraft('- a\n- \n- b'); input.setSelectionRange(6, 6); expect(enter(input).defaultPrevented).to.be.true; await el.updateComplete; expect(input.value).to.equal('- a\n\n- b'); }); it('should continue a list on Enter in the raw source view', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__raw-toggle')! .dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true})); await el.updateComplete; const raw = el.shadowRoot!.querySelector('.remarkd-editor__raw')!; raw.setSelectionRange(11, 11); expect(enter(raw).defaultPrevented).to.be.true; await el.updateComplete; expect(el.value).to.equal('- [ ] first\n- [ ] '); }); const tab = (input: HTMLTextAreaElement, shiftKey = false) => { const event = new KeyboardEvent('keydown', {key: 'Tab', shiftKey, bubbles: true, composed: true, cancelable: true}); input.dispatchEvent(event); return event; }; it('should nest and un-nest a list item on Tab and Shift+Tab in the open block', async () => { const {el, input} = await editDraft('- a\n- b'); expect(tab(input).defaultPrevented).to.be.true; await el.updateComplete; expect(input.value).to.equal('- a\n-- b'); expect(input.selectionStart).to.equal(8); expect(tab(input, true).defaultPrevented).to.be.true; await el.updateComplete; expect(input.value).to.equal('- a\n- b'); }); it('should leave Tab alone outside a list', async () => { const {input} = await editDraft('hello'); expect(tab(input).defaultPrevented).to.be.false; }); it('should nest a list item on Tab in the raw source view', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__raw-toggle')! .dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true})); await el.updateComplete; const raw = el.shadowRoot!.querySelector('.remarkd-editor__raw')!; raw.setSelectionRange(7, 7); expect(tab(raw).defaultPrevented).to.be.true; await el.updateComplete; expect(el.value).to.equal('-- first'); }); }); describe('paste', () => { const paste = (input: HTMLTextAreaElement, data: Record) => { const clipboardData = new DataTransfer(); for (const [type, value] of Object.entries(data)) clipboardData.setData(type, value); const event = new ClipboardEvent('paste', {clipboardData, bubbles: true, composed: true, cancelable: true}); input.dispatchEvent(event); return event; }; it('should paste rich content as remarkd source at the caret', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; const input = typeInBlock(el, 'Intro\n'); input.focus(); const event = paste(input, {'text/html': '
  1. a
    1. b
', 'text/plain': 'a\nb'}); expect(event.defaultPrevented).to.be.true; await el.updateComplete; expect(input.value).to.equal('Intro\n1. a\n1.1. b'); }); it('should leave a plain-text paste to the browser', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; const input = el.shadowRoot!.querySelector('.remarkd-editor__input')!; expect(paste(input, {'text/plain': 'just text'}).defaultPrevented).to.be.false; }); it('should paste rich content in the raw source view', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__raw-toggle')! .dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true})); await el.updateComplete; const raw = el.shadowRoot!.querySelector('.remarkd-editor__raw')!; raw.focus(); expect(paste(raw, {'text/html': '
  • a
'}).defaultPrevented).to.be.true; await el.updateComplete; expect(el.value).to.equal('- a'); }); }); it('should insert a placeholder when nothing is selected', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; const input = el.shadowRoot!.querySelector('.remarkd-editor__input')!; input.setSelectionRange(5, 5); [...el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button')] .find(b => b.getAttribute('label') === 'Strong')! .dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true})); await el.updateComplete; const result = el.shadowRoot!.querySelector('.remarkd-editor__input')!; expect(result.value).to.equal('hello**text**'); // Placeholder selected so typing replaces it. expect([result.selectionStart, result.selectionEnd]).to.eql([7, 11]); }); it('should unwrap a selection already carrying the mark', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; const input = el.shadowRoot!.querySelector('.remarkd-editor__input')!; input.setSelectionRange(2, 7); // hello, inside the marks [...el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button')] .find(b => b.getAttribute('label') === 'Strong')! .dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true})); await el.updateComplete; expect(el.shadowRoot!.querySelector('.remarkd-editor__input')!.value) .to.equal('hello world'); }); it('should not corrupt a shorter mark nested inside a longer sibling of the same character', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; const input = el.shadowRoot!.querySelector('.remarkd-editor__input')!; input.setSelectionRange(2, 7); // hello, inside the strike marks [...el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button')] .find(b => b.getAttribute('label') === 'Subscript')! .dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true})); await el.updateComplete; // Ambiguous remarkd, but the strike marks must survive intact rather than being stripped. expect(el.shadowRoot!.querySelector('.remarkd-editor__input')!.value) .to.equal('~~~hello~~~ world'); }); it('should disable inline actions when no block is being edited', async () => { const el = await fixture(html` `); const strong = [...el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button')] .find(b => b.getAttribute('label') === 'Strong')!; expect(strong.hasAttribute('disabled')).to.be.true; const link = [...el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button')] .find(b => b.getAttribute('label') === 'Link to article')!; expect(link.hasAttribute('disabled')).to.be.true; }); it('should toggle off an asymmetric mark instead of double-wrapping it', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; const input = el.shadowRoot!.querySelector('.remarkd-editor__input')!; input.setSelectionRange(2, 7); // Label, inside the link markup [...el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button')] .find(b => b.getAttribute('label') === 'Link')! .dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true})); await el.updateComplete; // A `)` trailing the mark must not be mistaken for a repeated-delimiter run; the mark // toggles off cleanly rather than double-wrapping. expect(el.shadowRoot!.querySelector('.remarkd-editor__input')!.value) .to.equal('(Label)'); }); it('should unwrap a link whose target is a real URL, not just the placeholder tail', async () => { // A literal-text unwrap check only recognises "](https://)" — the placeholder itself. A // real document never contains that; it contains a real URL, which must unwrap too. const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; const input = el.shadowRoot!.querySelector('.remarkd-editor__input')!; input.setSelectionRange(1, 6); // Label, inside the real link markup [...el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button')] .find(b => b.getAttribute('label') === 'Link')! .dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true})); await el.updateComplete; expect(el.shadowRoot!.querySelector('.remarkd-editor__input')!.value) .to.equal('Label'); }); it('should honour a block action\'s caretOffset from the toolbar, matching the slash menu', async () => { const el = await fixture(html` `); // Two actions share the "Code" tooltip (inline-code in the inline group, code-fence in // the blocks group) — disambiguate by the group the button actually lives in. const codeButton = [...el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button')] .find(b => b.getAttribute('label') === 'Code' && b.closest('.toolbar__group')?.getAttribute('data-group') === 'blocks')!; codeButton.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true})); await el.updateComplete; const input = el.shadowRoot!.querySelector('.remarkd-editor__input')!; expect(input.value).to.equal('```\n\n```'); // caretOffset: 4 — right on the blank interior line, same as inserting "/code" would. expect([input.selectionStart, input.selectionEnd]).to.eql([4, 4]); }); it('should prevent the toolbar mousedown default so a real click keeps focus and selection in the textarea', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; const strong = [...el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button')] .find(b => b.getAttribute('label') === 'Strong')!; const mousedown = new MouseEvent('mousedown', {bubbles: true, composed: true, cancelable: true}); strong.dispatchEvent(mousedown); // If this were not prevented, the mousedown would shift focus off the textarea, blurring // and committing the edit before the click even fires — disabling the button underneath it. expect(mousedown.defaultPrevented).to.be.true; }); it('should give every image control icon button an accessible name', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; await expectNamedIconButtons(el, el.shadowRoot!.querySelector('.remarkd-editor__image-controls')!); }); /** A document tall enough to overflow the editor's max height. */ const longValue = Array.from({length: 60}, (_, i) => `Paragraph ${i}`).join('\n\n'); it('should not grow taller than the viewport', async () => { const el = await fixture(html` `); expect(el.getBoundingClientRect().height).to.be.at.most(window.innerHeight + 1); }); /** Adds a block below the middle block via its gutter button. */ async function addBlockMidDocument(el: ZnRemarkdEditor) { const blocks = el.shadowRoot!.querySelectorAll('.remarkd-editor__block'); blocks[Math.floor(blocks.length / 2)].querySelector('.remarkd-editor__actions zn-button')! .dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true})); await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__input'), 'no block opened'); } it('should centre a newly added block in its own body', async () => { const el = await fixture(html` `); const body = el.shadowRoot!.querySelector('.remarkd-editor__body')!; await addBlockMidDocument(el); const bodyBox = body.getBoundingClientRect(); const inputBox = el.shadowRoot!.querySelector('.remarkd-editor__input')!.getBoundingClientRect(); expect(body.scrollTop, 'the body never scrolled').to.be.greaterThan(0); expect(Math.abs((inputBox.top - bodyBox.top) - (bodyBox.bottom - inputBox.bottom)), 'the new block is not centred').to.be.lessThan(60); }); it('should not scroll an enclosing panel when a block is added', async () => { // An `overflow: hidden` panel has no scrollbar but is still scrollable in code, so // scrollIntoView() or a plain focus() would shift it with no way to scroll back. const panel = await fixture(html`
`); const el = panel.querySelector('zn-remarkd-editor')!; await el.updateComplete; const body = el.shadowRoot!.querySelector('.remarkd-editor__body')!; await addBlockMidDocument(el); expect(body.scrollTop, 'the editor body should still scroll').to.be.greaterThan(0); expect(panel.scrollTop, 'the enclosing panel was scrolled').to.equal(0); }); it('should not scroll an enclosing scroller when an image block is added', async () => { const panel = await fixture(html`
`); const el = panel.querySelector('zn-remarkd-editor')!; await el.updateComplete; const body = el.shadowRoot!.querySelector('.remarkd-editor__body')!; expect(panel.scrollHeight - panel.clientHeight, 'the panel needs room to scroll') .to.be.greaterThan(0); // A 1x1 gif: an image block whose height only lands once it loads. const src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; el.value = `${longValue}\n\nimage::${src}[pixel]`; await el.updateComplete; await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__block img'), 'no image block'); expect(panel.scrollTop, 'the enclosing scroller was scrolled').to.equal(0); expect(body.scrollTop, 'the body should be scrollable').to.be.at.least(0); }); it('should keep scrolling while a drag is held at the edge', async () => { const el = await fixture(html` `); const body = el.shadowRoot!.querySelector('.remarkd-editor__body')!; const handle = el.shadowRoot!.querySelectorAll('.remarkd-editor__block')[1] .querySelector('.remarkd-editor__drag-handle')!; const from = handle.getBoundingClientRect(); const drag = (type: string, clientY: number, target: EventTarget, buttons = 1) => target.dispatchEvent(new PointerEvent(type, { bubbles: true, composed: true, cancelable: true, clientX: Math.round(from.left + 5), clientY, button: 0, buttons, })); drag('pointerdown', Math.round(from.top + 5), handle); drag('pointermove', Math.round(from.top + 20), document); expect(el.shadowRoot!.querySelector('.remarkd-editor__ghost'), 'the drag never started').to.exist; // Park the pointer in the bottom edge zone — the frame loop scrolls without further moves. drag('pointermove', Math.round(body.getBoundingClientRect().bottom - 20), document); await waitUntil(() => body.scrollTop > 0, 'a drag held at the edge never scrolled'); drag('pointercancel', 0, document, 0); const settled = body.scrollTop; await new Promise(resolve => setTimeout(resolve, 100)); expect(body.scrollTop, 'scrolling continued after the drag ended').to.equal(settled); }); it('should open zn-slash-menu with the block types when "/" starts an empty block', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; typeInBlock(el, '/'); await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened'); const menu = el.shadowRoot!.querySelector('zn-slash-menu')!; expect(menu.items.map(item => item.label)).to.contain('Heading 1'); expect(menu.activeItem?.label).to.equal('Text'); }); it('should list every action in the slash menu under its group', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; typeInBlock(el, '/'); await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened'); const menu = el.shadowRoot!.querySelector('zn-slash-menu')!; expect(menu.items.length).to.equal(EDITOR_ACTIONS.length); expect(menu.items.some(item => item.group === 'Alerts')).to.be.true; }); it('should omit the Include action from the slash menu without an include-url', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; typeInBlock(el, '/'); await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened'); const menu = el.shadowRoot!.querySelector('zn-slash-menu')!; expect(menu.items.length).to.equal(EDITOR_ACTIONS.length - 1); expect(menu.items.every(item => item.action !== 'include')).to.be.true; }); it('should omit the Image action from the slash menu without an attachment-url', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; typeInBlock(el, '/'); await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened'); const menu = el.shadowRoot!.querySelector('zn-slash-menu')!; expect(menu.items.every(item => item.action !== 'image')).to.be.true; }); it('should omit the article link action without a link-url', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; typeInBlock(el, '/'); await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened'); const menu = el.shadowRoot!.querySelector('zn-slash-menu')!; expect(menu.items.length).to.equal(EDITOR_ACTIONS.length - 1); expect(menu.items.every(item => item.action !== 'link')).to.be.true; }); it('should offer the article link action with a link-url', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; typeInBlock(el, '/'); await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened'); const menu = el.shadowRoot!.querySelector('zn-slash-menu')!; expect(menu.items.length).to.equal(EDITOR_ACTIONS.length); expect(menu.items.some(item => item.action === 'link' && item.label === 'Link to article')).to.be.true; }); it('should not open the slash menu part-way through a block', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; typeInBlock(el, 'Hello /'); await el.updateComplete; await new Promise(resolve => requestAnimationFrame(resolve)); expect(el.shadowRoot!.querySelector('zn-slash-menu[open]')).to.not.exist; }); it('should insert the chosen block prefix over the slash command', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; const input = typeInBlock(el, '/note'); await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened'); const menu = el.shadowRoot!.querySelector('zn-slash-menu')!; expect(menu.activeItem?.label).to.equal('Note'); input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true})); await el.updateComplete; expect(input.value).to.equal('NOTE: '); expect(el.shadowRoot!.querySelector('zn-slash-menu[open]')).to.not.exist; }); it('should insert the full inline construct from the slash menu, not just the opener', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; const input = typeInBlock(el, '/strong'); await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened'); const menu = el.shadowRoot!.querySelector('zn-slash-menu')!; expect(menu.activeItem?.label).to.equal('Strong'); input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true})); await el.updateComplete; // Full construct, not a bare unclosed opener — caret lands at the placeholder's start. expect(input.value).to.equal('**text**'); expect([input.selectionStart, input.selectionEnd]).to.eql([2, 2]); }); it('should start a new block on shift+enter', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; const input = el.shadowRoot!.querySelector('.remarkd-editor__input')!; input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', shiftKey: true, bubbles: true})); await el.updateComplete; // First block committed and rendered; a fresh draft textarea sits below it. expect(el.shadowRoot!.querySelectorAll('.remarkd-editor__block').length).to.equal(1); const draft = el.shadowRoot!.querySelector('.remarkd-editor__input')!; expect(draft).to.exist; expect(draft.value).to.equal(''); }); it('should swap the slash command for the image picker when Image is chosen', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; const input = typeInBlock(el, '/image'); await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened'); input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true})); await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__image-picker'), 'the image picker never opened'); // The "/image" draft is dropped rather than committed as a block. expect(el.value).to.equal(''); expect(el.shadowRoot!.querySelector('.remarkd-editor__input')).to.not.exist; }); it('should show an inline zn-file picker from the toolbar image button', async () => { const el = await fixture(html` `); const imageButton = el.shadowRoot!.querySelector('.remarkd-editor__toolbar zn-button[label="Image"]')!; imageButton.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true})); await el.updateComplete; expect(el.shadowRoot!.querySelector('.remarkd-editor__body .remarkd-editor__image-picker')).to.exist; expect(el.shadowRoot!.querySelector('.remarkd-editor__image-file')).to.exist; }); it('should toggle a checkbox in the source instead of opening the editor', async () => { const el = await fixture(html` `); const boxes = el.shadowRoot!.querySelectorAll('input[type="checkbox"]'); boxes[0].dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true})); await el.updateComplete; expect(el.value).to.equal('- [x] First\n- [x] Second'); expect(el.shadowRoot!.querySelector('.remarkd-editor__input')).to.not.exist; }); it('should open image controls for an image block and save caption/align/size', async () => { const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; const panel = el.shadowRoot!.querySelector('.remarkd-editor__image-controls')!; expect(panel).to.exist; expect(el.shadowRoot!.querySelector('.remarkd-editor__input')).to.not.exist; const caption = panel.querySelector('.remarkd-editor__image-field input')!; caption.value = 'A caption'; caption.dispatchEvent(new Event('input', {bubbles: true})); await el.updateComplete; const buttons = el.shadowRoot!.querySelectorAll('.remarkd-editor__image-buttons zn-button'); buttons[0].dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true})); await el.updateComplete; expect(el.value).to.equal('.A caption\nimage::photo.png[Alt,640,480]'); expect(el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.innerHTML).to.contain('A caption'); }); it('should delete a block from its hover delete button', async () => { const el = await fixture(html` `); const deleteButton = el.shadowRoot!.querySelectorAll('.remarkd-editor__delete')[0]; deleteButton.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true})); await el.updateComplete; expect(el.value).to.equal('Second'); expect(el.shadowRoot!.querySelectorAll('.remarkd-editor__block').length).to.equal(1); }); it('should only show the raw toggle when allow-raw is set', async () => { const plain = await fixture(html` `); expect(plain.shadowRoot!.querySelector('.remarkd-editor__raw-toggle')).to.not.exist; const el = await fixture(html` `); expect(el.shadowRoot!.querySelector('.remarkd-editor__raw-toggle')).to.exist; }); it('should swap the blocks for a full-source textarea and commit edits on toggle back', async () => { const el = await fixture(html` `); let changes = 0; el.addEventListener('zn-change', () => { changes++; }); const toggle = el.shadowRoot!.querySelector('.remarkd-editor__raw-toggle')!; toggle.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true})); await el.updateComplete; const raw = el.shadowRoot!.querySelector('.remarkd-editor__raw')!; expect(raw).to.exist; expect(raw.value).to.equal('# Title\n\nSecond'); expect(el.shadowRoot!.querySelectorAll('.remarkd-editor__block').length).to.equal(0); raw.value = '# Changed\n\nSecond\n\nThird'; raw.dispatchEvent(new Event('input', {bubbles: true})); await el.updateComplete; expect(el.value).to.equal('# Changed\n\nSecond\n\nThird'); el.shadowRoot!.querySelector('.remarkd-editor__raw-toggle')! .dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true})); await el.updateComplete; const blocks = el.shadowRoot!.querySelectorAll('.remarkd-editor__block'); expect(blocks.length).to.equal(3); expect(blocks[0].innerHTML).to.contain('Changed'); expect(changes).to.be.greaterThan(0); }); it('should emit zn-change when a block edit changes the value', async () => { const el = await fixture(html` `); let fired = false; el.addEventListener('zn-change', () => { fired = true; }); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; const input = el.shadowRoot!.querySelector('.remarkd-editor__input')!; input.value = 'Hello world'; input.dispatchEvent(new Event('input', {bubbles: true})); input.dispatchEvent(new Event('blur')); await el.updateComplete; expect(fired).to.be.true; }); it('should keep an include directive in a block of its own', async () => { const el = await fixture(html` `); const blocks = el.shadowRoot!.querySelectorAll('.remarkd-editor__block'); expect(blocks.length).to.equal(3); expect(blocks[1].querySelector('.remarkd-editor__include-title')!.textContent).to.contain('Payment Terms'); expect(blocks[0].querySelector('.remarkd-editor__include')).to.not.exist; }); it('should leave an include directive inside a fence alone', async () => { const fenced = '```\ninclude::inc-1[Payment Terms]\n```'; const el = await fixture(html` `); expect(el.shadowRoot!.querySelectorAll('.remarkd-editor__block').length).to.equal(1); expect(el.shadowRoot!.querySelector('.remarkd-editor__include')).to.not.exist; }); it('should label an include chip from the marker when no list is configured', async () => { const el = await fixture(html` `); const chip = el.shadowRoot!.querySelector('.remarkd-editor__include')!; expect(chip.querySelector('.remarkd-editor__include-title')!.textContent).to.contain('Payment Terms'); expect(chip.classList.contains('remarkd-editor__include--missing')).to.be.false; }); // Ordering an embed within the content is the whole point of the chip: the // block chrome's drag handle has to move it like any other block. it('should reorder an include block by dragging its handle', async () => { const el = await fixture(html` `); const first = el.shadowRoot!.querySelectorAll('.remarkd-editor__block')[0].getBoundingClientRect(); const handle = el.shadowRoot!.querySelectorAll('.remarkd-editor__drag-handle')[1]; const target = first.top + 1; handle.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true, button: 0, buttons: 1, clientX: 0, clientY: 0})); document.dispatchEvent(new PointerEvent('pointermove', {bubbles: true, buttons: 1, clientX: 0, clientY: target})); document.dispatchEvent(new PointerEvent('pointerup', {bubbles: true, buttons: 0, clientX: 0, clientY: target})); await el.updateComplete; expect(el.value).to.equal('include::inc-1[Payment Terms]\n\nIntro'); }); /** Serves one include list to the editor, and restores fetch when the test ends. */ function stubIncludeList(items: unknown[]): void { const original = window.fetch; window.fetch = () => Promise.resolve(new Response(JSON.stringify({items}), {headers: {'Content-Type': 'application/json'}})); afterEach(() => { window.fetch = original; }); } it('should label an include chip from the fetched list', async () => { stubIncludeList([{id: 'inc-1', title: 'Refund Policy', scope: 'Global', languages: 'English', url: '/kb/kb1/includes/inc-1'}]); const el = await fixture(html` `); await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include-link'), 'the include list never resolved'); const chip = el.shadowRoot!.querySelector('.remarkd-editor__include')!; expect(chip.querySelector('.remarkd-editor__include-title')!.textContent).to.contain('Refund Policy'); expect(chip.querySelector('.remarkd-editor__include-scope')!.textContent).to.contain('Global'); expect(chip.querySelector('.remarkd-editor__include-link')!.getAttribute('href')) .to.equal('/kb/kb1/includes/inc-1'); expect(chip.classList.contains('remarkd-editor__include--missing')).to.be.false; }); it('should flag an include the list does not know', async () => { stubIncludeList([{id: 'inc-1', title: 'Refund Policy'}]); const el = await fixture(html` `); await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include--missing'), 'the missing state never rendered'); expect(el.shadowRoot!.querySelector('.remarkd-editor__include-meta')!.textContent).to.contain('inc-9'); }); // remarkd's file include shares the syntax, so an unknown path is not broken. it('should not flag a file include the list does not know', async () => { stubIncludeList([{id: 'inc-1', title: 'Refund Policy'}]); const el = await fixture(html` `); await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include'), 'the chip never rendered'); expect(el.shadowRoot!.querySelector('.remarkd-editor__include--missing')).to.not.exist; }); it('should not request the include list for a document with no embeds', async () => { const original = window.fetch; let requests = 0; window.fetch = () => { requests++; return Promise.resolve(new Response('{"items":[]}')); }; try { const el = await fixture(html` `); await el.updateComplete; expect(requests).to.equal(0); } finally { window.fetch = original; } }); it('should insert an include marker from the toolbar picker', async () => { stubIncludeList([{id: 'inc-1', title: 'Refund Policy', scope: 'Global', languages: 'English'}]); const el = await fixture(html` `); const includeButton = el.shadowRoot! .querySelector('.remarkd-editor__toolbar zn-button[label="Include"]')!; includeButton.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true})); await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include-option'), 'the include picker never listed anything'); el.shadowRoot!.querySelector('.remarkd-editor__include-option')!.click(); await el.updateComplete; expect(el.value).to.equal('# Title\n\ninclude::inc-1[Refund Policy]'); }); it('should swap the slash command for the include picker when Include is chosen', async () => { stubIncludeList([{id: 'inc-1', title: 'Refund Policy', languages: 'English'}]); const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; const input = typeInBlock(el, '/include'); await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened'); input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true})); await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include-picker'), 'the include picker never opened'); // The "/include" draft is dropped rather than committed as a block. expect(el.value).to.equal(''); }); it('should filter the include picker', async () => { stubIncludeList([ {id: 'inc-1', title: 'Refund Policy', keywords: ['money'], languages: 'English'}, {id: 'inc-2', title: 'Shipping', languages: 'English'}, ]); const el = await fixture(html` `); const includeButton = el.shadowRoot! .querySelector('.remarkd-editor__toolbar zn-button[label="Include"]')!; includeButton.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true})); await waitUntil(() => el.shadowRoot!.querySelectorAll('.remarkd-editor__include-option').length === 2, 'the include picker never listed both'); const filter = el.shadowRoot!.querySelector('.remarkd-editor__include-filter')!; filter.value = 'money'; filter.dispatchEvent(new Event('input', {bubbles: true})); await el.updateComplete; const options = el.shadowRoot!.querySelectorAll('.remarkd-editor__include-option'); expect(options.length).to.equal(1); expect(options[0].textContent).to.contain('Refund Policy'); }); it('should offer no image entry points without an attachment-url', async () => { const el = await fixture(html` `); const tooltips = Array.from(el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button')) .map(button => button.getAttribute('label')); expect(tooltips).to.not.contain('Image'); }); it('should offer the Image action with an attachment-url', async () => { const el = await fixture(html` `); const tooltips = Array.from(el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button')) .map(button => button.getAttribute('label')); expect(tooltips).to.contain('Image'); }); it('should offer no include entry points without an include-url', async () => { const el = await fixture(html` `); const tooltips = Array.from(el.shadowRoot!.querySelectorAll('.remarkd-editor__toolbar zn-button')) .map(button => button.getAttribute('label')); expect(tooltips).to.not.contain('Include'); }); // A list that never arrived is not evidence an embed is broken: flagging every // chip "not found" on a failed request sends people hunting for the wrong bug. it('should not flag includes when the list request fails', async () => { const original = window.fetch; window.fetch = () => Promise.resolve(new Response('nope', {status: 404})); try { const el = await fixture(html` `); await el.updateComplete; await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include'), 'the chip never rendered'); const chip = el.shadowRoot!.querySelector('.remarkd-editor__include')!; expect(chip.classList.contains('remarkd-editor__include--missing')).to.be.false; expect(chip.querySelector('.remarkd-editor__include-title')!.textContent).to.contain('Payment Terms'); } finally { window.fetch = original; } }); it('should say so in the picker when the list request fails', async () => { const original = window.fetch; window.fetch = () => Promise.resolve(new Response('nope', {status: 500})); try { const el = await fixture(html` `); const includeButton = el.shadowRoot! .querySelector('.remarkd-editor__toolbar zn-button[label="Include"]')!; includeButton.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true})); await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include-picker-empty')?.textContent ?.includes('Could not load'), 'the picker never reported the failure'); } finally { window.fetch = original; } }); // An app fragment's URLs are relative to its app base, and the console puts the // app's gaid on the host element. A link built client-side from JSON is inside // the shadow root, where a click retargets to the host, so the pagelet handler // never sees it — the href has to carry the base itself. it('should resolve an include link against the app base from gaid', async () => { stubIncludeList([{id: 'inc-1', title: 'Payment Terms', scope: 'Global', languages: 'English', url: '/global/includes/inc-1'}]); const el = await fixture(html` `); await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include-link'), 'the include list never resolved'); expect(el.shadowRoot!.querySelector('.remarkd-editor__include-link')! .getAttribute('href')).to.equal('/ch/kb/global/includes/inc-1'); }); it('should leave an include link alone when it already carries the app base', async () => { stubIncludeList([{id: 'inc-1', title: 'Payment Terms', languages: 'English', url: '/ch/kb/global/includes/inc-1'}]); const el = await fixture(html` `); await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include-link'), 'the include list never resolved'); expect(el.shadowRoot!.querySelector('.remarkd-editor__include-link')! .getAttribute('href')).to.equal('/ch/kb/global/includes/inc-1'); }); it('should leave an include link alone without a gaid', async () => { stubIncludeList([{id: 'inc-1', title: 'Payment Terms', languages: 'English', url: '/global/includes/inc-1'}]); const el = await fixture(html` `); await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__include-link'), 'the include list never resolved'); expect(el.shadowRoot!.querySelector('.remarkd-editor__include-link')! .getAttribute('href')).to.equal('/global/includes/inc-1'); }); it('should render an attribute definition as a variable chip', async () => { const el = await fixture(html` `); const chip = el.shadowRoot!.querySelector('.remarkd-editor__variable')!; expect(chip, 'no variable chip').to.exist; expect(chip.textContent).to.contain('product'); expect(chip.textContent).to.contain('Remarkd'); }); it('should render a title-only block as a chip', async () => { const el = await fixture(html` `); const chip = el.shadowRoot!.querySelector('.remarkd-editor__variable')!; expect(chip, 'no title chip').to.exist; expect(chip.textContent).to.contain('Intro'); }); it('should render a bracketed attribute line as a chip', async () => { const el = await fixture(html` `); const chip = el.shadowRoot!.querySelector('.remarkd-editor__variable')!; expect(chip, 'no bracket chip').to.exist; expect(chip.textContent).to.contain('[%hardbreaks]'); }); it('should render a title immediately followed by content normally, not as a chip', async () => { const el = await fixture(html` `); await el.updateComplete; const rendered = el.shadowRoot!.querySelector('.remarkd-editor__rendered')!; expect(rendered.querySelector('.remarkd-editor__variable'), 'should not chip mixed content').to.not.exist; expect(rendered.textContent).to.contain('Data'); }); // remarkd drops a lone-period paragraph entirely (verified against the parser directly: // parse('.NET is a popular framework.') returns section--empty) — treating it as a title // chip surfaces content the parser would otherwise silently discard, not the reverse. it('should chip a period-led sentence remarkd would otherwise render empty', async () => { const el = await fixture(html` `); const chip = el.shadowRoot!.querySelector('.remarkd-editor__variable')!; expect(chip, 'no title chip').to.exist; expect(chip.textContent).to.contain('NET is a popular framework.'); }); // Same reasoning for a bracket-only line that happens to look like a citation marker: // parse('[1]') also returns section--empty, so this is content the chip rescues. it('should chip a bracketed line remarkd would otherwise render empty', async () => { const el = await fixture(html` `); const chip = el.shadowRoot!.querySelector('.remarkd-editor__variable')!; expect(chip, 'no bracket chip').to.exist; expect(chip.textContent).to.contain('[1]'); }); // markVariables must never touch the meta-chip branch's Lit-owned ChildPart: it assumes its // next sibling stays a Text node, so wrapping a {name} inside it and then re-rendering with a // different value would leave a stale token or silently freeze the display. Scoping // markVariables to the parser-rendered branch only (remarkd-editor__rendered--parsed) is what // this test guards. it('should refresh a metadata chip cleanly when its value changes, leaving no stale token', async () => { const el = await fixture(html` `); await el.updateComplete; el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; const input = el.shadowRoot!.querySelector('.remarkd-editor__input')!; input.value = ':tip: See {other} for details'; input.dispatchEvent(new Event('input', {bubbles: true})); input.dispatchEvent(new Event('blur')); await el.updateComplete; const chip = el.shadowRoot!.querySelector('.remarkd-editor__variable')!; expect(chip, 'no variable chip after edit').to.exist; expect(chip.textContent).to.contain('other'); expect(chip.textContent).to.not.contain('syntax'); // Never evaluated: still the raw {other} reference, not resolved to a value. expect(chip.textContent).to.contain('{other}'); }); it('should mark a variable reference in rendered text', async () => { const el = await fixture(html` `); await el.updateComplete; const tokens = el.shadowRoot!.querySelectorAll('.remarkd-editor__var'); expect(tokens.length).to.equal(1); expect(tokens[0].textContent).to.equal('{product}'); // Not evaluated: the source is untouched. expect(el.value).to.equal('This is {product} here'); }); it('should leave braces in code alone', async () => { const el = await fixture(html` `); await el.updateComplete; expect(el.shadowRoot!.querySelectorAll('.remarkd-editor__var').length).to.equal(0); }); /** Stubs the link options endpoint, capturing every URL it is called with. */ function stubLinkSearch(items: {ref: string; kind: string; title: string; context?: string; status?: string}[]) { const original = window.fetch; const calls: string[] = []; window.fetch = (input: RequestInfo | URL) => { calls.push(String(input)); return Promise.resolve(new Response(JSON.stringify({items}), {headers: {'Content-Type': 'application/json'}})); }; afterEach(() => { window.fetch = original; }); return calls; } it('should search the link endpoint from the picker', async () => { const calls = stubLinkSearch([ {ref: 'kb:document/doc1', kind: 'document', title: 'Refunds', context: 'Billing', status: 'published'}, ]); const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; // "article" matches only this action's label — "/link" would leave the plain // Link action active, since its label sorts into the same band first. const input = typeInBlock(el, '/article'); await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened'); input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true})); await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__link-picker'), 'the link picker never opened'); const filter = el.shadowRoot!.querySelector('.remarkd-editor__link-filter')!; filter.value = 'refund'; filter.dispatchEvent(new Event('input', {bubbles: true})); await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__link-option'), 'no results rendered'); expect(calls.some(url => url.includes('/links?q=refund'))).to.be.true; const option = el.shadowRoot!.querySelector('.remarkd-editor__link-option')!; expect(option.querySelector('.remarkd-editor__link-option-title')!.textContent).to.contain('Refunds'); expect(option.querySelector('.remarkd-editor__link-option-meta')!.textContent).to.contain('Billing'); }); it('should keep the link picker open when opened from the overflow menu', async () => { stubLinkSearch([]); // Narrow enough that every action, including "Link to article", collapses into the // overflow menu — the toolbar-bar buttons guard against blur with @mousedown // preventDefault, but a menu item does not, so this is the path that can race. const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__toolbar-more'), 'the overflow trigger never appeared'); const dropdown = el.shadowRoot!.querySelector('.remarkd-editor__toolbar-more')!; await dropdown.show(); await new Promise(resolve => requestAnimationFrame(resolve)); const linkItem = [...dropdown.querySelectorAll('zn-menu[slot="submenu"] zn-menu-item')] .find(item => item.textContent?.includes('Link to article')); expect(linkItem, 'the article-link action should be reachable through the overflow menu').to.exist; // ZnButton overrides click() without dispatching a DOM event, so the menu item's // own click listener (and zn-menu's selection handling) needs a real event. linkItem!.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true})); await el.updateComplete; await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__link-picker'), 'the link picker never opened'); // The dropdown's own @zn-hide fires as the menu closes after the item is chosen; it must // not clear suppressBlurCommit out from under the picker it just opened. expect(el.shadowRoot!.querySelector('.remarkd-editor__link-picker'), 'the picker unmounted, so the deferred blur committed the edit and closed the block') .to.exist; }); it('should apply only the newest search response when an older one resolves later', async () => { const resolvers: Record void> = {}; const original = window.fetch; window.fetch = (input: RequestInfo | URL) => { const q = new URL(String(input), window.location.href).searchParams.get('q') ?? ''; return new Promise(resolve => { resolvers[q] = resolve; }); }; afterEach(() => { window.fetch = original; }); const respond = (items: {ref: string; kind: string; title: string}[]) => new Response(JSON.stringify({items}), {headers: {'Content-Type': 'application/json'}}); const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; const input = typeInBlock(el, '/article'); await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened'); input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true})); await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__link-picker'), 'the link picker never opened'); const filter = el.shadowRoot!.querySelector('.remarkd-editor__link-filter')!; filter.value = 'first'; filter.dispatchEvent(new Event('input', {bubbles: true})); await aTimeout(250); filter.value = 'second'; filter.dispatchEvent(new Event('input', {bubbles: true})); await aTimeout(250); // Resolve out of order: the stale "first" request settles after the current // "second" one, and must not be allowed to overwrite it. resolvers['second']!(respond([{ref: 'kb:document/second', kind: 'document', title: 'Second Result'}])); await aTimeout(0); resolvers['first']!(respond([{ref: 'kb:document/first', kind: 'document', title: 'First Result'}])); await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__link-option'), 'no results rendered'); // An option already exists from the "second" response, so waiting on its mere // presence would race the still-settling "first" response — give it time to // land (and, unguarded, overwrite) before asserting it did not. await aTimeout(50); const titles = [...el.shadowRoot!.querySelectorAll('.remarkd-editor__link-option-title')] .map(title => title.textContent); expect(titles).to.eql(['Second Result']); }); it('should report a failed link search', async () => { const original = window.fetch; window.fetch = () => Promise.resolve(new Response('nope', {status: 500})); afterEach(() => { window.fetch = original; }); const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; const input = typeInBlock(el, '/article'); await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened'); input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true})); // The debounced request is still pending when the empty state first renders // ("Searching…"), so wait for the failed message itself, not just the div. await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__link-picker-empty') ?.textContent?.includes('Could not search'), 'the picker never settled'); expect(el.shadowRoot!.querySelector('.remarkd-editor__link-picker-empty')!.textContent) .to.contain('Could not search'); }); it('should close the link picker without touching the block', async () => { stubLinkSearch([]); const el = await fixture(html` `); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; const input = typeInBlock(el, '/article'); await waitUntil(() => el.shadowRoot!.querySelector('zn-slash-menu[open]'), 'the slash menu never opened'); input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true})); await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__link-picker'), 'the link picker never opened'); // ZnButton overrides .click() to skip dispatching a real click event, so the // host's own @click listener needs one dispatched directly, as elsewhere in this file. el.shadowRoot!.querySelector('.remarkd-editor__link-picker zn-button')! .dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true})); await el.updateComplete; expect(el.shadowRoot!.querySelector('.remarkd-editor__link-picker')).to.not.exist; expect(el.value).to.equal('Hello'); }); /** * Opens the picker from the toolbar over the open block, with the given * selection — the slash path never has one, so the toolbar is the only way to * exercise a selected label. The fixtures below are 4000px wide so no toolbar * group has collapsed into the overflow menu. */ async function openLinkPicker(el: ZnRemarkdEditor, text: string, from: number, to: number) { await aTimeout(50); el.shadowRoot!.querySelector('.remarkd-editor__rendered')!.click(); await el.updateComplete; const input = typeInBlock(el, text); input.setSelectionRange(from, to); el.shadowRoot!.querySelector('zn-button[label="Link to article"]')! .dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true})); await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__link-picker'), 'the link picker never opened'); } it('should wrap the selected text as the link label', async () => { stubLinkSearch([{ref: 'kb:document/doc1', kind: 'document', title: 'Refunds'}]); const el = await fixture(html` `); await openLinkPicker(el, 'Read Refunds now', 5, 12); await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__link-option'), 'no results'); el.shadowRoot!.querySelector('.remarkd-editor__link-option')!.click(); await el.updateComplete; const input = el.shadowRoot!.querySelector('.remarkd-editor__input')!; expect(input.value).to.equal('Read [Refunds](kb:document/doc1) now'); }); it('should insert the title as the label with no selection', async () => { stubLinkSearch([{ref: 'kb:document/doc1', kind: 'document', title: 'Refunds [and] more'}]); const el = await fixture(html` `); await openLinkPicker(el, 'See ', 4, 4); await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__link-option'), 'no results'); el.shadowRoot!.querySelector('.remarkd-editor__link-option')!.click(); await el.updateComplete; const input = el.shadowRoot!.querySelector('.remarkd-editor__input')!; expect(input.value).to.equal('See [Refunds (and) more](kb:document/doc1)'); expect(el.shadowRoot!.querySelector('.remarkd-editor__link-picker')).to.not.exist; }); it('should collapse each CRLF character in the title to a space, matching Go byte-for-byte', async () => { // Label formatting must match app-kb's model.ContentLinkMarkup character for character. stubLinkSearch([{ref: 'kb:document/doc1', kind: 'document', title: 'Contact\r\nnow'}]); const el = await fixture(html` `); await openLinkPicker(el, 'See ', 4, 4); await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__link-option'), 'no results'); el.shadowRoot!.querySelector('.remarkd-editor__link-option')!.click(); await el.updateComplete; const input = el.shadowRoot!.querySelector('.remarkd-editor__input')!; expect(input.value).to.equal('See [Contact now](kb:document/doc1)'); }); it('should commit the inserted link to the value', async () => { stubLinkSearch([{ref: 'kb:category/cat1', kind: 'category', title: 'Billing'}]); const el = await fixture(html` `); await openLinkPicker(el, 'See ', 4, 4); await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__link-option'), 'no results'); el.shadowRoot!.querySelector('.remarkd-editor__link-option')!.click(); await el.updateComplete; el.shadowRoot!.querySelector('.remarkd-editor__input')! .dispatchEvent(new Event('blur', {bubbles: true})); await el.updateComplete; expect(el.value).to.equal('See [Billing](kb:category/cat1)'); }); it('should flag a link whose target the app does not know', async () => { const calls = stubLinkSearch([{ref: 'kb:document/doc1', kind: 'document', title: 'Refunds'}]); const el = await fixture(html` `); await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__link--missing'), 'the missing state never rendered'); expect(calls.some(url => url.includes('refs=') && url.includes('doc9'))).to.be.true; const anchors = [...el.shadowRoot!.querySelectorAll('a[href^="kb:"]')]; const healthy = anchors.find(a => a.getAttribute('href') === 'kb:document/doc1')!; const missing = anchors.find(a => a.getAttribute('href') === 'kb:document/doc9')!; expect(healthy.classList.contains('remarkd-editor__link--missing')).to.be.false; expect(missing.classList.contains('remarkd-editor__link--missing')).to.be.true; }); // A list that never arrived is not evidence a link is broken. it('should not flag links when the resolve request fails', async () => { const original = window.fetch; const calls: string[] = []; window.fetch = (input: RequestInfo | URL) => { calls.push(String(input)); return Promise.resolve(new Response('nope', {status: 500})); }; afterEach(() => { window.fetch = original; }); const el = await fixture(html` `); await aTimeout(200); expect(calls.some(url => url.includes('refs=') && url.includes('doc9'))).to.be.true; expect(el.shadowRoot!.querySelector('.remarkd-editor__link--missing')).to.not.exist; }); it('should not flag links without a link-url', async () => { const calls = stubLinkSearch([]); const el = await fixture(html` `); await aTimeout(50); expect(calls).to.have.length(0); expect(el.shadowRoot!.querySelector('.remarkd-editor__link--missing')).to.not.exist; }); });