414 lines
16 KiB
JavaScript
414 lines
16 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|