chore: import upstream snapshot with attribution
docmd CI verification / verify (push) Failing after 0s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:55 +08:00
commit 6db8fca185
437 changed files with 68762 additions and 0 deletions
@@ -0,0 +1,413 @@
/**
* Tests for threads action handlers
*
* Run: node packages/plugins/threads/tests/actions.test.js
*
* @copyright Copyright (c) 2026 Saulo Vallory
* @license MIT
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const { actions } = require('../src/plugin/actions');
let passed = 0;
let total = 0;
function assert(condition, msg) {
total++;
if (!condition) {
console.error(`FAIL: ${msg}`);
process.exit(1);
}
passed++;
console.log(` PASS: ${msg}`);
}
function assertDeepEqual(actual, expected, msg) {
const a = JSON.stringify(actual);
const e = JSON.stringify(expected);
assert(a === e, `${msg}\n expected: ${e}\n actual: ${a}`);
}
const today = new Date().toISOString().split('T')[0];
/**
* Create a context object matching what the action dispatcher provides.
*/
function createCtx(projectRoot) {
const ctx = {
projectRoot,
config: {},
broadcast: () => {},
_modified: false,
source: { _modified: false },
async readFile(relativePath) {
const resolved = path.resolve(projectRoot, relativePath);
return fs.promises.readFile(resolved, 'utf8');
},
async writeFile(relativePath, content) {
const resolved = path.resolve(projectRoot, relativePath);
await fs.promises.writeFile(resolved, content);
ctx._modified = true;
},
async readFileLines(relativePath) {
const content = await ctx.readFile(relativePath);
return content.split('\n');
},
};
return ctx;
}
/**
* Call an action handler and return { result, reload } like the dispatcher does.
*/
async function handleCall(actionName, payload, projectRoot) {
const handler = actions[actionName];
if (!handler) throw new Error(`Unknown action: ${actionName}`);
const ctx = createCtx(projectRoot);
const result = await handler(payload, ctx);
return { result, reload: ctx._modified || ctx.source._modified };
}
async function run() {
// Create a temp directory with a test markdown file
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'threads-actions-'));
const testFile = 'test.md';
const testFilePath = path.join(tempDir, testFile);
// Write an initial file with no threads
fs.writeFileSync(testFilePath, `# Test Document\n\nSome content here.\n`);
// ─── Test 1: get-threads on empty file → empty array, reload=false ───
console.log('\nTest 1: get-threads on empty file');
const getEmpty = await handleCall('threads:get-threads', { file: testFile }, tempDir);
assert(Array.isArray(getEmpty.result), 'get-threads returns an array');
assert(getEmpty.result.length === 0, 'get-threads returns empty array for file without threads');
assert(getEmpty.reload === false, 'get-threads reload is false (read-only)');
// ─── Test 2: add-thread → creates thread with ID, reload=true ───
console.log('\nTest 2: add-thread creates a thread');
const addThread1 = await handleCall('threads:add-thread', {
file: testFile,
author: 'alice',
body: 'This needs to be reworded.',
anchor: '#section-1',
}, tempDir);
const thread1 = addThread1.result;
assert(addThread1.reload === true, 'add-thread reload is true');
assert(thread1.id.startsWith('t-'), 'thread id starts with t-');
assert(thread1.id.length === 10, 'thread id is t- + 8 hex chars');
assert(thread1.resolved === false, 'new thread is not resolved');
assert(thread1.comments.length === 1, 'new thread has 1 comment');
assert(thread1.comments[0].author === 'alice', 'first comment author is alice');
assert(thread1.comments[0].body === 'This needs to be reworded.', 'first comment body matches');
assert(thread1.comments[0].date === today, 'first comment date is today');
assert(thread1.comments[0].id.startsWith('c-'), 'comment id starts with c-');
assert(thread1.comments[0].id.length === 10, 'comment id is c- + 8 hex chars');
// ─── Test 3: add-comment → appends to thread, reload=true ───
console.log('\nTest 3: add-comment appends to thread');
const addComment = await handleCall('threads:add-comment', {
file: testFile,
threadId: thread1.id,
author: 'bob',
body: 'I agree, let me fix it.',
}, tempDir);
const newComment = addComment.result;
assert(addComment.reload === true, 'add-comment reload is true');
assert(newComment.id.startsWith('c-'), 'new comment id starts with c-');
assert(newComment.author === 'bob', 'new comment author is bob');
assert(newComment.body === 'I agree, let me fix it.', 'new comment body matches');
assert(newComment.date === today, 'new comment date is today');
assert(newComment.thread_id === thread1.id, 'new comment thread_id matches');
// ─── Test 4: get-threads after adds → returns correct data ───
console.log('\nTest 4: get-threads after adds');
const getAfterAdds = await handleCall('threads:get-threads', { file: testFile }, tempDir);
assert(getAfterAdds.result.length === 1, 'still has 1 thread');
assert(getAfterAdds.result[0].comments.length === 2, 'thread now has 2 comments');
assert(getAfterAdds.result[0].comments[0].author === 'alice', 'first comment is alice');
assert(getAfterAdds.result[0].comments[1].author === 'bob', 'second comment is bob');
// ─── Test 5: edit-comment → updates body + edited_at, reload=true ───
console.log('\nTest 5: edit-comment');
// Need to get the actual comment ID from the file (parser generates new IDs on parse)
const preEdit = await handleCall('threads:get-threads', { file: testFile }, tempDir);
const bobCommentIdForEdit = preEdit.result[0].comments[1].id;
const editResult = await handleCall('threads:edit-comment', {
file: testFile,
threadId: thread1.id,
commentId: bobCommentIdForEdit,
body: 'Updated: I will fix it tomorrow.',
}, tempDir);
assert(editResult.reload === true, 'edit-comment reload is true');
assert(editResult.result.body === 'Updated: I will fix it tomorrow.', 'edited body matches');
assert(editResult.result.edited_at === today, 'edited_at is set to today');
// Verify via get-threads
const afterEdit = await handleCall('threads:get-threads', { file: testFile }, tempDir);
assert(afterEdit.result[0].comments[1].body === 'Updated: I will fix it tomorrow.', 'get-threads reflects edit');
assert(afterEdit.result[0].comments[1].edited_at === today, 'get-threads reflects edited_at');
// ─── Test 6: toggle-reaction → add then remove, reload=true ───
console.log('\nTest 6: toggle-reaction');
const firstCommentId = afterEdit.result[0].comments[0].id;
// Add a reaction
const addReaction = await handleCall('threads:toggle-reaction', {
file: testFile,
threadId: thread1.id,
commentId: firstCommentId,
emoji: '👍',
author: 'bob',
}, tempDir);
assert(addReaction.reload === true, 'toggle-reaction reload is true');
assert(addReaction.result.length === 1, 'one reaction after adding');
assert(addReaction.result[0].emoji === '👍', 'reaction emoji matches');
assertDeepEqual(addReaction.result[0].authors, ['bob'], 'reaction authors after add');
// Add same emoji from another author
const addReaction2 = await handleCall('threads:toggle-reaction', {
file: testFile,
threadId: thread1.id,
commentId: firstCommentId,
emoji: '👍',
author: 'charlie',
}, tempDir);
assert(addReaction2.result.length === 1, 'still one reaction type');
assertDeepEqual(addReaction2.result[0].authors, ['bob', 'charlie'], 'reaction authors after second add');
// Remove (toggle off) bob's reaction
const removeReaction = await handleCall('threads:toggle-reaction', {
file: testFile,
threadId: thread1.id,
commentId: firstCommentId,
emoji: '👍',
author: 'bob',
}, tempDir);
assert(removeReaction.result.length === 1, 'still one reaction after removing one author');
assertDeepEqual(removeReaction.result[0].authors, ['charlie'], 'bob removed from authors');
// Remove last author → reaction removed entirely
const removeLastReaction = await handleCall('threads:toggle-reaction', {
file: testFile,
threadId: thread1.id,
commentId: firstCommentId,
emoji: '👍',
author: 'charlie',
}, tempDir);
assert(removeLastReaction.result.length === 0, 'reaction removed entirely when no authors left');
// ─── Test 7: resolve-thread → toggles resolved status ───
console.log('\nTest 7: resolve-thread toggles');
const resolveResult = await handleCall('threads:resolve-thread', {
file: testFile,
threadId: thread1.id,
resolved_by: 'alice',
}, tempDir);
assert(resolveResult.reload === true, 'resolve-thread reload is true');
assert(resolveResult.result.resolved === true, 'thread is now resolved');
assert(resolveResult.result.resolved_by === 'alice', 'resolved_by is alice');
assert(resolveResult.result.resolved_at === today, 'resolved_at is today');
// Toggle back (unresolve)
const unresolveResult = await handleCall('threads:resolve-thread', {
file: testFile,
threadId: thread1.id,
resolved_by: 'alice',
}, tempDir);
assert(unresolveResult.result.resolved === false, 'thread is now unresolved');
assert(unresolveResult.result.resolved_by === null, 'resolved_by cleared');
assert(unresolveResult.result.resolved_at === null, 'resolved_at cleared');
// ─── Test 8: delete-comment → removes comment ───
console.log('\nTest 8: delete-comment');
// Get current state to know comment IDs
const beforeDelete = await handleCall('threads:get-threads', { file: testFile }, tempDir);
const bobCommentId = beforeDelete.result[0].comments[1].id;
const deleteComment = await handleCall('threads:delete-comment', {
file: testFile,
threadId: thread1.id,
commentId: bobCommentId,
}, tempDir);
assert(deleteComment.reload === true, 'delete-comment reload is true');
assertDeepEqual(deleteComment.result, { deleted: true }, 'delete-comment returns { deleted: true }');
const afterDeleteComment = await handleCall('threads:get-threads', { file: testFile }, tempDir);
assert(afterDeleteComment.result[0].comments.length === 1, 'thread has 1 comment after delete');
assert(afterDeleteComment.result[0].comments[0].author === 'alice', 'remaining comment is alice');
// ─── Test 9: delete-thread → removes thread ───
console.log('\nTest 9: delete-thread');
const deleteThread = await handleCall('threads:delete-thread', {
file: testFile,
threadId: thread1.id,
}, tempDir);
assert(deleteThread.reload === true, 'delete-thread reload is true');
assertDeepEqual(deleteThread.result, { deleted: true }, 'delete-thread returns { deleted: true }');
const afterDeleteThread = await handleCall('threads:get-threads', { file: testFile }, tempDir);
assert(afterDeleteThread.result.length === 0, 'no threads after delete');
// Verify the file content no longer has a threads block
const finalContent = fs.readFileSync(testFilePath, 'utf8');
assert(finalContent.includes('# Test Document'), 'original content preserved');
assert(!finalContent.includes('::: threads'), 'threads block removed from file');
// ─── Test 10: add-thread with anchor → wraps quote with highlight markup ───
console.log('\nTest 10: add-thread with anchor (highlight creation)');
const anchorFile = 'anchor-test.md';
fs.writeFileSync(path.join(tempDir, anchorFile), '# Test\n\nSome important text in a paragraph.\n');
const addThreadAnchor = await handleCall('threads:add-thread', {
file: anchorFile,
author: 'alice',
body: 'This is important',
anchor: {
quote: 'important text',
prefix: 'Some ',
suffix: ' in a',
selector: 'p',
offset: 5,
blockText: 'Some important text in a paragraph.',
},
}, tempDir);
const anchorThread = addThreadAnchor.result;
assert(addThreadAnchor.reload === true, 'add-thread with anchor reload is true');
const anchorContent = fs.readFileSync(path.join(tempDir, anchorFile), 'utf8');
assert(anchorContent.includes(`==important text=={${anchorThread.id}}`), `Should have highlight markup. Got:\n${anchorContent}`);
assert(anchorContent.includes('::: threads'), 'Should have threads block');
const highlightIdx = anchorContent.indexOf(`==important text=={${anchorThread.id}}`);
const threadsBlockIdx = anchorContent.indexOf('::: threads');
assert(highlightIdx < threadsBlockIdx, 'Highlight should be before threads block');
// ─── Test 11: add-thread with anchor doesn't match inside threads block ───
console.log('\nTest 11: add-thread with anchor avoids matching inside threads block');
const anchorFile2 = 'anchor-test2.md';
// Write a file where the quote text appears in both body and an existing thread comment
fs.writeFileSync(path.join(tempDir, anchorFile2), [
'# Test',
'',
'Some unique phrase here.',
'',
'::: threads',
'### t-existing',
'- resolved: false',
'',
'#### c-existing',
'- author: bob',
'- date: 2026-01-01',
'',
'Some unique phrase here.',
'',
':::',
'',
].join('\n'));
const addThreadAnchor2 = await handleCall('threads:add-thread', {
file: anchorFile2,
author: 'alice',
body: 'Noting this phrase',
anchor: {
quote: 'unique phrase',
},
}, tempDir);
const anchorContent2 = fs.readFileSync(path.join(tempDir, anchorFile2), 'utf8');
const anchorThread2 = addThreadAnchor2.result;
// The highlight should appear in the body, before the threads block
const highlightIdx2 = anchorContent2.indexOf(`==unique phrase=={${anchorThread2.id}}`);
const threadsBlockIdx2 = anchorContent2.indexOf('::: threads');
assert(highlightIdx2 >= 0, `Highlight markup should exist. Got:\n${anchorContent2}`);
assert(highlightIdx2 < threadsBlockIdx2, 'Highlight should be in body, not in threads block');
// ─── Test 12: delete-thread removes highlight markup from body ───
console.log('\nTest 12: delete-thread removes highlight markup');
const deleteHighlightFile = 'delete-highlight.md';
const deleteThreadId = anchorThread.id;
// Copy the anchor file content which has ==important text=={threadId}
fs.writeFileSync(path.join(tempDir, deleteHighlightFile), anchorContent);
await handleCall('threads:delete-thread', {
file: deleteHighlightFile,
threadId: deleteThreadId,
}, tempDir);
const afterDelete = fs.readFileSync(path.join(tempDir, deleteHighlightFile), 'utf8');
assert(!afterDelete.includes('==' + 'important text=={'), 'Highlight markup should be removed');
assert(afterDelete.includes('important text'), 'Original text should remain');
// ─── Test 13: input validation ───
console.log('\nTest 13: input validation');
let validationError;
try {
await handleCall('threads:add-thread', { file: '', author: 'alice', body: 'hi' }, tempDir);
} catch (e) {
validationError = e.message;
}
assert(validationError && validationError.includes('file is required'), 'Should reject empty file');
validationError = null;
try {
await handleCall('threads:add-thread', { file: testFile, author: '', body: 'hi' }, tempDir);
} catch (e) {
validationError = e.message;
}
assert(validationError && validationError.includes('author is required'), 'Should reject empty author');
validationError = null;
try {
await handleCall('threads:add-thread', { file: testFile, author: 'alice', body: ' ' }, tempDir);
} catch (e) {
validationError = e.message;
}
assert(validationError && validationError.includes('body is required'), 'Should reject whitespace-only body');
validationError = null;
try {
await handleCall('threads:get-threads', {}, tempDir);
} catch (e) {
validationError = e.message;
}
assert(validationError && validationError.includes('file is required'), 'get-threads should reject missing file');
// ─── Cleanup ───
fs.rmSync(tempDir, { recursive: true });
console.log(`\n✓ All ${passed}/${total} tests passed.\n`);
}
run().catch((err) => {
console.error('Test error:', err);
process.exit(1);
});
@@ -0,0 +1,308 @@
/**
* Tests for threads container rules - markdown-it rendering of ::: threads blocks
*
* Run: node packages/plugins/threads/tests/containers.test.js
*
* @copyright Copyright (c) 2026 Saulo Vallory
* @license MIT
*/
import MarkdownIt from 'markdown-it';
import { setup as setupContainers } from '../dist/plugin/containers.js';
let passed = 0;
let total = 0;
function assert(condition, msg) {
total++;
if (!condition) {
console.error(`FAIL: ${msg}`);
process.exit(1);
}
passed++;
console.log(` PASS: ${msg}`);
}
function assertContains(html, substring, msg) {
assert(html.includes(substring), `${msg}\n expected to contain: ${substring}\n actual: ${html.slice(0, 500)}`);
}
function assertNotContains(html, substring, msg) {
assert(!html.includes(substring), `${msg}\n expected NOT to contain: ${substring}\n actual: ${html.slice(0, 500)}`);
}
function createMd() {
const md = MarkdownIt({ html: true });
setupContainers(md);
return md;
}
// ─── Test 1: Full threads block renders with correct structure ───
console.log('\nTest 1: Full threads block renders correct HTML structure');
const md1 = createMd();
const fullInput = `::: threads
::: thread t-abc123
::: comment "alice" "2026-03-07"
This is the first comment
:::
:::
:::
`;
const html1 = md1.render(fullInput);
assertContains(html1, 'class="threads-sidebar"', 'threads wrapper has correct class');
assertContains(html1, 'class="threads-thread"', 'thread has correct class');
assertContains(html1, 'data-thread-id="t-abc123"', 'thread has data-thread-id');
assertContains(html1, 'class="threads-comment"', 'comment has correct class');
assertContains(html1, 'data-author="alice"', 'comment has data-author');
assertContains(html1, 'data-date="2026-03-07"', 'comment has data-date');
assertContains(html1, 'class="threads-comment__meta"', 'comment meta renders');
assertContains(html1, 'class="threads-comment__body"', 'comment body wrapper renders');
assertContains(html1, 'This is the first comment', 'comment body content renders');
// ─── Test 2: Thread ID attribute is present ───
console.log('\nTest 2: Thread id attribute');
const md2 = createMd();
const idInput = `::: threads
::: thread t-xyz789
::: comment "bob" "2026-01-01"
Test
:::
:::
:::
`;
const html2 = md2.render(idInput);
assertContains(html2, 'data-thread-id="t-xyz789"', 'thread id attribute is present and correct');
// ─── Test 3: Resolved thread has resolved class ───
console.log('\nTest 3: Resolved thread has resolved class');
const md3 = createMd();
const resolvedInput = `::: threads
::: thread t-res001 resolved "charlie" "2026-03-08"
::: comment "charlie" "2026-03-08"
Fixed the typo
:::
:::
:::
`;
const html3 = md3.render(resolvedInput);
assertContains(html3, 'threads-thread--resolved', 'resolved thread has resolved class');
assertContains(html3, 'data-thread-id="t-res001"', 'resolved thread has id attribute');
// ─── Test 4: Comment has data-author and data-date ───
console.log('\nTest 4: Comment data attributes');
const md4 = createMd();
const commentInput = `::: threads
::: thread t-c001
::: comment "dave" "2026-05-15"
Comment with attributes
:::
:::
:::
`;
const html4 = md4.render(commentInput);
assertContains(html4, 'data-author="dave"', 'comment has data-author');
assertContains(html4, 'data-date="2026-05-15"', 'comment has data-date');
// ─── Test 5: Comment meta header renders author and date ───
console.log('\nTest 5: Comment meta header');
const md5 = createMd();
const metaInput = `::: threads
::: thread t-m001
::: comment "eve" "2026-06-20"
Testing meta
:::
:::
:::
`;
const html5 = md5.render(metaInput);
assertContains(html5, '<strong>eve</strong>', 'meta includes author in strong tag');
assertContains(html5, '2026-06-20', 'meta includes date');
// ─── Test 6: Reactions container renders ───
console.log('\nTest 6: Reactions container');
const md6 = createMd();
const reactionsInput = `::: threads
::: thread t-r001
::: comment "frank" "2026-07-01"
Comment with reactions
::: reactions
- 👍 alice, bob
- 🎉 charlie
:::
:::
:::
:::
`;
const html6 = md6.render(reactionsInput);
assertContains(html6, 'class="threads-reactions"', 'reactions container has correct class');
// ─── Test 7: Multiple threads render ───
console.log('\nTest 7: Multiple threads');
const md7 = createMd();
const multiInput = `::: threads
::: thread t-first
::: comment "alice" "2026-01-01"
First thread
:::
:::
::: thread t-second
::: comment "bob" "2026-01-02"
Second thread
:::
:::
:::
`;
const html7 = md7.render(multiInput);
assertContains(html7, 'data-thread-id="t-first"', 'first thread renders');
assertContains(html7, 'data-thread-id="t-second"', 'second thread renders');
// ─── Test 8: Unresolved thread does NOT have resolved class ───
console.log('\nTest 8: Unresolved thread lacks resolved class');
const md8 = createMd();
const unresolvedInput = `::: threads
::: thread t-unres
::: comment "alice" "2026-01-01"
Not resolved
:::
:::
:::
`;
const html8 = md8.render(unresolvedInput);
assertContains(html8, 'data-thread-id="t-unres"', 'unresolved thread renders');
assertNotContains(html8, 'threads-thread--resolved', 'unresolved thread does not have resolved class');
// ─── Test 9: Edited comment has edited data attribute ───
console.log('\nTest 9: Edited comment');
const md9 = createMd();
const editedInput = `::: threads
::: thread t-ed01
::: comment "alice" "2026-01-01" edited "2026-01-02"
Edited comment
:::
:::
:::
`;
const html9 = md9.render(editedInput);
assertContains(html9, 'data-edited="2026-01-02"', 'edited comment has data-edited attribute');
// ─── Test 10: Comment body is rendered as markdown ───
console.log('\nTest 10: Comment body rendered as markdown');
const md10 = createMd();
const markdownBodyInput = `::: threads
::: thread t-md01
::: comment "alice" "2026-01-01"
This has **bold** and *italic* text
:::
:::
:::
`;
const html10 = md10.render(markdownBodyInput);
assertContains(html10, '<strong>bold</strong>', 'bold markdown renders in comment body');
assertContains(html10, '<em>italic</em>', 'italic markdown renders in comment body');
// ─── Test 11: Comment body escapes raw HTML while preserving markdown ───
console.log('\nTest 11: Comment body escapes raw HTML');
const md11 = createMd();
const rawHtmlInput = `::: threads
::: thread t-html01
::: comment "alice" "2026-01-01"
<span data-test="raw">raw html</span>
This still has **bold** text
:::
:::
:::
`;
const html11 = md11.render(rawHtmlInput);
assert(!html11.includes('<span data-test="raw">raw html</span>'), 'raw HTML is not emitted as an element');
assert(html11.includes('&lt;span') || html11.includes('&amp;lt;span'), 'raw HTML tag marker is escaped');
assertContains(html11, '<strong>bold</strong>', 'markdown still renders in escaped comment body');
// ─── Test 12: Empty threads block ───
console.log('\nTest 12: Empty threads block');
const md12 = createMd();
const emptyInput = `::: threads
:::
`;
const html12 = md12.render(emptyInput);
assertContains(html12, 'class="threads-sidebar"', 'empty threads wrapper still renders');
// ─── Test 13: Comment with ID (new serialized format) ───
console.log('\nTest 13: Comment with ID in info string');
const md13 = createMd();
const commentWithIdInput = `::: threads
::: thread t-id01
::: comment c-abc12345 "alice" "2026-03-07"
Comment with persistent ID
:::
:::
:::
`;
const html13 = md13.render(commentWithIdInput);
assertContains(html13, 'data-comment-id="c-abc12345"', 'comment has data-comment-id from new format');
assertContains(html13, 'data-author="alice"', 'comment has correct author from new format');
assertContains(html13, 'data-date="2026-03-07"', 'comment has correct date from new format');
// ─── Test 14: Comment with ID and edited (new serialized format) ───
console.log('\nTest 14: Comment with ID and edited');
const md14 = createMd();
const commentWithIdEditedInput = `::: threads
::: thread t-id02
::: comment c-def67890 "bob" "2026-03-07" edited "2026-03-08"
Edited comment with ID
:::
:::
:::
`;
const html14 = md14.render(commentWithIdEditedInput);
assertContains(html14, 'data-comment-id="c-def67890"', 'edited comment has data-comment-id');
assertContains(html14, 'data-author="bob"', 'edited comment has correct author');
assertContains(html14, 'data-edited="2026-03-08"', 'edited comment has data-edited');
// ─── Done ───
console.log(`\n✓ All ${passed}/${total} containers tests passed.\n`);
@@ -0,0 +1,136 @@
/**
* Tests for highlight inline rule - ==text=={thread-id} syntax
*
* Run: node packages/plugins/threads/tests/highlight-rule.test.js
*/
const md = require('markdown-it')();
const highlightRule = require('../src/plugin/highlight-rule');
highlightRule.setup(md);
let passed = 0;
let total = 0;
function assert(condition, msg) {
total++;
if (!condition) {
console.error(`FAIL: ${msg}`);
process.exit(1);
}
passed++;
console.log(` PASS: ${msg}`);
}
// ─── Test 1: Basic highlight with thread ID ───
console.log('\nTest 1: Basic highlight with thread ID');
const result1 = md.render('==highlighted text=={t-abc123}');
assert(
result1.includes('<mark class="threads-highlight" data-thread-id="t-abc123">highlighted text</mark>'),
'renders mark with class and data-thread-id attribute'
);
// ─── Test 2: Plain highlight without thread ID ───
console.log('\nTest 2: Plain highlight without thread ID');
const result2 = md.render('==plain highlight==');
assert(
result2.includes('<mark class="threads-highlight">plain highlight</mark>'),
'renders mark with class but no data-thread-id'
);
assert(
!result2.includes('data-thread-id'),
'no data-thread-id attribute present'
);
// ─── Test 3: Multiple highlights in one line ───
console.log('\nTest 3: Multiple highlights in one line');
const result3 = md.render('==first=={t-1} and ==second=={t-2}');
assert(
result3.includes('<mark class="threads-highlight" data-thread-id="t-1">first</mark>'),
'first highlight rendered correctly'
);
assert(
result3.includes('<mark class="threads-highlight" data-thread-id="t-2">second</mark>'),
'second highlight rendered correctly'
);
assert(
result3.includes(' and '),
'text between highlights preserved'
);
// ─── Test 4: Highlight inside a paragraph with other text ───
console.log('\nTest 4: Highlight inside a paragraph');
const result4 = md.render('This is a ==highlighted phrase=={t-42} in a sentence.');
assert(
result4.includes('This is a <mark class="threads-highlight" data-thread-id="t-42">highlighted phrase</mark> in a sentence.'),
'highlight embedded in paragraph text'
);
// ─── Test 5: Highlight with bold inside ───
console.log('\nTest 5: Highlight with bold inside');
const result5 = md.render('==**bold** text=={t-1}');
assert(
result5.includes('<mark class="threads-highlight" data-thread-id="t-1">'),
'mark open tag with thread id'
);
assert(
result5.includes('<strong>bold</strong>'),
'bold rendered inside mark'
);
assert(
result5.includes('</mark>'),
'mark close tag present'
);
// ─── Test 6: No match for single = ───
console.log('\nTest 6: No false positive on single =');
const result6 = md.render('a = b and c = d');
assert(
!result6.includes('<mark'),
'single = signs do not trigger highlight'
);
// ─── Test 7: Empty highlight text is not matched ───
console.log('\nTest 7: Empty highlight text');
const result7 = md.render('===={t-1}');
assert(
!result7.includes('<mark'),
'empty content between == markers does not match'
);
// ─── Test 8: Highlight does not span across line breaks ───
console.log('\nTest 8: No match across line breaks');
const result8 = md.render('==start\nend==');
assert(
!result8.includes('<mark'),
'highlight does not span newlines'
);
// ─── Test 9: Escaped == is not matched ───
console.log('\nTest 9: Backslash-escaped markers');
const result9 = md.render('\\==not highlighted==');
assert(
!result9.includes('<mark'),
'escaped opening == is not treated as highlight'
);
// ─── Done ───
console.log(`\n✓ All ${passed}/${total} tests passed.\n`);
@@ -0,0 +1,426 @@
/**
* Integration test - full round-trip for threads plugin
*
* Verifies: plugin loading, action handlers, markdown serialization,
* container rendering, and highlight rule.
*
* Run: node packages/plugins/threads/tests/integration.test.js
*
* @copyright Copyright (c) 2026 Saulo Vallory
* @license MIT
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
let passed = 0;
let total = 0;
let tempDir = null;
function assert(condition, msg) {
total++;
if (!condition) {
console.error(`FAIL: ${msg}`);
process.exit(1);
}
passed++;
console.log(` PASS: ${msg}`);
}
// ---------------------------------------------------------------------------
// Minimal action context (avoids broken createActionDispatcher dependency)
// ---------------------------------------------------------------------------
function createMinimalCtx(projectRoot) {
return {
projectRoot,
config: {},
broadcast: () => {},
_modified: false,
async readFile(relativePath) {
return fs.promises.readFile(path.join(projectRoot, relativePath), 'utf8');
},
async writeFile(relativePath, content) {
await fs.promises.writeFile(path.join(projectRoot, relativePath), content);
this._modified = true;
},
async readFileLines(relativePath) {
const content = await this.readFile(relativePath);
return content.split('\n');
},
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
async function testPluginLoads() {
console.log('\n--- Plugin loading ---');
const plugin = require('..');
assert(typeof plugin.markdownSetup === 'function', 'markdownSetup is a function');
assert(typeof plugin.generateScripts === 'function', 'generateScripts is a function');
assert(typeof plugin.getAssets === 'function', 'getAssets is a function');
assert(typeof plugin.actions === 'object' && plugin.actions !== null, 'actions is an object');
assert(typeof plugin.actions['threads:add-thread'] === 'function', 'add-thread action exists');
assert(typeof plugin.actions['threads:add-comment'] === 'function', 'add-comment action exists');
assert(typeof plugin.actions['threads:toggle-reaction'] === 'function', 'toggle-reaction action exists');
assert(typeof plugin.actions['threads:get-threads'] === 'function', 'get-threads action exists');
return plugin;
}
async function testCreateThread(plugin, ctx) {
console.log('\n--- Create thread ---');
const result = await plugin.actions['threads:add-thread'](
{ file: 'test.md', author: 'Alice', body: 'This needs work.', anchor: 'some-anchor' },
ctx
);
assert(typeof result.id === 'string' && result.id.startsWith('t-'), 'Thread ID starts with t-');
assert(result.id.length === 10, 'Thread ID is t- + 8 hex chars');
assert(result.resolved === false, 'Thread is not resolved');
assert(Array.isArray(result.comments), 'Thread has comments array');
assert(result.comments.length === 1, 'Thread has exactly one comment');
assert(result.comments[0].author === 'Alice', 'First comment author is Alice');
assert(result.comments[0].body === 'This needs work.', 'First comment body matches');
assert(
typeof result.comments[0].id === 'string' && result.comments[0].id.startsWith('c-'),
'Comment ID starts with c-'
);
return result;
}
async function testAddComment(plugin, ctx, threadId) {
console.log('\n--- Add comment ---');
const comment = await plugin.actions['threads:add-comment'](
{ file: 'test.md', threadId, author: 'Bob', body: 'I agree, let me fix it.' },
ctx
);
assert(typeof comment.id === 'string' && comment.id.startsWith('c-'), 'Comment ID starts with c-');
assert(comment.author === 'Bob', 'Comment author is Bob');
assert(comment.body === 'I agree, let me fix it.', 'Comment body matches');
assert(comment.thread_id === threadId, 'Comment thread_id matches');
return comment;
}
async function testToggleReaction(plugin, ctx, threadId, commentId) {
console.log('\n--- Toggle reaction ---');
const reactions = await plugin.actions['threads:toggle-reaction'](
{ file: 'test.md', threadId, commentId, emoji: '👍', author: 'Alice' },
ctx
);
assert(Array.isArray(reactions), 'Reactions is an array');
assert(reactions.length === 1, 'One reaction');
assert(reactions[0].emoji === '👍', 'Reaction emoji is thumbs up');
assert(reactions[0].authors.includes('Alice'), 'Alice is in reaction authors');
// Add a second author to the same reaction
const reactions2 = await plugin.actions['threads:toggle-reaction'](
{ file: 'test.md', threadId, commentId, emoji: '👍', author: 'Charlie' },
ctx
);
assert(reactions2[0].authors.length === 2, 'Two authors on the reaction');
assert(reactions2[0].authors.includes('Charlie'), 'Charlie is in reaction authors');
return reactions2;
}
async function testGetThreads(plugin, ctx, threadId, secondCommentId) {
console.log('\n--- Get threads (read back) ---');
const threads = await plugin.actions['threads:get-threads'](
{ file: 'test.md' },
ctx
);
assert(Array.isArray(threads), 'Result is an array');
assert(threads.length === 1, 'One thread exists');
const thread = threads[0];
assert(thread.id === threadId, 'Thread ID matches');
assert(thread.resolved === false, 'Thread is not resolved');
assert(thread.comments.length === 2, 'Thread has two comments');
assert(thread.comments[0].author === 'Alice', 'First comment by Alice');
assert(thread.comments[1].author === 'Bob', 'Second comment by Bob');
assert(thread.comments[1].id === secondCommentId, 'Second comment ID matches');
// Check reaction persisted
const bobComment = thread.comments[1];
assert(bobComment.reactions.length === 1, 'Bob comment has one reaction');
assert(bobComment.reactions[0].emoji === '👍', 'Reaction emoji persisted');
assert(bobComment.reactions[0].authors.length === 2, 'Reaction has two authors');
return threads;
}
async function testMarkdownFileContent(ctx) {
console.log('\n--- Markdown file content ---');
const content = await ctx.readFile('test.md');
// Original content preserved
assert(content.includes('# Hello World'), 'Original heading preserved');
assert(content.includes('Some content here.'), 'Original body preserved');
// Threads block structure
assert(content.includes('::: threads'), 'File has ::: threads block');
const lines = content.split('\n');
// Find the thread line
const threadLine = lines.find((l) => l.trim().startsWith('::: thread t-'));
assert(threadLine !== undefined, 'Found thread line');
assert(threadLine.startsWith(' ::: thread'), 'Thread line starts with 2-space indent');
// Find comment lines
const commentLines = lines.filter((l) => l.trim().startsWith('::: comment c-'));
assert(commentLines.length === 2, 'Two comment lines in the file');
for (const cl of commentLines) {
assert(cl.startsWith(' ::: comment'), 'Comment line starts with 4-space indent');
// Format: ::: comment c-xxxx "author" "date"
assert(/c-[0-9a-f]{8}/.test(cl), 'Comment ID in serialized output');
assert(cl.includes('"'), 'Comment line has quoted author/date');
}
// Check reactions block is present
assert(content.includes('::: reactions'), 'Reactions block present');
assert(content.includes('👍'), 'Reaction emoji in file content');
}
async function testMarkdownItRendering(plugin) {
console.log('\n--- markdown-it rendering ---');
// Load markdown-it from the parser package
const mdPath = path.join(__dirname, '..', '..', '..', 'parser', 'node_modules', 'markdown-it', 'dist', 'index.cjs.js');
const MarkdownIt = require(mdPath);
const md = new MarkdownIt();
// Register the plugin's containers and highlight rule
plugin.markdownSetup(md, {});
// Test container rendering with the format containers.js expects
// Note: the comment container's parseCommentInfo expects "author" "date"
// (without a comment ID prefix), which is the format passed as the info string
// by createDepthTrackingContainer (everything after "::: comment").
// The serialized format from parser.js includes the ID (e.g., c-xxx "Author" "date"),
// but containers.js falls back gracefully for unknown formats.
// Test with the format the serializer actually produces (includes comment ID)
const serializedInput = [
'::: threads',
' ::: thread t-abc12345',
' ::: comment c-def67890 "Alice" "2026-01-15"',
' This is a comment.',
' :::',
' :::',
':::',
].join('\n');
const html = md.render(serializedInput);
assert(html.includes('threads-sidebar'), 'Renders threads-sidebar class');
assert(html.includes('threads-thread'), 'Renders threads-thread class');
assert(html.includes('data-thread-id="t-abc12345"'), 'Renders data-thread-id attribute');
assert(html.includes('threads-comment'), 'Renders threads-comment class');
assert(html.includes('This is a comment.'), 'Comment body rendered');
// Test with a simple format the container parser fully understands
const simpleInput = [
'::: threads',
' ::: thread t-simple01',
' ::: comment "Bob" "2026-03-01"',
' Simple comment.',
' :::',
' :::',
':::',
].join('\n');
const simpleHtml = md.render(simpleInput);
assert(simpleHtml.includes('data-author="Bob"'), 'Renders data-author for simple format');
assert(simpleHtml.includes('data-date="2026-03-01"'), 'Renders data-date for simple format');
// Test resolved thread
const resolvedInput = [
'::: threads',
' ::: thread t-res00001 resolved "Bob" "2026-02-01"',
' ::: comment "Bob" "2026-02-01"',
' Done.',
' :::',
' :::',
':::',
].join('\n');
const resolvedHtml = md.render(resolvedInput);
assert(
resolvedHtml.includes('threads-thread--resolved'),
'Resolved thread has --resolved class'
);
}
async function testHighlightRule(plugin) {
console.log('\n--- Highlight rule ---');
const mdPath = path.join(__dirname, '..', '..', '..', 'parser', 'node_modules', 'markdown-it', 'dist', 'index.cjs.js');
const MarkdownIt = require(mdPath);
const md = new MarkdownIt();
plugin.markdownSetup(md, {});
// Basic highlight with thread ID
const html1 = md.render('==highlighted text=={t-abc12345}');
assert(html1.includes('<mark'), 'Renders <mark> element');
assert(html1.includes('threads-highlight'), 'Has threads-highlight class');
assert(html1.includes('data-thread-id="t-abc12345"'), 'Has data-thread-id attribute');
assert(html1.includes('highlighted text'), 'Contains highlighted text');
// Highlight without thread ID
const html2 = md.render('==just highlighted==');
assert(html2.includes('<mark'), 'Renders <mark> without thread ID');
assert(html2.includes('threads-highlight'), 'Has threads-highlight class without thread ID');
assert(!html2.includes('data-thread-id'), 'No data-thread-id when no ID given');
// Highlight with surrounding text
const html3 = md.render('Before ==marked=={t-001} after.');
assert(html3.includes('Before'), 'Text before highlight preserved');
assert(html3.includes('after.'), 'Text after highlight preserved');
assert(html3.includes('<mark'), 'Mark tag in mixed content');
}
async function testEdgeCases(plugin, ctx) {
console.log('\n--- Edge cases ---');
// Create a second thread to test multiple threads
const thread2 = await plugin.actions['threads:add-thread'](
{ file: 'test.md', author: 'Charlie', body: 'Another discussion.' },
ctx
);
assert(thread2.id !== undefined, 'Second thread created');
const threads = await plugin.actions['threads:get-threads'](
{ file: 'test.md' },
ctx
);
assert(threads.length === 2, 'Two threads in file after adding second');
// Delete the second thread to leave file clean
await plugin.actions['threads:delete-thread'](
{ file: 'test.md', threadId: thread2.id },
ctx
);
const afterDelete = await plugin.actions['threads:get-threads'](
{ file: 'test.md' },
ctx
);
assert(afterDelete.length === 1, 'Back to one thread after deletion');
// Test editing a comment
const firstCommentId = afterDelete[0].comments[0].id;
const edited = await plugin.actions['threads:edit-comment'](
{
file: 'test.md',
threadId: afterDelete[0].id,
commentId: firstCommentId,
body: 'Updated body.',
},
ctx
);
assert(edited.body === 'Updated body.', 'Comment body updated');
assert(edited.edited_at !== null, 'edited_at set after edit');
// Verify edited_at persists in file
const content = await ctx.readFile('test.md');
assert(content.includes('edited'), 'edited marker in serialized file');
}
async function testFileWithNoExistingThreads(plugin) {
console.log('\n--- File with no existing threads ---');
// Create a separate file with no threads block
const noThreadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'threads-no-'));
const ctx2 = createMinimalCtx(noThreadDir);
fs.writeFileSync(path.join(noThreadDir, 'empty.md'), '# Empty\n\nNothing here.\n');
const threads = await plugin.actions['threads:get-threads'](
{ file: 'empty.md' },
ctx2
);
assert(threads.length === 0, 'No threads in file without threads block');
// Adding a thread creates the block
const newThread = await plugin.actions['threads:add-thread'](
{ file: 'empty.md', author: 'Dan', body: 'First thread.' },
ctx2
);
assert(newThread.id.startsWith('t-'), 'Thread created in previously empty file');
const content = await ctx2.readFile('empty.md');
assert(content.includes('# Empty'), 'Original content preserved');
assert(content.includes('::: threads'), 'Threads block appended');
// Cleanup
fs.rmSync(noThreadDir, { recursive: true, force: true });
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function run() {
// Setup temp directory and test file
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'threads-integration-'));
const testFile = path.join(tempDir, 'test.md');
fs.writeFileSync(testFile, '# Hello World\n\nSome content here.\n');
const ctx = createMinimalCtx(tempDir);
// 1. Load plugin
const plugin = await testPluginLoads();
// 2. Create a thread
const thread = await testCreateThread(plugin, ctx);
// 3. Add a comment
const comment = await testAddComment(plugin, ctx, thread.id);
// 4. Toggle reactions
await testToggleReaction(plugin, ctx, thread.id, comment.id);
// 5. Read back and verify
await testGetThreads(plugin, ctx, thread.id, comment.id);
// 6. Verify file content
await testMarkdownFileContent(ctx);
// 7. Verify markdown-it rendering
await testMarkdownItRendering(plugin);
// 8. Verify highlight rule
await testHighlightRule(plugin);
// 9. Edge cases
await testEdgeCases(plugin, ctx);
// 10. File with no existing threads
await testFileWithNoExistingThreads(plugin);
console.log(`\nAll integration tests passed. (${passed}/${total})`);
}
run()
.catch((e) => {
console.error('FAIL:', e.message, e.stack);
process.exit(1);
})
.finally(() => {
// Cleanup
if (tempDir) {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
}
});
@@ -0,0 +1,304 @@
/**
* Tests for threads parser - serialize/deserialize ::: threads block
*
* Run: node packages/plugins/threads/tests/parser.test.js
*/
const {
parseThreadsFromContent,
serializeThreadsBlock,
replaceThreadsBlock,
} = require('../src/plugin/parser.js');
let passed = 0;
let total = 0;
function assert(condition, msg) {
total++;
if (!condition) {
console.error(`FAIL: ${msg}`);
process.exit(1);
}
passed++;
console.log(` PASS: ${msg}`);
}
function assertDeepEqual(actual, expected, msg) {
const a = JSON.stringify(actual);
const e = JSON.stringify(expected);
assert(a === e, `${msg}\n expected: ${e}\n actual: ${a}`);
}
// ─── Test 1: Parse threads from content with 2 threads, comments, reactions, resolved status ───
console.log('\nTest 1: Parse full threads block');
const fullContent = `# My Document
Some content here.
::: threads
::: thread t-abc123
::: comment "alice" "2026-03-07"
This is the first comment
:::
::: comment "bob" "2026-03-08" edited "2026-03-09"
I agree, let's reword it
::: reactions
- 👍 charlie, dave
- 🎉 alice
:::
:::
:::
::: thread t-def456 resolved "charlie" "2026-03-08"
::: comment "charlie" "2026-03-08"
Typo in this section
:::
:::
:::
Some trailing content.
`;
const threads = parseThreadsFromContent(fullContent);
assert(Array.isArray(threads), 'parseThreadsFromContent returns an array');
assert(threads.length === 2, 'parses 2 threads');
// Thread 1
const t1 = threads[0];
assert(t1.id === 't-abc123', 'thread 1 id');
assert(t1.resolved === false, 'thread 1 not resolved');
assert(t1.resolved_by === null, 'thread 1 resolved_by is null');
assert(t1.resolved_at === null, 'thread 1 resolved_at is null');
assert(t1.comments.length === 2, 'thread 1 has 2 comments');
// Comment 1 of thread 1
const c1 = t1.comments[0];
assert(c1.author === 'alice', 'comment 1 author');
assert(c1.date === '2026-03-07', 'comment 1 date');
assert(c1.edited_at === null, 'comment 1 not edited');
assert(c1.body === 'This is the first comment', 'comment 1 body');
assert(c1.reactions.length === 0, 'comment 1 no reactions');
assert(c1.id.startsWith('c-'), 'comment 1 id starts with c-');
assert(c1.id.length === 10, 'comment 1 id is c- + 8 hex chars');
assert(c1.thread_id === 't-abc123', 'comment 1 thread_id matches');
// Comment 2 of thread 1
const c2 = t1.comments[1];
assert(c2.author === 'bob', 'comment 2 author');
assert(c2.date === '2026-03-08', 'comment 2 date');
assert(c2.edited_at === '2026-03-09', 'comment 2 edited_at');
assert(c2.body === "I agree, let's reword it", 'comment 2 body (no reactions content)');
assert(c2.reactions.length === 2, 'comment 2 has 2 reactions');
assert(c2.reactions[0].emoji === '👍', 'reaction 1 emoji');
assertDeepEqual(c2.reactions[0].authors, ['charlie', 'dave'], 'reaction 1 authors');
assert(c2.reactions[1].emoji === '🎉', 'reaction 2 emoji');
assertDeepEqual(c2.reactions[1].authors, ['alice'], 'reaction 2 authors');
// Thread 2
const t2 = threads[1];
assert(t2.id === 't-def456', 'thread 2 id');
assert(t2.resolved === true, 'thread 2 is resolved');
assert(t2.resolved_by === 'charlie', 'thread 2 resolved_by');
assert(t2.resolved_at === '2026-03-08', 'thread 2 resolved_at');
assert(t2.comments.length === 1, 'thread 2 has 1 comment');
assert(t2.comments[0].author === 'charlie', 'thread 2 comment author');
assert(t2.comments[0].body === 'Typo in this section', 'thread 2 comment body');
// ─── Test 2: Parse content with no threads block → empty array ───
console.log('\nTest 2: No threads block');
const noThreads = `# Just a document\n\nNo threads here.\n`;
const emptyResult = parseThreadsFromContent(noThreads);
assert(Array.isArray(emptyResult), 'returns array for no threads');
assert(emptyResult.length === 0, 'returns empty array');
// ─── Test 3: Serialize and re-parse (round-trip) ───
console.log('\nTest 3: Round-trip serialize → parse');
const threadData = [
{
id: 't-111111',
resolved: false,
resolved_by: null,
resolved_at: null,
comments: [
{
id: 'c-aabbccdd',
thread_id: 't-111111',
author: 'eve',
date: '2026-01-01',
edited_at: null,
body: 'Hello world',
reactions: [],
},
],
},
{
id: 't-222222',
resolved: true,
resolved_by: 'frank',
resolved_at: '2026-02-15',
comments: [
{
id: 'c-11223344',
thread_id: 't-222222',
author: 'frank',
date: '2026-02-10',
edited_at: '2026-02-12',
body: 'Multi-line\ncontent here',
reactions: [
{ emoji: '👍', authors: ['eve'] },
],
},
],
},
];
const serialized = serializeThreadsBlock(threadData);
assert(serialized.startsWith('::: threads\n'), 'serialized starts with ::: threads');
assert(serialized.trimEnd().endsWith(':::'), 'serialized ends with :::');
// Re-parse
const reparsed = parseThreadsFromContent(serialized);
assert(reparsed.length === 2, 'round-trip: 2 threads');
assert(reparsed[0].id === 't-111111', 'round-trip: thread 1 id');
assert(reparsed[0].resolved === false, 'round-trip: thread 1 not resolved');
assert(reparsed[0].comments[0].author === 'eve', 'round-trip: comment author');
assert(reparsed[0].comments[0].body === 'Hello world', 'round-trip: comment body');
assert(reparsed[1].id === 't-222222', 'round-trip: thread 2 id');
assert(reparsed[1].resolved === true, 'round-trip: thread 2 resolved');
assert(reparsed[1].resolved_by === 'frank', 'round-trip: resolved_by');
assert(reparsed[1].resolved_at === '2026-02-15', 'round-trip: resolved_at');
assert(reparsed[1].comments[0].author === 'frank', 'round-trip: comment 2 author');
assert(reparsed[1].comments[0].edited_at === '2026-02-12', 'round-trip: edited_at');
assert(reparsed[1].comments[0].body === 'Multi-line\ncontent here', 'round-trip: multi-line body');
assert(reparsed[1].comments[0].reactions.length === 1, 'round-trip: reactions count');
assert(reparsed[1].comments[0].reactions[0].emoji === '👍', 'round-trip: reaction emoji');
assertDeepEqual(reparsed[1].comments[0].reactions[0].authors, ['eve'], 'round-trip: reaction authors');
// ─── Test 4: Replace threads block in existing file ───
console.log('\nTest 4: Replace threads block');
const existingContent = `# Document
Some text.
::: threads
::: thread t-old
::: comment "olduser" "2025-01-01"
Old comment
:::
:::
:::
Footer text.
`;
const newThreads = [
{
id: 't-new',
resolved: false,
resolved_by: null,
resolved_at: null,
comments: [
{
id: 'c-newid000',
thread_id: 't-new',
author: 'newuser',
date: '2026-06-01',
edited_at: null,
body: 'New comment',
reactions: [],
},
],
},
];
const replaced = replaceThreadsBlock(existingContent, newThreads);
assert(replaced.includes('# Document'), 'replace preserves header');
assert(replaced.includes('Footer text.'), 'replace preserves footer');
assert(replaced.includes('t-new'), 'replace includes new thread');
assert(!replaced.includes('t-old'), 'replace removes old thread');
assert(!replaced.includes('olduser'), 'replace removes old content');
// Verify the replaced content re-parses correctly
const reparsedReplaced = parseThreadsFromContent(replaced);
assert(reparsedReplaced.length === 1, 'replaced content has 1 thread');
assert(reparsedReplaced[0].id === 't-new', 'replaced thread id correct');
// ─── Test 5: Add threads block to file that doesn't have one ───
console.log('\nTest 5: Add threads block to file without one');
const plainContent = `# My Page
Just some content.
`;
const withThreads = replaceThreadsBlock(plainContent, newThreads);
assert(withThreads.includes('# My Page'), 'add preserves original content');
assert(withThreads.includes('::: threads'), 'add includes threads block');
assert(withThreads.includes('t-new'), 'add includes thread id');
const reparsedAdded = parseThreadsFromContent(withThreads);
assert(reparsedAdded.length === 1, 'added content has 1 thread');
assert(reparsedAdded[0].id === 't-new', 'added thread id correct');
// ─── Test 6: Replace with empty threads removes the block ───
console.log('\nTest 6: Replace with empty threads removes block');
const clearedContent = replaceThreadsBlock(existingContent, []);
assert(!clearedContent.includes('::: threads'), 'empty threads removes block');
assert(clearedContent.includes('# Document'), 'preserves header after clearing');
assert(clearedContent.includes('Footer text.'), 'preserves footer after clearing');
// ─── Test 7: Comment body with multiple paragraphs ───
console.log('\nTest 7: Multi-paragraph comment body');
const multiParaContent = `::: threads
::: thread t-mp
::: comment "alice" "2026-03-01"
First paragraph.
Second paragraph.
Third paragraph.
:::
:::
:::
`;
const mpThreads = parseThreadsFromContent(multiParaContent);
assert(mpThreads.length === 1, 'multi-para: 1 thread');
assert(
mpThreads[0].comments[0].body === 'First paragraph.\n\nSecond paragraph.\n\nThird paragraph.',
'multi-para: preserves paragraphs in body'
);
// ─── Test 8: Comment IDs are unique ───
console.log('\nTest 8: Comment IDs are unique');
const idSet = new Set();
for (const thread of threads) {
for (const comment of thread.comments) {
assert(!idSet.has(comment.id), `comment id ${comment.id} is unique`);
idSet.add(comment.id);
}
}
// ─── Done ───
console.log(`\n✓ All ${passed}/${total} tests passed.\n`);