import {markdownToBlocks} from '../src';
import * as slack from '../src/slack';
import type {TableBlock, RichTextBlock} from '../src/slack';
describe('integration with unified', () => {
it('should parse raw markdown into slack blocks', async () => {
const text = `
a **b** _c_ **_d_ e**
# heading **a**

> block quote **a**
> block quote b
[link](https://apple.com)
- bullet _a_
- bullet _b_
1. number _a_
2. number _b_
- [ ] checkbox false
- [x] checkbox true
| Syntax | Description |
| ----------- | ----------- |
| Header | Title |
| Paragraph | Text |
`;
const actual = await markdownToBlocks(text);
const expected = [
slack.section('a *b* _c_ *_d_ e*'),
slack.header('heading a'),
slack.image(
'https://user-images.githubusercontent.com/16073505/123464383-b8715300-d5ba-11eb-8586-b1f965e1f18d.jpg',
'59953191-480px'
),
slack.image(
'https://user-images.githubusercontent.com/16073505/123464383-b8715300-d5ba-11eb-8586-b1f965e1f18d.jpg',
'59953191-480px'
),
slack.richTextQuote([
{type: 'text', text: 'block quote '},
{type: 'text', text: 'a', style: {bold: true}},
{type: 'text', text: '\nblock quote b'},
]),
slack.section(' '),
slack.richTextList(
[
{
type: 'rich_text_section',
elements: [
{type: 'text', text: 'bullet '},
{type: 'text', text: 'a', style: {italic: true}},
],
},
{
type: 'rich_text_section',
elements: [
{type: 'text', text: 'bullet '},
{type: 'text', text: 'b', style: {italic: true}},
],
},
],
'bullet'
),
slack.richTextList(
[
{
type: 'rich_text_section',
elements: [
{type: 'text', text: 'number '},
{type: 'text', text: 'a', style: {italic: true}},
],
},
{
type: 'rich_text_section',
elements: [
{type: 'text', text: 'number '},
{type: 'text', text: 'b', style: {italic: true}},
],
},
],
'ordered'
),
slack.richTextList(
[
{
type: 'rich_text_section',
elements: [
{type: 'text', text: '☐ '},
{type: 'text', text: 'checkbox false'},
],
},
{
type: 'rich_text_section',
elements: [
{type: 'text', text: '✅ '},
{type: 'text', text: 'checkbox true'},
],
},
],
'bullet'
),
slack.table([
[
{type: 'raw_text', text: 'Syntax'},
{type: 'raw_text', text: 'Description'},
],
[
{type: 'raw_text', text: 'Header'},
{type: 'raw_text', text: 'Title'},
],
[
{type: 'raw_text', text: 'Paragraph'},
{type: 'raw_text', text: 'Text'},
],
]),
];
expect(actual).toStrictEqual(expected);
});
it('should parse long markdown', async () => {
const text: string = new Array(3500).fill('a').join('') + 'bbbcccdddeee';
const actual = await markdownToBlocks(text);
const expected = [slack.section(text.slice(0, 3000))];
expect(actual).toStrictEqual(expected);
});
describe('code blocks', () => {
it('should parse code blocks with no language', async () => {
const text = `\`\`\`
if (a === 'hi') {
console.log('hi!')
} else {
console.log('hello')
}
\`\`\``;
const actual = await markdownToBlocks(text);
const expected = [
slack.richTextCode(`if (a === 'hi') {
console.log('hi!')
} else {
console.log('hello')
}`),
];
expect(actual).toStrictEqual(expected);
});
it('should parse code blocks with language', async () => {
const text = `\`\`\`javascript
if (a === 'hi') {
console.log('hi!')
} else {
console.log('hello')
}
\`\`\``;
const actual = await markdownToBlocks(text);
const expected = [
slack.richTextCode(`if (a === 'hi') {
console.log('hi!')
} else {
console.log('hello')
}`),
];
expect(actual).toStrictEqual(expected);
});
});
it('should correctly escape text', async () => {
const actual = await markdownToBlocks('<>&\'""\'&><');
const expected = [slack.section('<>&\'""\'&><')];
expect(actual).toStrictEqual(expected);
});
});
describe('robustness - error handling', () => {
it('should throw ValidationError for null input', async () => {
await expect(markdownToBlocks(null as unknown as string)).rejects.toThrow(
'Input cannot be null or undefined'
);
});
it('should throw ValidationError for undefined input', async () => {
await expect(
markdownToBlocks(undefined as unknown as string)
).rejects.toThrow('Input cannot be null or undefined');
});
it('should throw ValidationError for empty string', async () => {
await expect(markdownToBlocks('')).rejects.toThrow('Input cannot be empty');
});
it('should throw ValidationError for input exceeding size limit', async () => {
const tooLargeInput = new Array(1_000_001).fill('a').join('');
await expect(markdownToBlocks(tooLargeInput)).rejects.toThrow(
'exceeds maximum'
);
});
it('should throw ValidationError for block count exceeding limit', async () => {
// Create markdown that generates more than 50 blocks
const manyBlocksMarkdown = new Array(60)
.fill(0)
.map((_, i) => `Paragraph ${i}`)
.join('\n\n');
await expect(markdownToBlocks(manyBlocksMarkdown)).rejects.toThrow(
'Block count'
);
});
it('should throw ValidationError for invalid image URL in absolute form', async () => {
expect(() => {
slack.image('http://', 'alt');
}).toThrow('Invalid image URL');
});
it('should accept relative image URLs', () => {
const result = slack.image('/path/to/image.png', 'alt');
expect(result.image_url).toBe('/path/to/image.png');
});
it('should accept arbitrary URLs for backward compatibility', () => {
const result = slack.image('custom-url-string', 'alt');
expect(result.image_url).toBe('custom-url-string');
});
it('should throw ValidationError for non-string section text', () => {
expect(() => {
slack.section(123 as unknown as string);
}).toThrow('Section text must be a string');
});
it('should throw ValidationError for non-string header text', () => {
expect(() => {
slack.header(123 as unknown as string);
}).toThrow('Header text must be a string');
});
it('should throw ValidationError for empty image URL', () => {
expect(() => {
slack.image('', 'alt');
}).toThrow('Image URL must be a non-empty string');
});
});
describe('robustness - text truncation', () => {
it('should safely truncate text at 3000 characters', async () => {
const text = new Array(3500).fill('a').join('');
const result = await markdownToBlocks(text);
expect(result[0]).toBeDefined();
if ('text' in result[0] && result[0].text) {
expect(
(result[0].text as {text: string}).text.length
).toBeLessThanOrEqual(3000);
}
});
it('should safely truncate header at 150 characters', () => {
const longHeader = new Array(200).fill('a').join('');
const result = slack.header(longHeader);
expect(result.text.text.length).toBeLessThanOrEqual(150);
});
it('should safely truncate image alt text at 2000 characters', () => {
const longAlt = new Array(2500).fill('a').join('');
const result = slack.image('https://example.com/img.png', longAlt);
expect(result.alt_text.length).toBeLessThanOrEqual(2000);
});
it('should handle multi-byte UTF-8 characters correctly', () => {
// Emoji and multi-byte characters should truncate safely
const emoji = '😀'.repeat(2000);
const result = slack.section(emoji);
// Should not throw and result should be valid
expect(result).toBeDefined();
expect(result.text?.text).toBeDefined();
});
});
describe('robustness - nested content support', () => {
it('should parse nested lists', async () => {
const markdown = `
- Item 1
- Nested item 1.1
- Nested item 1.2
- Item 2
`;
const result = await markdownToBlocks(markdown);
expect(result.length).toBeGreaterThan(0);
// Should contain nested list content
const content = result[0];
if ('text' in content && content.text) {
const text = (content.text as {text: string}).text;
expect(text).toContain('Nested');
}
});
it('should parse blockquotes with lists', async () => {
const markdown = `
> - List item in blockquote
> - Another item
`;
const result = await markdownToBlocks(markdown);
expect(result.length).toBeGreaterThan(0);
});
it('should parse blockquotes with code blocks', async () => {
const markdown = `
> \`\`\`
> code in blockquote
> \`\`\`
`;
const result = await markdownToBlocks(markdown);
expect(result.length).toBeGreaterThan(0);
});
it('should parse nested blockquotes', async () => {
const markdown = `
> Outer blockquote
> > Nested blockquote
`;
const result = await markdownToBlocks(markdown);
expect(result.length).toBeGreaterThan(0);
});
});
describe('robustness - URL validation', () => {
it('should validate http URLs', async () => {
const markdown = '';
const result = await markdownToBlocks(markdown);
expect(result.length).toBeGreaterThan(0);
});
it('should validate https URLs', async () => {
const markdown = '';
const result = await markdownToBlocks(markdown);
expect(result.length).toBeGreaterThan(0);
});
it('should handle invalid URLs gracefully', async () => {
// Malformed absolute URL should skip the image
const markdown = '';
const result = await markdownToBlocks(markdown);
// Should not throw
expect(result).toBeDefined();
});
it('should validate links with invalid URLs', async () => {
const markdown = '[text](http://)';
const result = await markdownToBlocks(markdown);
// Should return link text without URL formatting
if ('text' in result[0] && result[0].text) {
const text = (result[0].text as {text: string}).text;
expect(text).toContain('text');
}
});
it('should handle data: URLs for images', async () => {
const markdown = '';
const result = await markdownToBlocks(markdown);
expect(result.length).toBeGreaterThan(0);
});
it('should reject non-image data: URLs', () => {
expect(() => {
slack.image('data:text/plain;base64,aGVsbG8=', 'alt');
}).toThrow('Invalid image URL');
});
});
describe('robustness - HTML parsing security', () => {
it('should handle malformed HTML gracefully', async () => {
const markdown =
'
';
const result = await markdownToBlocks(markdown);
// Should not throw and should handle gracefully
expect(result).toBeDefined();
});
it('should extract images from valid HTML', async () => {
const markdown =
'
';
const result = await markdownToBlocks(markdown);
expect(result.length).toBeGreaterThan(0);
});
it('should filter out images with invalid URLs from HTML', async () => {
const markdown = '
';
const result = await markdownToBlocks(markdown);
// Should handle gracefully without throwing
expect(result).toBeDefined();
});
});
describe('robustness - recursion depth protection', () => {
it('should handle deeply nested formatting', async () => {
// Create deeply nested emphasis/strong formatting
let markdown = 'text';
for (let i = 0; i < 40; i++) {
markdown = `**_${markdown}_**`;
}
const result = await markdownToBlocks(markdown);
// Should not throw stack overflow
expect(result).toBeDefined();
});
});
describe('table block support', () => {
it('should parse simple markdown tables as native table blocks', async () => {
const markdown = `
| Header A | Header B |
|----------|----------|
| Cell 1A | Cell 1B |
| Cell 2A | Cell 2B |
`;
const result = await markdownToBlocks(markdown);
expect(result.length).toBe(1);
const tableBlock = result[0] as TableBlock;
expect(tableBlock.type).toBe('table');
expect(tableBlock.rows).toBeDefined();
expect(tableBlock.rows.length).toBe(3); // header + 2 data rows
});
it('should support column alignment in tables', async () => {
const markdown = `
| Left | Center | Right |
|:-----|:------:|------:|
| A | B | C |
`;
const result = await markdownToBlocks(markdown);
const tableBlock = result[0] as TableBlock;
expect(tableBlock.type).toBe('table');
expect(tableBlock.column_settings).toBeDefined();
// First column default (left), second center, third right
expect(tableBlock.column_settings).toEqual([
{align: 'center'}, // Column 2
{align: 'right'}, // Column 3
]);
});
it('should use rich text for formatted table cells', async () => {
const markdown = `
| Plain | **Bold** | *Italic* | [Link](https://slack.com) |
|-------|----------|----------|---------------------------|
| text | **bold** | _em_ | [link](https://example.com) |
`;
const result = await markdownToBlocks(markdown);
const tableBlock = result[0] as TableBlock;
expect(tableBlock.type).toBe('table');
// Check header row
const headerRow = tableBlock.rows[0];
expect(headerRow[0].type).toBe('raw_text'); // Plain text
expect(headerRow[1].type).toBe('rich_text'); // Bold text
expect(headerRow[2].type).toBe('rich_text'); // Italic text
expect(headerRow[3].type).toBe('rich_text'); // Link
// Check formatted cells have proper structure
const boldCell = headerRow[1];
expect(boldCell.elements?.[0].type).toBe('rich_text_section');
expect(boldCell.elements?.[0].elements[0].style?.bold).toBe(true);
});
it('should handle inline code in table cells', async () => {
const markdown = `
| Code | Description |
|------|-------------|
| \`npm install\` | Install dependencies |
| \`npm test\` | Run tests |
`;
const result = await markdownToBlocks(markdown);
const tableBlock = result[0] as TableBlock;
expect(tableBlock.type).toBe('table');
// Check code cells use rich text with code style
const codeCell = tableBlock.rows[1][0];
expect(codeCell.type).toBe('rich_text');
expect(codeCell.elements?.[0].elements[0].style?.code).toBe(true);
});
it('should handle strikethrough in table cells', async () => {
const markdown = `
| Task | Status |
|------|--------|
| ~~Old task~~ | Complete |
| New task | Pending |
`;
const result = await markdownToBlocks(markdown);
const tableBlock = result[0] as TableBlock;
expect(tableBlock.type).toBe('table');
// Check strikethrough cell uses rich text
const strikeCell = tableBlock.rows[1][0];
expect(strikeCell.type).toBe('rich_text');
expect(strikeCell.elements?.[0].elements[0].style?.strike).toBe(true);
});
it('should validate table size limits', () => {
// Test row limit (max 100)
const tooManyRows = new Array(101).fill(['Cell']);
expect(() => {
slack.table(tooManyRows);
}).toThrow('cannot have more than 100 rows');
// Test cells per row limit (max 20)
const tooManyCells = [
new Array(21).fill({type: 'raw_text', text: 'Cell'}),
] as slack.TableRow[];
expect(() => {
slack.table(tooManyCells);
}).toThrow('cannot have more than 20 cells');
});
it('should validate table cell structure', () => {
// Invalid cell type
const invalidCell = [
[{type: 'invalid', text: 'test'}],
] as unknown as slack.TableRow[];
expect(() => {
slack.table(invalidCell);
}).toThrow('must have type');
// Missing text in raw_text cell
const missingText = [[{type: 'raw_text'}]] as unknown as slack.TableRow[];
expect(() => {
slack.table(missingText);
}).toThrow('must have a text property');
// Missing elements in rich_text cell
const missingElements = [
[{type: 'rich_text'}],
] as unknown as slack.TableRow[];
expect(() => {
slack.table(missingElements);
}).toThrow('must have an elements array');
});
it('should validate column settings', () => {
const validRows: slack.TableRow[] = [[{type: 'raw_text', text: 'Cell'}]];
// Invalid alignment
const invalidAlign = [
{align: 'invalid' as unknown},
] as slack.ColumnSetting[];
expect(() => {
slack.table(validRows, invalidAlign);
}).toThrow('must be left, center, or right');
// Too many column settings (max 20)
const tooManySettings = new Array(21).fill({
align: 'center',
}) as slack.ColumnSetting[];
expect(() => {
slack.table(validRows, tooManySettings);
}).toThrow('Cannot have more than 20 column settings');
});
it('should handle tables with mixed content gracefully', async () => {
const markdown = `
| Mixed | Content |
|-------|---------|
| Plain text | **Bold** with _italic_ and [link](https://example.com) |
| \`code\` | ~~strike~~ text |
`;
const result = await markdownToBlocks(markdown);
const tableBlock = result[0] as TableBlock;
expect(tableBlock.type).toBe('table');
expect(tableBlock.rows.length).toBe(3);
// Complex cell should be rich_text
const complexCell = tableBlock.rows[1][1];
expect(complexCell.type).toBe('rich_text');
});
it('should parse HTML tables as native table blocks', async () => {
const markdown = `
| Header 1 |
Header 2 |
| Cell 1A |
Cell 1B |
| Cell 2A |
Cell 2B |
`;
const result = await markdownToBlocks(markdown);
expect(result.length).toBe(1);
const tableBlock = result[0] as TableBlock;
expect(tableBlock.type).toBe('table');
expect(tableBlock.rows).toBeDefined();
expect(tableBlock.rows.length).toBe(3); // 1 header + 2 data rows
expect(tableBlock.rows[0][0].text).toBe('Header 1');
expect(tableBlock.rows[1][0].text).toBe('Cell 1A');
});
it('should handle HTML tables without thead', async () => {
const markdown = `
| Row 1 Cell 1 |
Row 1 Cell 2 |
| Row 2 Cell 1 |
Row 2 Cell 2 |
`;
const result = await markdownToBlocks(markdown);
const tableBlock = result[0] as TableBlock;
expect(tableBlock.type).toBe('table');
expect(tableBlock.rows.length).toBe(2);
expect(tableBlock.rows[0][0].text).toBe('Row 1 Cell 1');
});
it('should handle HTML table alignment attributes', async () => {
// Note: marked may not parse complex HTML tables with colgroup
// This test focuses on cell-level alignment which is more commonly supported
const markdown = ``;
const result = await markdownToBlocks(markdown);
// Check if marked parsed it as HTML
if (result.length > 0 && result[0].type === 'table') {
const tableBlock = result[0] as TableBlock;
expect(tableBlock.column_settings).toBeDefined();
expect(tableBlock.column_settings).toEqual([
{align: 'center'},
{align: 'right'},
]);
} else {
// Marked might not parse all HTML tables - that's ok
expect(result).toBeDefined();
}
});
it('should handle HTML table with cell alignment attributes', async () => {
const markdown = `
`;
const result = await markdownToBlocks(markdown);
const tableBlock = result[0] as TableBlock;
expect(tableBlock.type).toBe('table');
expect(tableBlock.column_settings).toBeDefined();
expect(tableBlock.column_settings).toEqual([
{align: 'center'},
{align: 'right'},
]);
});
it('should handle nested elements in HTML table cells', async () => {
const markdown = `
| Bold text |
Italic text |
Code text |
`;
const result = await markdownToBlocks(markdown);
const tableBlock = result[0] as TableBlock;
expect(tableBlock.type).toBe('table');
expect(tableBlock.rows[0][0].text).toBe('Bold text');
expect(tableBlock.rows[0][1].text).toBe('Italic text');
expect(tableBlock.rows[0][2].text).toBe('Code text');
});
it('should handle mixed markdown and HTML tables', async () => {
const markdown = `
# Document with Tables
| Markdown | Table |
|----------|-------|
| Cell 1 | Cell 2 |
`;
const result = await markdownToBlocks(markdown);
// Should have: header, markdown table, html table
expect(result.length).toBe(3);
expect(result[0].type).toBe('header');
expect(result[1].type).toBe('table');
expect(result[2].type).toBe('table');
const mdTable = result[1] as TableBlock;
expect(mdTable.rows[0][0].text).toBe('Markdown');
const htmlTable = result[2] as TableBlock;
expect(htmlTable.rows[0][0].text).toBe('HTML');
});
it('should handle empty HTML tables gracefully', async () => {
const markdown = '';
const result = await markdownToBlocks(markdown);
// Empty tables should be skipped
expect(result.length).toBe(0);
});
it('should handle malformed HTML tables gracefully', async () => {
const markdown = `
`;
// Should not throw, just handle gracefully
const result = await markdownToBlocks(markdown);
expect(result).toBeDefined();
});
// GitHub Issues Tests
describe('GitHub Issues', () => {
// Issue #24: Nested bulleted lists should be rendered
it('#24 - should render nested bulleted lists', async () => {
const markdown = `- asdf
- asdf2`;
const result = await markdownToBlocks(markdown);
expect(result.length).toBe(1);
const richTextBlock = result[0] as RichTextBlock;
expect(richTextBlock.type).toBe('rich_text');
// Should have a rich_text_list element
expect(richTextBlock.elements.length).toBeGreaterThan(0);
expect(richTextBlock.elements[0].type).toBe('rich_text_list');
// Note: Nested lists are currently flattened - this is a known limitation
// The list should contain both items
const listElement = richTextBlock.elements[0];
if (listElement.type === 'rich_text_list') {
expect(listElement.elements.length).toBeGreaterThan(0);
}
});
// Issue #25: Blockquote should support single-line quotes
it('#25 - should support single-line blockquotes', async () => {
const markdown = '> This is a blockquote';
const result = await markdownToBlocks(markdown);
expect(result.length).toBeGreaterThan(0);
const richTextBlock = result[0] as RichTextBlock;
expect(richTextBlock.type).toBe('rich_text');
// Should have a rich_text_quote element
expect(richTextBlock.elements.length).toBeGreaterThan(0);
expect(richTextBlock.elements[0].type).toBe('rich_text_quote');
});
// Issue #26: Quote characters should not be HTML-escaped
it('#26 - should not HTML-escape quote characters', async () => {
const markdown = "I've just completed the 'Research Report' workflow";
const result = await markdownToBlocks(markdown);
expect(result.length).toBeGreaterThan(0);
const section = result[0];
expect(section.type).toBe('section');
if (
section.type === 'section' &&
section.text &&
'text' in section.text
) {
// Should contain actual quote characters, not HTML entities
expect(section.text.text).toContain("I've");
expect(section.text.text).not.toContain(''');
expect(section.text.text).toContain("'Research Report'");
}
});
// Issue #27: Divider block under list should not turn list into header
it('#27 - should handle divider after list correctly', async () => {
const markdown = `Test
- test 1
- test 2
---
test3`;
const result = await markdownToBlocks(markdown);
// Should have at least: section/header for "Test", section for list, divider, section for "test3"
expect(result.length).toBeGreaterThanOrEqual(3);
// First should be section or header with "Test"
expect(result[0].type).toMatch(/^(section|header)$/);
// Should have a divider somewhere
const hasDivider = result.some(block => block.type === 'divider');
expect(hasDivider).toBe(true);
});
// Issue #23: marked.lexer runtime error - verify API works correctly
it('#23 - should not throw marked.lexer runtime error', async () => {
const markdown = `# Test
This is a paragraph`;
// Should not throw "Cannot read properties of undefined (reading 'lexer')"
const result = await markdownToBlocks(markdown);
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
});
});
});