/** * 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(' 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(' 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(' { 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 } } });