/** * Tests for scripts/lib/plan-canvas/markdown.js * * Run with: node tests/lib/plan-canvas-markdown.test.js */ const assert = require('assert'); // Import the module const { renderMarkdown, escapeHtml, slugify } = require('../../scripts/lib/plan-canvas/markdown'); // Test helper function test(name, fn) { try { fn(); console.log(` ✓ ${name}`); return true; } catch (err) { console.log(` ✗ ${name}`); console.log(` Error: ${err.message}`); return false; } } // Test suite function runTests() { console.log('\n=== Testing plan-canvas/markdown.js ===\n'); let passed = 0; let failed = 0; // escapeHtml tests console.log('escapeHtml:'); if (test('escapes & < > " \'', () => { assert.strictEqual( escapeHtml(''), '<a href="x" & 'y'>' ); })) passed++; else failed++; if (test('leaves safe text unchanged', () => { assert.strictEqual(escapeHtml('plain text 123'), 'plain text 123'); })) passed++; else failed++; if (test('handles null/undefined as empty string', () => { assert.strictEqual(escapeHtml(null), ''); assert.strictEqual(escapeHtml(undefined), ''); })) passed++; else failed++; // slugify tests console.log('\nslugify:'); if (test('lowercases and hyphenates spaces', () => { assert.strictEqual(slugify('Plan Overview'), 'plan-overview'); })) passed++; else failed++; if (test('strips punctuation', () => { assert.strictEqual(slugify('Files to Change: Phase 1!'), 'files-to-change-phase-1'); })) passed++; else failed++; if (test('collapses repeated separators and trims', () => { assert.strictEqual(slugify(' A B--C '), 'a-b-c'); })) passed++; else failed++; if (test('returns empty string for symbol-only input', () => { assert.strictEqual(slugify('***'), ''); })) passed++; else failed++; // Heading tests console.log('\nHeadings:'); for (let level = 1; level <= 6; level++) { if (test(`renders h${level} with slug id`, () => { const md = `${'#'.repeat(level)} Title ${level}`; assert.strictEqual( renderMarkdown(md), `Title ${level}` ); })) passed++; else failed++; } if (test('heading supports inline formatting, slug ignores markers', () => { assert.strictEqual( renderMarkdown('## Rollout **Plan**'), '

Rollout Plan

' ); })) passed++; else failed++; // Paragraph and inline tests console.log('\nParagraphs and Inline:'); if (test('splits paragraphs on blank lines', () => { assert.strictEqual( renderMarkdown('first para\n\nsecond para'), '

first para

\n

second para

' ); })) passed++; else failed++; if (test('joins consecutive lines into one paragraph', () => { assert.strictEqual(renderMarkdown('line a\nline b'), '

line a\nline b

'); })) passed++; else failed++; if (test('renders bold, italic, strikethrough, inline code', () => { const out = renderMarkdown('has **bold**, *ital*, _emph_, ~~gone~~, and `a < b`.'); assert.strictEqual( out, '

has bold, ital, emph, gone, and a < b.

' ); })) passed++; else failed++; if (test('does not italicize snake_case identifiers', () => { const out = renderMarkdown('use snake_case_name here'); assert.ok(!out.includes(''), `No expected, got ${out}`); })) passed++; else failed++; if (test('inline code contents are not parsed further', () => { assert.strictEqual(renderMarkdown('`**x**`'), '

**x**

