Files
wehub-resource-sync 6db8fca185
docmd CI verification / verify (push) Failing after 0s
chore: import upstream snapshot with attribution
2026-07-13 12:31:55 +08:00

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);
});