note-outline.test.mjs
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 1 | /** |
| 2 | * NoteOutline parser tests. |
| 3 | * |
| 4 | * Phase 1A is parser-only: no CLI, MCP, Hub, search, index, memory, or |
| 5 | * persistence behavior is expected here. |
| 6 | */ |
| 7 | import { describe, it } from 'node:test'; |
| 8 | import assert from 'node:assert/strict'; |
| 9 | |
| 10 | import { |
| 11 | MAX_NOTE_OUTLINE_HEADINGS, |
| 12 | MAX_NOTE_OUTLINE_INPUT_CHARS, |
| 13 | NOTE_OUTLINE_SCHEMA, |
| 14 | buildNoteOutline, |
| 15 | buildNoteOutlineFromMarkdown, |
| 16 | } from '../lib/note-outline.mjs'; |
| 17 | |
| 18 | describe('NoteOutline parser', () => { |
| 19 | it('unit: returns the v1 schema, path, title, and ATX heading order', () => { |
| 20 | const outline = buildNoteOutline({ |
| 21 | path: 'inbox/example.md', |
| 22 | frontmatter: { title: 'Example Note' }, |
| 23 | body: '# Intro\n\n## Details\n\n### Next', |
| 24 | }); |
| 25 | |
| 26 | assert.equal(outline.schema, NOTE_OUTLINE_SCHEMA); |
| 27 | assert.equal(outline.path, 'inbox/example.md'); |
| 28 | assert.equal(outline.title, 'Example Note'); |
| 29 | assert.equal(outline.truncated, false); |
| 30 | assert.deepEqual(outline.headings, [ |
| 31 | { level: 1, text: 'Intro', id: 'h1-intro-0001' }, |
| 32 | { level: 2, text: 'Details', id: 'h2-details-0002' }, |
| 33 | { level: 3, text: 'Next', id: 'h3-next-0003' }, |
| 34 | ]); |
| 35 | }); |
| 36 | |
| 37 | it('unit: ignores YAML frontmatter as outline content', () => { |
| 38 | const outline = buildNoteOutlineFromMarkdown( |
| 39 | 'inbox/frontmatter.md', |
| 40 | '---\ntitle: Frontmatter Title\nnote: "# not a heading"\n---\n\n# Body Heading\n' |
| 41 | ); |
| 42 | |
| 43 | assert.equal(outline.title, 'Frontmatter Title'); |
| 44 | assert.deepEqual(outline.headings, [ |
| 45 | { level: 1, text: 'Body Heading', id: 'h1-body-heading-0001' }, |
| 46 | ]); |
| 47 | }); |
| 48 | |
| 49 | it('unit: supports Setext headings', () => { |
| 50 | const outline = buildNoteOutline({ |
| 51 | path: 'notes/setext.md', |
| 52 | body: 'Title\n=====\n\nSubtitle\n--------\n\nText', |
| 53 | frontmatter: {}, |
| 54 | }); |
| 55 | |
| 56 | assert.deepEqual(outline.headings, [ |
| 57 | { level: 1, text: 'Title', id: 'h1-title-0001' }, |
| 58 | { level: 2, text: 'Subtitle', id: 'h2-subtitle-0002' }, |
| 59 | ]); |
| 60 | }); |
| 61 | |
| 62 | it('unit: ignores heading-looking text inside fenced and indented code blocks', () => { |
| 63 | const outline = buildNoteOutline({ |
| 64 | path: 'notes/code.md', |
| 65 | frontmatter: {}, |
| 66 | body: [ |
| 67 | '# Real', |
| 68 | '', |
| 69 | '```', |
| 70 | '# Not real', |
| 71 | '```', |
| 72 | '', |
| 73 | ' ## Also not real', |
| 74 | '', |
| 75 | '## Real Child', |
| 76 | ].join('\n'), |
| 77 | }); |
| 78 | |
| 79 | assert.deepEqual(outline.headings, [ |
| 80 | { level: 1, text: 'Real', id: 'h1-real-0001' }, |
| 81 | { level: 2, text: 'Real Child', id: 'h2-real-child-0002' }, |
| 82 | ]); |
| 83 | }); |
| 84 | |
| 85 | it('unit: gives duplicate headings deterministic distinct IDs', () => { |
| 86 | const note = { |
| 87 | path: 'notes/duplicates.md', |
| 88 | frontmatter: {}, |
| 89 | body: '## Install\n\nText\n\n## Install\n\nMore', |
| 90 | }; |
| 91 | |
| 92 | const first = buildNoteOutline(note); |
| 93 | const second = buildNoteOutline(note); |
| 94 | |
| 95 | assert.deepEqual(first, second); |
| 96 | assert.deepEqual(first.headings, [ |
| 97 | { level: 2, text: 'Install', id: 'h2-install-0001' }, |
| 98 | { level: 2, text: 'Install', id: 'h2-install-0002' }, |
| 99 | ]); |
| 100 | }); |
| 101 | |
| 102 | it('unit: extracts plain text from inline heading formatting', () => { |
| 103 | const outline = buildNoteOutline({ |
| 104 | path: 'notes/inline.md', |
| 105 | frontmatter: {}, |
| 106 | body: '## **Bold** [Link](https://example.com) `code`', |
| 107 | }); |
| 108 | |
| 109 | assert.deepEqual(outline.headings, [ |
| 110 | { level: 2, text: 'Bold Link code', id: 'h2-bold-link-code-0001' }, |
| 111 | ]); |
| 112 | }); |
| 113 | |
| 114 | it('security: treats HTML/script-looking heading content as plain text data', () => { |
| 115 | const outline = buildNoteOutline({ |
| 116 | path: 'notes/html.md', |
| 117 | frontmatter: {}, |
| 118 | body: '## <script>alert(1)</script>', |
| 119 | }); |
| 120 | |
| 121 | assert.equal(outline.headings.length, 1); |
| 122 | assert.equal(outline.headings[0].level, 2); |
| 123 | assert.equal(outline.headings[0].text, '<script> alert(1) </script>'); |
| 124 | assert.equal(outline.headings[0].id, 'h2-script-alert-1-script-0001'); |
| 125 | }); |
| 126 | |
| 127 | it('security: does not include body, snippets, frontmatter, or absolute paths', () => { |
| 128 | const outline = buildNoteOutline({ |
| 129 | path: 'private/secret.md', |
| 130 | frontmatter: { title: 'Private', api_key: 'must-not-appear' }, |
| 131 | body: '# Visible Heading\n\nSensitive body text must not appear.', |
| 132 | }); |
| 133 | |
| 134 | const serialized = JSON.stringify(outline); |
| 135 | assert.equal(Object.hasOwn(outline, 'body'), false); |
| 136 | assert.equal(Object.hasOwn(outline, 'frontmatter'), false); |
| 137 | assert.equal(Object.hasOwn(outline, 'snippet'), false); |
| 138 | assert.equal(serialized.includes('must-not-appear'), false); |
| 139 | assert.equal(serialized.includes('Sensitive body text'), false); |
| 140 | assert.equal(serialized.includes('/Users/'), false); |
| 141 | }); |
| 142 | |
| 143 | it('data-integrity: does not mutate the input note object', () => { |
| 144 | const note = { |
| 145 | path: 'notes/immutable.md', |
| 146 | frontmatter: { title: 'Immutable' }, |
| 147 | body: '# Heading', |
| 148 | }; |
| 149 | const before = JSON.stringify(note); |
| 150 | |
| 151 | buildNoteOutline(note); |
| 152 | |
| 153 | assert.equal(JSON.stringify(note), before); |
| 154 | }); |
| 155 | |
| 156 | it('unit: returns an empty headings array for empty and no-heading notes', () => { |
| 157 | assert.deepEqual( |
| 158 | buildNoteOutline({ path: 'notes/empty.md', frontmatter: {}, body: '' }).headings, |
| 159 | [] |
| 160 | ); |
| 161 | assert.deepEqual( |
| 162 | buildNoteOutline({ path: 'notes/no-heading.md', frontmatter: {}, body: 'plain text' }).headings, |
| 163 | [] |
| 164 | ); |
| 165 | }); |
| 166 | |
| 167 | it('unit: handles CRLF line endings', () => { |
| 168 | const outline = buildNoteOutline({ |
| 169 | path: 'notes/crlf.md', |
| 170 | frontmatter: {}, |
| 171 | body: '# First\r\n\r\n## Second\r\n', |
| 172 | }); |
| 173 | |
| 174 | assert.deepEqual(outline.headings, [ |
| 175 | { level: 1, text: 'First', id: 'h1-first-0001' }, |
| 176 | { level: 2, text: 'Second', id: 'h2-second-0002' }, |
| 177 | ]); |
| 178 | }); |
| 179 | |
| 180 | it('stress: caps returned headings and sets truncated', () => { |
| 181 | const maxHeadings = 3; |
| 182 | const outline = buildNoteOutline( |
| 183 | { |
| 184 | path: 'notes/many.md', |
| 185 | frontmatter: {}, |
| 186 | body: Array.from({ length: 10 }, (_, index) => `## Heading ${index + 1}`).join('\n\n'), |
| 187 | }, |
| 188 | { maxHeadings } |
| 189 | ); |
| 190 | |
| 191 | assert.equal(outline.headings.length, maxHeadings); |
| 192 | assert.equal(outline.truncated, true); |
| 193 | assert.deepEqual(outline.headings.map((heading) => heading.text), [ |
| 194 | 'Heading 1', |
| 195 | 'Heading 2', |
| 196 | 'Heading 3', |
| 197 | ]); |
| 198 | }); |
| 199 | |
| 200 | it('stress: rejects input above the configured character cap', () => { |
| 201 | assert.throws( |
| 202 | () => |
| 203 | buildNoteOutline( |
| 204 | { |
| 205 | path: 'notes/huge.md', |
| 206 | frontmatter: {}, |
| 207 | body: 'x'.repeat(11), |
| 208 | }, |
| 209 | { maxInputChars: 10 } |
| 210 | ), |
| 211 | /exceeds/ |
| 212 | ); |
| 213 | }); |
| 214 | |
| 215 | it('performance: parses a large but bounded heading set quickly', () => { |
| 216 | const body = Array.from( |
| 217 | { length: Math.min(1000, MAX_NOTE_OUTLINE_HEADINGS * 2) }, |
| 218 | (_, index) => `## Heading ${index + 1}\n\nText ${index + 1}` |
| 219 | ).join('\n\n'); |
| 220 | const started = Date.now(); |
| 221 | const outline = buildNoteOutline({ path: 'notes/perf.md', frontmatter: {}, body }); |
| 222 | const elapsedMs = Date.now() - started; |
| 223 | |
| 224 | assert.equal(outline.headings.length, MAX_NOTE_OUTLINE_HEADINGS); |
| 225 | assert.equal(outline.truncated, true); |
| 226 | assert.ok(elapsedMs < 2000, `expected parser under 2s, got ${elapsedMs}ms`); |
| 227 | }); |
| 228 | |
| 229 | it('unit: exposes documented default caps', () => { |
| 230 | assert.equal(MAX_NOTE_OUTLINE_INPUT_CHARS, 1_000_000); |
| 231 | assert.equal(MAX_NOTE_OUTLINE_HEADINGS, 500); |
| 232 | }); |
| 233 | }); |