import { describe, it, afterEach, expect, $, $$, click, rightClick, sleep, csrf } from './runner.js'; describe('Context Menu', () => { afterEach(async () => { document.dispatchEvent(new MouseEvent('click')); await sleep(50); }); it('opens on card right-click', async () => { const card = $('#board [data-id]'); if (!card) return; rightClick(card); await sleep(150); expect($('#ctx-menu').classList.contains('hidden')).toBe(false); }); it('has all standard actions', async () => { const card = $('#board [data-id]'); if (!card) return; rightClick(card); await sleep(150); const actions = $$('#ctx-items [data-action]').map(b => b.dataset.action); for (const a of ['open', 'edit', 'duplicate', 'due-date', 'save-md', 'push-relay', 'favorite', 'color', 'bucket', 'tags', 'delete']) { expect(actions).toContain(a); } }); it('has color submenu with presets', async () => { const card = $('#board [data-id]'); if (!card) return; rightClick(card); await sleep(100); const colors = $$('#ctx-color-grid [data-ctx-color]'); expect(colors.length).toBeGreaterThan(5); }); it('has tag submenu with input', async () => { const card = $('#board [data-id]'); if (!card) return; rightClick(card); await sleep(100); expect($('#ctx-tag-input')).toExist(); expect($('#ctx-tag-list')).toExist(); }); it('closes on document click', async () => { const card = $('#board [data-id]'); if (!card) return; rightClick(card); await sleep(100); document.dispatchEvent(new MouseEvent('click')); await sleep(50); expect($('#ctx-menu').classList.contains('hidden')).toBe(true); }); it('has copy-text action', async () => { const card = $('#board [data-id]'); if (!card) return; rightClick(card); await sleep(150); const actions = $$('#ctx-items [data-action]').map(b => b.dataset.action); expect(actions).toContain('copy-text'); }); it('copy-text strips tags, code blocks and image markdown', async () => { // Stub navigator.clipboard.writeText so we can capture what would be copied // without depending on a focused/secure-context clipboard. const origWrite = navigator.clipboard?.writeText?.bind(navigator.clipboard); let captured = null; try { Object.defineProperty(navigator, 'clipboard', { value: { writeText: async (t) => { captured = t; } }, configurable: true, }); } catch { // some browsers don't allow re-defining `clipboard` — bail out cleanly return; } try { const body = [ 'first line', '', '![alt](uploads/foo.png)', '```js', 'console.log("nope")', '```', 'second line #tagone with #tagtwo inline', ].join('\n'); const res = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title: '__test_copy_text__', body, color: '#38bdf8' }), }).then(r => r.json()); await window._reloadAll?.(); await sleep(200); const cardEl = document.querySelector(`[data-id="${res.id}"]`); if (!cardEl) return; rightClick(cardEl); await sleep(150); const copyBtn = $$('#ctx-items [data-action="copy-text"]')[0]; expect(copyBtn).toExist(); click(copyBtn); await sleep(100); expect(captured).toExist(); // Title goes first, separated by a blank line expect(captured.startsWith('__test_copy_text__\n\n')).toBe(true); expect(captured).toContain('first line'); expect(captured).toContain('second line'); // Tags removed (chip + suffix), but the surrounding word "with" stays expect(captured).toContain('with'); expect(captured.includes('#tagone')).toBe(false); expect(captured.includes('#tagtwo')).toBe(false); // Fenced code block dropped entirely expect(captured.includes('console.log')).toBe(false); expect(captured.includes('```')).toBe(false); // Image markdown dropped expect(captured.includes('uploads/foo.png')).toBe(false); expect(captured.includes('![')).toBe(false); } finally { try { Object.defineProperty(navigator, 'clipboard', { value: { writeText: origWrite || (async () => {}) }, configurable: true, }); } catch {} } }); });