import { describe, it, afterEach, expect, $, $$, click, rightClick, sleep, csrf } from './runner.js?v=4'; 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 {} } }); }); describe('Card: Make ticket (gitea)', () => { afterEach(async () => { // Always close the gitea overlay so subsequent tests aren't // running on top of it document.getElementById('gitea-overlay')?.classList.remove('open'); await sleep(50); }); it('action is exposed when the gitea plugin is loaded', () => { // The make-ticket entry only renders when the plugin exposes // _giteaMakeIssueFromCard. If the plugin isn't loaded in this // build, treat as a soft skip — nothing to test. if (typeof window._giteaMakeIssueFromCard !== 'function') return; const card = $('#board [data-id]'); if (!card) return; rightClick(card); // Use sleep here, the menu render is synchronous after the dispatch const actions = $$('#ctx-items [data-action]').map(b => b.dataset.action); expect(actions).toContain('make-ticket'); }); it('clicking make-ticket pre-fills the gitea create form (no submit)', async () => { if (typeof window._giteaMakeIssueFromCard !== 'function') return; const title = '__test_make_ticket_title__'; const body = 'a body that should land in the gitea form'; const res = await fetch('api/cards', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, body: JSON.stringify({ title, 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 btn = $$('#ctx-items [data-action="make-ticket"]')[0]; expect(btn).toExist(); click(btn); // openGitea + openCreateForm involve account fetch + repo fetch. // Wait until the gitea overlay is open OR a reasonable timeout // passes. Without a configured gitea account the welcome view // shows instead — that's a soft skip since we can't test the // pre-fill in that case. let overlay = null; for (let i = 0; i < 60; i++) { overlay = document.getElementById('gitea-overlay'); if (overlay?.classList.contains('open')) break; await sleep(50); } if (!overlay?.classList.contains('open')) return; const welcome = document.getElementById('gitea-view-welcome'); if (welcome && !welcome.classList.contains('hidden')) { // No gitea accounts configured — function returns early, // form never renders, can't test the pre-fill path. return; } // Wait until openCreateForm has finished its async repo load // and surfaced the form (it's hidden by default). let form = null; for (let i = 0; i < 60; i++) { form = document.getElementById('gitea-create-form'); if (form && !form.classList.contains('hidden')) break; await sleep(50); } expect(form && !form.classList.contains('hidden')).toBe(true); // The form must NOT auto-submit — the title and body should be // populated and the form still visible afterwards. The pre-fill // happens after an async openCreateForm, so poll until the title // input has our expected value or we time out. const titleInput = document.getElementById('gitea-create-title'); const bodyInput = document.getElementById('gitea-create-body'); for (let i = 0; i < 40; i++) { if (titleInput?.value === title) break; await sleep(50); } expect(titleInput?.value).toBe(title); expect(bodyInput?.value).toBe(body); // Issues list / detail view should NOT be the foreground const detail = document.getElementById('gitea-detail'); expect(detail?.classList.contains('hidden')).toBe(true); }); });