335 lines
10 KiB
JavaScript
335 lines
10 KiB
JavaScript
/**
|
|
* Tests for detecting markdown formatting issues described in GitHub issue #483
|
|
* These tests identify CRLF line endings, inconsistent whitespace, and non-standard markdown formatting
|
|
*/
|
|
|
|
const fs = require('fs-extra');
|
|
const path = require('node:path');
|
|
|
|
describe('Markdown Formatting Issue Detection', () => {
|
|
const testOutputDir = path.join(__dirname, 'fixtures', 'temp', 'markdown-test-output');
|
|
|
|
beforeAll(async () => {
|
|
// Ensure test output directory exists
|
|
await fs.ensureDir(testOutputDir);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
// Clean up test files
|
|
await fs.remove(testOutputDir);
|
|
});
|
|
|
|
describe('Line Ending Detection', () => {
|
|
test('should detect CRLF line endings in generated markdown', async () => {
|
|
// Create a test file with CRLF line endings
|
|
const testContent = 'Line 1\r\nLine 2\r\nLine 3\r\n';
|
|
const testFile = path.join(testOutputDir, 'crlf-test.md');
|
|
|
|
await fs.writeFile(testFile, testContent, 'utf8');
|
|
|
|
// Read file as buffer to detect actual line endings
|
|
const buffer = await fs.readFile(testFile);
|
|
const content = buffer.toString('utf8');
|
|
|
|
// Test for CRLF presence
|
|
const hasCRLF = content.includes('\r\n');
|
|
const hasOnlyLF = !content.includes('\r\n') && content.includes('\n');
|
|
|
|
expect(hasCRLF).toBe(true);
|
|
expect(hasOnlyLF).toBe(false);
|
|
});
|
|
|
|
test('should detect LF-only line endings in normalized markdown', async () => {
|
|
// Create a test file with LF-only line endings
|
|
const testContent = 'Line 1\nLine 2\nLine 3\n';
|
|
const testFile = path.join(testOutputDir, 'lf-test.md');
|
|
|
|
await fs.writeFile(testFile, testContent, 'utf8');
|
|
|
|
// Read file as buffer to detect actual line endings
|
|
const buffer = await fs.readFile(testFile);
|
|
const content = buffer.toString('utf8');
|
|
|
|
// Test for LF-only
|
|
const hasCRLF = content.includes('\r\n');
|
|
const hasOnlyLF = !content.includes('\r\n') && content.includes('\n');
|
|
|
|
expect(hasCRLF).toBe(false);
|
|
expect(hasOnlyLF).toBe(true);
|
|
});
|
|
|
|
test('should provide utility function to normalize line endings', () => {
|
|
const normalizeLineEndings = (content) => {
|
|
return content.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
|
|
};
|
|
|
|
const crlfContent = 'Line 1\r\nLine 2\r\nLine 3\r\n';
|
|
const mixedContent = 'Line 1\r\nLine 2\nLine 3\rLine 4\n';
|
|
|
|
expect(normalizeLineEndings(crlfContent)).toBe('Line 1\nLine 2\nLine 3\n');
|
|
expect(normalizeLineEndings(mixedContent)).toBe('Line 1\nLine 2\nLine 3\nLine 4\n');
|
|
});
|
|
});
|
|
|
|
describe('Whitespace Consistency Detection', () => {
|
|
test('should detect inconsistent blank lines around headings', () => {
|
|
const inconsistentMarkdown = `# Title
|
|
|
|
|
|
## Section 1
|
|
|
|
Content here.
|
|
|
|
## Section 2
|
|
|
|
|
|
Content here.
|
|
|
|
|
|
## Section 3
|
|
Content here.`;
|
|
|
|
const lines = inconsistentMarkdown.split('\n');
|
|
const headingLines = [];
|
|
|
|
for (const [index, line] of lines.entries()) {
|
|
if (/^#{1,6}\s/.test(line)) {
|
|
headingLines.push({
|
|
line: index + 1,
|
|
content: line,
|
|
blankLinesBefore: countBlankLinesBefore(lines, index),
|
|
blankLinesAfter: countBlankLinesAfter(lines, index),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check for inconsistent spacing
|
|
const spacingInconsistencies = headingLines.filter((heading) => {
|
|
// H1 should have 0 blank lines before (if first) or 2 before
|
|
// H2-H6 should have 1 blank line before and 1 after
|
|
return heading.content.startsWith('# ') ? heading.blankLinesBefore > 2 : heading.blankLinesBefore !== 1 || heading.blankLinesAfter !== 1;
|
|
});
|
|
|
|
expect(spacingInconsistencies.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('should detect trailing whitespace', () => {
|
|
const markdownWithTrailingSpaces = `# Title
|
|
Content with trailing spaces.
|
|
|
|
## Section
|
|
More content.
|
|
`;
|
|
|
|
const lines = markdownWithTrailingSpaces.split('\n');
|
|
const linesWithTrailingWhitespace = lines
|
|
.map((line, index) => ({ line: index + 1, content: line }))
|
|
.filter((item) => item.content.match(/\s+$/));
|
|
|
|
expect(linesWithTrailingWhitespace.length).toBeGreaterThan(0);
|
|
expect(linesWithTrailingWhitespace[0].line).toBe(1); // Title has trailing spaces
|
|
});
|
|
|
|
test('should validate consistent list formatting', () => {
|
|
const inconsistentList = `## Tasks
|
|
|
|
- Task 1
|
|
- Subtask 1.1
|
|
- Subtask 1.2
|
|
- Task 2
|
|
- Subtask 2.1 (wrong indentation)
|
|
- Task 3
|
|
- Subtask 3.1 (tab instead of spaces)
|
|
`;
|
|
|
|
const lines = inconsistentList.split('\n');
|
|
const listItems = lines.map((line, index) => ({ line: index + 1, content: line })).filter((item) => item.content.match(/^\s*-\s/));
|
|
|
|
// Check for inconsistent indentation
|
|
const indentationIssues = listItems.filter((item) => {
|
|
const match = item.content.match(/^(\s*)-/);
|
|
if (match) {
|
|
const indent = match[1];
|
|
// Should be either no indent (0) or multiples of 2 spaces
|
|
return indent.length > 0 && (indent.length % 2 !== 0 || indent.includes('\t'));
|
|
}
|
|
return false;
|
|
});
|
|
|
|
expect(indentationIssues.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('GFM Compliance Detection', () => {
|
|
test('should detect improper heading hierarchy', () => {
|
|
const badHierarchy = `# Main Title
|
|
|
|
### Skipped H2 - This is wrong
|
|
|
|
## Proper H2
|
|
|
|
##### Skipped H3 and H4 - This is also wrong
|
|
|
|
#### Back to H4
|
|
`;
|
|
|
|
const lines = badHierarchy.split('\n');
|
|
const headings = lines
|
|
.map((line, index) => ({ line: index + 1, content: line }))
|
|
.filter((item) => item.content.match(/^#{1,6}\s/))
|
|
.map((item) => ({
|
|
...item,
|
|
level: item.content.match(/^(#{1,6})/)[1].length,
|
|
}));
|
|
|
|
// Check for hierarchy violations (skipping levels)
|
|
const hierarchyViolations = [];
|
|
for (let i = 1; i < headings.length; i++) {
|
|
const prev = headings[i - 1];
|
|
const curr = headings[i];
|
|
|
|
// If current level is more than 1 level deeper than previous, it's a violation
|
|
if (curr.level > prev.level + 1) {
|
|
hierarchyViolations.push({
|
|
line: curr.line,
|
|
issue: `H${curr.level} follows H${prev.level}, skipping intermediate levels`,
|
|
});
|
|
}
|
|
}
|
|
|
|
expect(hierarchyViolations.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('should detect improperly formatted code blocks', () => {
|
|
const badCodeBlocks = `## Code Examples
|
|
|
|
\`\`\`
|
|
// No language specified - should be javascript or text
|
|
const example = 'test';
|
|
\`\`\`
|
|
|
|
\`\`\`bash
|
|
# This is good
|
|
echo "Hello World"
|
|
\`\`\`
|
|
|
|
\`\`\`yaml
|
|
# This is also good
|
|
key: value
|
|
\`\`\`
|
|
`;
|
|
|
|
const codeBlockMatches = [...badCodeBlocks.matchAll(/```(\w+)?\n/g)];
|
|
const blocksWithoutLanguage = codeBlockMatches.filter((match) => !match[1]);
|
|
|
|
expect(blocksWithoutLanguage.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('should detect smart quotes and other problematic characters', () => {
|
|
const smartQuotesText = `# Title with \u201Csmart quotes\u201D and \u2018curly quotes\u2019
|
|
|
|
Content with em-dash \u2014 and ellipses\u2026 and other problematic characters.
|
|
|
|
- Bullet with \u201Cquote\u201D
|
|
- Another bullet with \u2018single quotes\u2019
|
|
`;
|
|
|
|
const problematicChars = [
|
|
{ char: '\u201C', name: 'left double quote' },
|
|
{ char: '\u201D', name: 'right double quote' },
|
|
{ char: '\u2018', name: 'left single quote' },
|
|
{ char: '\u2019', name: 'right single quote' },
|
|
{ char: '\u2014', name: 'em dash' },
|
|
{ char: '\u2026', name: 'ellipsis' },
|
|
];
|
|
|
|
const foundProblematicChars = problematicChars.filter((item) => smartQuotesText.includes(item.char));
|
|
|
|
expect(foundProblematicChars.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('Story Template Specific Issues', () => {
|
|
test('should detect QA Results section formatting issues', () => {
|
|
const storyWithQAResults = `# Story 1.1: Project Setup
|
|
|
|
## QA Results
|
|
|
|
### Review Summary:
|
|
|
|
The story "Project Initialization & Setup" (Story 1.1) is well-defined and covers the essential setup for a new Next.js 15 application.
|
|
|
|
### Acceptance Criteria Review:
|
|
- ✓ Criterion 1 met
|
|
- ✓ Criterion 2 met
|
|
`;
|
|
|
|
// This simulates the exact-match replacement issue from the GitHub issue
|
|
const targetString = `## QA Results\n\n### Review Summary:\n\nThe story "Project Initialization`;
|
|
const found = storyWithQAResults.includes(targetString);
|
|
|
|
// If this fails, it indicates the whitespace/formatting issue
|
|
expect(found).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// Helper functions
|
|
function countBlankLinesBefore(lines, currentIndex) {
|
|
let count = 0;
|
|
for (let i = currentIndex - 1; i >= 0; i--) {
|
|
if (lines[i].trim() === '') {
|
|
count++;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function countBlankLinesAfter(lines, currentIndex) {
|
|
let count = 0;
|
|
for (let i = currentIndex + 1; i < lines.length; i++) {
|
|
if (lines[i].trim() === '') {
|
|
count++;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
module.exports = {
|
|
normalizeLineEndings: (content) => content.replaceAll('\r\n', '\n').replaceAll('\r', '\n'),
|
|
detectTrailingWhitespace: (content) => {
|
|
return content
|
|
.split('\n')
|
|
.map((line, index) => ({ line: index + 1, content: line }))
|
|
.filter((item) => item.content.match(/\s+$/));
|
|
},
|
|
validateHeadingHierarchy: (content) => {
|
|
const lines = content.split('\n');
|
|
const headings = lines
|
|
.map((line, index) => ({ line: index + 1, content: line }))
|
|
.filter((item) => item.content.match(/^#{1,6}\s/))
|
|
.map((item) => ({
|
|
...item,
|
|
level: item.content.match(/^(#{1,6})/)[1].length,
|
|
}));
|
|
|
|
const violations = [];
|
|
for (let i = 1; i < headings.length; i++) {
|
|
const prev = headings[i - 1];
|
|
const curr = headings[i];
|
|
|
|
if (curr.level > prev.level + 1) {
|
|
violations.push({
|
|
line: curr.line,
|
|
issue: `H${curr.level} follows H${prev.level}, skipping intermediate levels`,
|
|
});
|
|
}
|
|
}
|
|
return violations;
|
|
},
|
|
};
|