chore: import upstream snapshot with attribution
docmd CI verification / verify (push) Failing after 0s
docmd CI verification / verify (push) Failing after 0s
This commit is contained in:
@@ -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('<span') || html11.includes('&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`);
|
||||
Reference in New Issue
Block a user