'); })) passed++; else failed++; // List tests console.log('\nLists:'); if (test('renders nested unordered list (2 levels)', () => { const out = renderMarkdown('- top one\n - child one\n - child two\n- top two'); assert.ok(out.startsWith('
    '), 'Should start with
      '); assert.ok(out.includes('
    • top one\n
        '), 'Nested list should sit inside first
      • '); assert.ok(out.includes('
      • child one
      • '), 'Should contain first child'); assert.ok(out.includes('
      \n
    • \n
    • top two
    • '), 'Second top item follows nested list'); })) passed++; else failed++; if (test('renders ordered list', () => { assert.strictEqual( renderMarkdown('1. first\n2. second'), '
        \n
      1. first
      2. \n
      3. second
      4. \n
      ' ); })) passed++; else failed++; if (test('renders unordered list nested inside ordered list', () => { const out = renderMarkdown('1. step one\n - detail\n2. step two'); assert.ok(out.startsWith('
        '), 'Outer list should be
          '); assert.ok(out.includes('
        1. step one\n
            \n
          • detail
          • \n
          \n
        2. '), `Nested
            expected, got ${out}`); })) passed++; else failed++; if (test('renders task list items (checked and unchecked)', () => { const out = renderMarkdown('- [ ] draft plan\n- [x] review plan'); assert.ok(out.includes('
          • draft plan
          • '), `Unchecked task expected, got ${out}`); assert.ok(out.includes('
          • review plan
          • '), `Checked task expected, got ${out}`); })) passed++; else failed++; if (test('asterisk bullets work like hyphen bullets', () => { assert.strictEqual(renderMarkdown('* a\n* b'), '
              \n
            • a
            • \n
            • b
            • \n
            '); })) passed++; else failed++; // Table tests console.log('\nTables:'); const planTable = [ '| File | Action | Why |', '|:-----|:------:|----:|', '| `scripts/lib/plan-canvas/markdown.js` | Create | GFM renderer |', '| `tests/lib/plan-canvas-markdown.test.js` | Create | **Required** coverage |' ].join('\n'); if (test('renders plan-artifact table with thead/tbody', () => { const out = renderMarkdown(planTable); assert.ok(out.startsWith(''), 'Should start with
            '); assert.ok(out.includes(''), 'Should contain '); assert.ok(out.includes(''), 'Should contain '); })) passed++; else failed++; if (test('applies alignment styles to header and body cells', () => { const out = renderMarkdown(planTable); assert.ok(out.includes(''), 'Left-aligned header'); assert.ok(out.includes(''), 'Center-aligned header'); assert.ok(out.includes(''), 'Right-aligned header'); assert.ok(out.includes(''), 'Center-aligned cell'); })) passed++; else failed++; if (test('renders inline code and bold inside table cells', () => { const out = renderMarkdown(planTable); assert.ok(out.includes('scripts/lib/plan-canvas/markdown.js'), 'Code span in cell'); assert.ok(out.includes('Required coverage'), 'Bold in cell'); })) passed++; else failed++; if (test('omits style attribute when column has no alignment', () => { const out = renderMarkdown('| A | B |\n|---|---|\n| 1 | 2 |'); assert.ok(out.includes(''), 'Header without style'); assert.ok(out.includes(''), 'Cell without style'); assert.ok(!out.includes('style='), 'No style attributes at all'); })) passed++; else failed++; // Code fence tests console.log('\nFenced Code Blocks:'); if (test('renders fence with language class and escaped content', () => { assert.strictEqual( renderMarkdown('```js\nconst x = 1 < 2;\n```'), '
            const x = 1 < 2;
            ' ); })) passed++; else failed++; if (test('escapes \n```'); assert.ok(!out.includes(''); assert.strictEqual(out, '

            <script>alert(1)</script>

            '); })) passed++; else failed++; if (test('javascript: link renders as plain label text', () => { const out = renderMarkdown('[x](javascript:alert(1))'); assert.ok(!out.includes(' { const out = renderMarkdown('[x](JaVaScRiPt:alert(1))'); assert.ok(!out.includes(' { const out = renderMarkdown('[x](java\tscript:alert(1))'); assert.ok(!out.includes(' { assert.strictEqual(renderMarkdown('[x](data:text/html;base64,AAAA)'), '

            x

            '); const vb = renderMarkdown('[x](vbscript:msgbox(1))'); assert.ok(!vb.includes(' { const out = renderMarkdown('![x](javascript:alert(1))'); assert.ok(!out.includes(' HTML is escaped', () => { const out = renderMarkdown(''); assert.strictEqual(out, '

            <img src=x onerror=alert(1)>

            '); })) passed++; else failed++; if (test('event-handler injection via link text is escaped', () => { const out = renderMarkdown('[">](https://evil.example)'); assert.ok(!out.includes(' { assert.strictEqual( renderMarkdown('![a"b](x.png)'), '

            a"b

            ' ); })) passed++; else failed++; // Link protocol tests console.log('\nLink Protocols:'); if (test('https link gets target=_blank and rel=noopener', () => { assert.strictEqual( renderMarkdown('[docs](https://example.com)'), '

            docs

            ' ); })) passed++; else failed++; if (test('#anchor link has no target/rel', () => { assert.strictEqual( renderMarkdown('[phase](#phase-1)'), '

            phase

            ' ); })) passed++; else failed++; if (test('relative link has no target/rel', () => { assert.strictEqual( renderMarkdown('[utils](./scripts/lib/utils.js)'), '

            utils

            ' ); })) passed++; else failed++; if (test('mailto link allowed without target/rel', () => { assert.strictEqual( renderMarkdown('[mail](mailto:team@example.com)'), '

            mail

            ' ); })) passed++; else failed++; if (test('relative image src allowed', () => { assert.strictEqual( renderMarkdown('![diagram](assets/plan.png)'), '

            diagram

            ' ); })) passed++; else failed++; if (test('inline formatting works inside link labels', () => { const out = renderMarkdown('[`code` and **bold** docs](https://example.com)'); assert.ok(out.includes('code and bold docs'), `Formatted label expected, got ${out}`); })) passed++; else failed++; // Edge case tests console.log('\nEdge Cases:'); if (test('empty input returns empty string', () => { assert.strictEqual(renderMarkdown(''), ''); assert.strictEqual(renderMarkdown(null), ''); assert.strictEqual(renderMarkdown(undefined), ''); })) passed++; else failed++; if (test('input without trailing newline works', () => { assert.strictEqual(renderMarkdown('final line'), '

            final line

            '); })) passed++; else failed++; if (test('CRLF line endings are normalized', () => { assert.strictEqual(renderMarkdown('one\r\n\r\ntwo'), '

            one

            \n

            two

            '); })) passed++; else failed++; if (test('whitespace-only input returns empty string', () => { assert.strictEqual(renderMarkdown(' \n\n '), ''); })) passed++; else failed++; console.log('\nMermaid diagrams:'); if (test('```mermaid becomes
            , not a code block', () => {
                const html = renderMarkdown('```mermaid\nflowchart LR\n  A --> B\n```');
                assert.ok(html.includes('
            '), 'expected mermaid container');
                assert.ok(!html.includes('language-mermaid'), 'should not render as a code block');
              })) passed++; else failed++;
            
              if (test('mermaid arrows are entity-escaped so textContent decodes them', () => {
                // The browser decodes > back to > in textContent, so the renderer
                // still receives valid `-->` while HTML injection is prevented.
                const html = renderMarkdown('```mermaid\nA --> B\n```');
                assert.ok(html.includes('A --> B'));
              })) passed++; else failed++;
            
              if (test('script tags inside a mermaid block are inert', () => {
                const html = renderMarkdown('```mermaid\n\n```');
                assert.ok(!html.includes(''));
                assert.ok(html.includes('<script>'));
              })) passed++; else failed++;
            
              if (test('a 
            in the source cannot break out of the container', () => { const html = renderMarkdown('```mermaid\nA
            \n```'); assert.ok(!html.includes(' 0 ? 1 : 0); } runTests();
            FileActionWhyCreateA1