init
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* Tests for dashboard assets
|
||||
* HTML template structure, CSS syntax, JS functions
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
const assetsDir = path.join(__dirname, '..', 'assets');
|
||||
const templatePath = path.join(assetsDir, 'dashboard-template.html');
|
||||
const cssPath = path.join(assetsDir, 'dashboard.css');
|
||||
const jsPath = path.join(assetsDir, 'dashboard.js');
|
||||
|
||||
describe('dashboard-template.html', () => {
|
||||
let htmlContent;
|
||||
|
||||
before(() => {
|
||||
assert(fs.existsSync(templatePath), `Template file not found: ${templatePath}`);
|
||||
htmlContent = fs.readFileSync(templatePath, 'utf8');
|
||||
});
|
||||
|
||||
it('should be valid HTML5', () => {
|
||||
assert(htmlContent.includes('<!DOCTYPE html'));
|
||||
assert(htmlContent.includes('<html'));
|
||||
assert(htmlContent.includes('</html>'));
|
||||
});
|
||||
|
||||
it('should have proper head section', () => {
|
||||
assert(htmlContent.includes('<head>'));
|
||||
assert(htmlContent.includes('<meta charset="UTF-8">'));
|
||||
assert(htmlContent.includes('<meta name="viewport"'));
|
||||
assert(htmlContent.includes('</head>'));
|
||||
});
|
||||
|
||||
it('should have title element', () => {
|
||||
assert(htmlContent.includes('<title>'));
|
||||
assert(htmlContent.includes('Plans Dashboard'));
|
||||
});
|
||||
|
||||
it('should link required CSS files', () => {
|
||||
assert(htmlContent.includes('novel-theme.css'));
|
||||
assert(htmlContent.includes('dashboard.css'));
|
||||
});
|
||||
|
||||
it('should have main content area', () => {
|
||||
assert(htmlContent.includes('<main'));
|
||||
assert(htmlContent.includes('role="main"'));
|
||||
assert(htmlContent.includes('aria-label="Plans Dashboard"'));
|
||||
});
|
||||
|
||||
it('should have dashboard header', () => {
|
||||
assert(htmlContent.includes('class="dashboard-header"'));
|
||||
assert(htmlContent.includes('<h1>Plans Dashboard</h1>'));
|
||||
});
|
||||
|
||||
it('should have theme toggle button', () => {
|
||||
assert(htmlContent.includes('id="theme-toggle"'));
|
||||
assert(htmlContent.includes('aria-label="Toggle theme"'));
|
||||
});
|
||||
|
||||
it('should have search input', () => {
|
||||
assert(htmlContent.includes('id="plan-search"'));
|
||||
assert(htmlContent.includes('type="search"'));
|
||||
assert(htmlContent.includes('placeholder="Search plans..."'));
|
||||
});
|
||||
|
||||
it('should have sort select', () => {
|
||||
assert(htmlContent.includes('id="sort-select"'));
|
||||
assert(htmlContent.includes('value="date-desc"'));
|
||||
assert(htmlContent.includes('value="name-asc"'));
|
||||
});
|
||||
|
||||
it('should have filter pills', () => {
|
||||
assert(htmlContent.includes('class="filter-pills"'));
|
||||
assert(htmlContent.includes('data-filter="all"'));
|
||||
assert(htmlContent.includes('data-filter="completed"'));
|
||||
assert(htmlContent.includes('data-filter="in-progress"'));
|
||||
assert(htmlContent.includes('data-filter="pending"'));
|
||||
});
|
||||
|
||||
it('should have plans grid section', () => {
|
||||
assert(htmlContent.includes('class="plans-grid"'));
|
||||
assert(htmlContent.includes('aria-label="Plans list"'));
|
||||
});
|
||||
|
||||
it('should have template placeholders', () => {
|
||||
assert(htmlContent.includes('{{plans-grid}}'));
|
||||
assert(htmlContent.includes('{{plan-count}}'));
|
||||
assert(htmlContent.includes('{{plans-json}}'));
|
||||
assert(htmlContent.includes('{{empty-state}}'));
|
||||
});
|
||||
|
||||
it('should have loading skeleton', () => {
|
||||
assert(htmlContent.includes('class="loading-skeleton"'));
|
||||
assert(htmlContent.includes('class="skeleton-card"'));
|
||||
});
|
||||
|
||||
it('should have screen reader announcements', () => {
|
||||
assert(htmlContent.includes('id="sr-announce"'));
|
||||
assert(htmlContent.includes('aria-live="polite"'));
|
||||
});
|
||||
|
||||
it('should embed plans JSON', () => {
|
||||
assert(htmlContent.includes('window.__plans'));
|
||||
});
|
||||
|
||||
it('should load dashboard.js', () => {
|
||||
assert(htmlContent.includes('src="/assets/dashboard.js"'));
|
||||
});
|
||||
|
||||
it('should have proper closing tags', () => {
|
||||
const openMain = (htmlContent.match(/<main/g) || []).length;
|
||||
const closeMain = (htmlContent.match(/<\/main>/g) || []).length;
|
||||
assert.strictEqual(openMain, closeMain, 'Mismatched main tags');
|
||||
|
||||
const openBody = (htmlContent.match(/<body/g) || []).length;
|
||||
const closeBody = (htmlContent.match(/<\/body>/g) || []).length;
|
||||
assert.strictEqual(openBody, closeBody, 'Mismatched body tags');
|
||||
});
|
||||
|
||||
it('should have data-theme attribute on html', () => {
|
||||
assert(htmlContent.includes('data-theme='));
|
||||
});
|
||||
});
|
||||
|
||||
describe('dashboard.css', () => {
|
||||
let cssContent;
|
||||
|
||||
before(() => {
|
||||
assert(fs.existsSync(cssPath), `CSS file not found: ${cssPath}`);
|
||||
cssContent = fs.readFileSync(cssPath, 'utf8');
|
||||
});
|
||||
|
||||
it('should have valid CSS syntax', () => {
|
||||
// Basic check: should have selectors and properties
|
||||
assert(cssContent.includes('{'));
|
||||
assert(cssContent.includes('}'));
|
||||
});
|
||||
|
||||
it('should define dashboard-view class', () => {
|
||||
assert(cssContent.includes('.dashboard-view'));
|
||||
});
|
||||
|
||||
it('should define dashboard-header styles', () => {
|
||||
assert(cssContent.includes('.dashboard-header'));
|
||||
});
|
||||
|
||||
it('should define plan-card styles', () => {
|
||||
assert(cssContent.includes('.plan-card'));
|
||||
});
|
||||
|
||||
it('should define progress-ring styles', () => {
|
||||
assert(cssContent.includes('.progress-ring'));
|
||||
});
|
||||
|
||||
it('should define progress-bar styles', () => {
|
||||
assert(cssContent.includes('.progress-bar'));
|
||||
});
|
||||
|
||||
it('should define empty-state styles', () => {
|
||||
assert(cssContent.includes('.empty-state'));
|
||||
});
|
||||
|
||||
it('should have responsive media queries', () => {
|
||||
assert(cssContent.includes('@media'));
|
||||
});
|
||||
|
||||
it('should define animations', () => {
|
||||
assert(cssContent.includes('@keyframes'));
|
||||
});
|
||||
|
||||
it('should have accessibility classes', () => {
|
||||
assert(cssContent.includes('.visually-hidden'));
|
||||
});
|
||||
|
||||
it('should have focus styles', () => {
|
||||
assert(cssContent.includes(':focus'));
|
||||
assert(cssContent.includes(':focus-visible'));
|
||||
});
|
||||
|
||||
it('should support reduced motion', () => {
|
||||
assert(cssContent.includes('prefers-reduced-motion'));
|
||||
});
|
||||
|
||||
it('should define color variables or hex values', () => {
|
||||
// Check for color definitions
|
||||
assert(cssContent.includes('var(--') || cssContent.includes('#') || cssContent.includes('rgb'));
|
||||
});
|
||||
|
||||
it('should not have CSS syntax errors (basic check)', () => {
|
||||
// Check for unclosed braces
|
||||
const openBraces = (cssContent.match(/{/g) || []).length;
|
||||
const closeBraces = (cssContent.match(/}/g) || []).length;
|
||||
assert.strictEqual(openBraces, closeBraces, 'Unmatched CSS braces');
|
||||
});
|
||||
|
||||
it('should define filter pills styling', () => {
|
||||
assert(cssContent.includes('.filter-pill'));
|
||||
});
|
||||
|
||||
it('should define search box styling', () => {
|
||||
assert(cssContent.includes('.search-box'));
|
||||
});
|
||||
|
||||
it('should define status count styling', () => {
|
||||
assert(cssContent.includes('.status-count'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('dashboard.js', () => {
|
||||
let jsContent;
|
||||
|
||||
before(() => {
|
||||
assert(fs.existsSync(jsPath), `JS file not found: ${jsPath}`);
|
||||
jsContent = fs.readFileSync(jsPath, 'utf8');
|
||||
});
|
||||
|
||||
it('should be valid JavaScript', () => {
|
||||
// Check for syntax errors by looking for basic patterns
|
||||
assert(jsContent.includes('function') || jsContent.includes('const') || jsContent.includes('let'));
|
||||
});
|
||||
|
||||
it('should have IIFE pattern for encapsulation', () => {
|
||||
assert(jsContent.includes('(function()'));
|
||||
assert(jsContent.includes('})()'));
|
||||
});
|
||||
|
||||
it('should initialize state object', () => {
|
||||
assert(jsContent.includes('const state'));
|
||||
assert(jsContent.includes('sort:'));
|
||||
assert(jsContent.includes('filter:'));
|
||||
assert(jsContent.includes('search:'));
|
||||
});
|
||||
|
||||
it('should have init function', () => {
|
||||
assert(jsContent.includes('function init()'));
|
||||
});
|
||||
|
||||
it('should bind events', () => {
|
||||
assert(jsContent.includes('function bindEvents()'));
|
||||
});
|
||||
|
||||
it('should apply filters and sort', () => {
|
||||
assert(jsContent.includes('function applyFiltersAndSort()'));
|
||||
});
|
||||
|
||||
it('should render grid', () => {
|
||||
assert(jsContent.includes('renderGrid'));
|
||||
assert(jsContent.includes('.plans-grid'));
|
||||
});
|
||||
|
||||
it('should parse URL parameters', () => {
|
||||
assert(jsContent.includes('parseURL'));
|
||||
assert(jsContent.includes('URLSearchParams'));
|
||||
});
|
||||
|
||||
it('should update URL', () => {
|
||||
assert(jsContent.includes('updateURL'));
|
||||
assert(jsContent.includes('history.replaceState'));
|
||||
});
|
||||
|
||||
it('should handle search input', () => {
|
||||
assert(jsContent.includes('plan-search'));
|
||||
assert(jsContent.includes('addEventListener'));
|
||||
});
|
||||
|
||||
it('should handle sort select', () => {
|
||||
assert(jsContent.includes('sort-select'));
|
||||
assert(jsContent.includes('change'));
|
||||
});
|
||||
|
||||
it('should handle filter pills', () => {
|
||||
assert(jsContent.includes('.filter-pill'));
|
||||
});
|
||||
|
||||
it('should handle card click navigation', () => {
|
||||
assert(jsContent.includes('.plan-card'));
|
||||
assert(jsContent.includes('.view-btn'));
|
||||
});
|
||||
|
||||
it('should have keyboard navigation', () => {
|
||||
assert(jsContent.includes('setupKeyboardNav'));
|
||||
assert(jsContent.includes('ArrowRight') || jsContent.includes('ArrowDown'));
|
||||
});
|
||||
|
||||
it('should have theme toggle setup', () => {
|
||||
assert(jsContent.includes('setupThemeToggle'));
|
||||
assert(jsContent.includes('theme-toggle'));
|
||||
assert(jsContent.includes('localStorage'));
|
||||
});
|
||||
|
||||
it('should announce to screen readers', () => {
|
||||
assert(jsContent.includes('announce'));
|
||||
assert(jsContent.includes('sr-announce'));
|
||||
});
|
||||
|
||||
it('should use window.__plans data', () => {
|
||||
assert(jsContent.includes('window.__plans'));
|
||||
});
|
||||
|
||||
it('should initialize on DOM ready', () => {
|
||||
assert(jsContent.includes('DOMContentLoaded'));
|
||||
});
|
||||
|
||||
it('should have strict mode', () => {
|
||||
assert(jsContent.includes("'use strict'"));
|
||||
});
|
||||
|
||||
it('should check for required DOM elements', () => {
|
||||
assert(jsContent.includes('document.querySelector'));
|
||||
assert(jsContent.includes('.plans-grid'));
|
||||
assert(jsContent.includes('.result-count'));
|
||||
assert(jsContent.includes('.empty-state'));
|
||||
});
|
||||
|
||||
it('should validate syntax with basic checks', () => {
|
||||
// Check for unclosed strings
|
||||
const singleQuotes = (jsContent.match(/'/g) || []).length;
|
||||
const doubleQuotes = (jsContent.match(/"/g) || []).length;
|
||||
// Both should be even (pairs)
|
||||
assert.strictEqual(singleQuotes % 2, 0, 'Unmatched single quotes');
|
||||
assert.strictEqual(doubleQuotes % 2, 0, 'Unmatched double quotes');
|
||||
});
|
||||
|
||||
it('should have debounce for search input', () => {
|
||||
assert(jsContent.includes('debounce'));
|
||||
assert(jsContent.includes('setTimeout'));
|
||||
});
|
||||
|
||||
it('should support sort options', () => {
|
||||
assert(jsContent.includes('date-desc'));
|
||||
assert(jsContent.includes('name-asc'));
|
||||
assert(jsContent.includes('progress-desc'));
|
||||
});
|
||||
});
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('Dashboard Assets Tests');
|
||||
console.log('='.repeat(60));
|
||||
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* Tests for dashboard-renderer.cjs
|
||||
* XSS protection, progress ring, plan card generation, and dashboard rendering
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const {
|
||||
renderDashboard,
|
||||
generatePlanCard,
|
||||
generateProgressRing,
|
||||
generateProgressBar,
|
||||
generateStatusCounts,
|
||||
generateEmptyState,
|
||||
generatePlansGrid,
|
||||
escapeHtml,
|
||||
formatDate
|
||||
} = require('../scripts/lib/dashboard-renderer.cjs');
|
||||
|
||||
describe('escapeHtml', () => {
|
||||
it('should escape HTML special characters', () => {
|
||||
assert.strictEqual(escapeHtml('<script>alert("xss")</script>'), '<script>alert("xss")</script>');
|
||||
});
|
||||
|
||||
it('should handle ampersands', () => {
|
||||
assert.strictEqual(escapeHtml('Tom & Jerry'), 'Tom & Jerry');
|
||||
});
|
||||
|
||||
it('should handle single quotes', () => {
|
||||
assert.strictEqual(escapeHtml("it's"), 'it's');
|
||||
});
|
||||
|
||||
it('should handle double quotes', () => {
|
||||
assert.strictEqual(escapeHtml('He said "hello"'), 'He said "hello"');
|
||||
});
|
||||
|
||||
it('should handle null/undefined', () => {
|
||||
assert.strictEqual(escapeHtml(null), '');
|
||||
assert.strictEqual(escapeHtml(undefined), '');
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
assert.strictEqual(escapeHtml(''), '');
|
||||
});
|
||||
|
||||
it('should escape multiple occurrences', () => {
|
||||
const result = escapeHtml('<div class="test">Hello & "goodbye"</div>');
|
||||
assert.strictEqual(result, '<div class="test">Hello & "goodbye"</div>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('should format ISO date string', () => {
|
||||
const result = formatDate('2025-12-11T10:30:00Z');
|
||||
assert(result.includes('Dec'));
|
||||
assert(result.includes('11'));
|
||||
assert(result.includes('2025'));
|
||||
});
|
||||
|
||||
it('should handle null/undefined', () => {
|
||||
assert.strictEqual(formatDate(null), '');
|
||||
assert.strictEqual(formatDate(undefined), '');
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
assert.strictEqual(formatDate(''), '');
|
||||
});
|
||||
|
||||
it('should format different dates correctly', () => {
|
||||
const result1 = formatDate('2025-01-01T00:00:00Z');
|
||||
const result2 = formatDate('2025-12-31T23:59:59Z');
|
||||
// Check for month indicator - could be "Jan" or "1" depending on locale
|
||||
assert(result1.length > 0, 'Date 1 should format');
|
||||
assert(result2.length > 0, 'Date 2 should format');
|
||||
assert(result1 !== result2, 'Different dates should format differently');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateProgressRing', () => {
|
||||
it('should generate SVG with correct progress percentage', () => {
|
||||
const svg = generateProgressRing(50);
|
||||
assert(svg.includes('50%'));
|
||||
assert(svg.includes('stroke-dasharray'));
|
||||
});
|
||||
|
||||
it('should generate valid SVG structure', () => {
|
||||
const svg = generateProgressRing(75);
|
||||
assert(svg.includes('<svg class="progress-ring"'));
|
||||
assert(svg.includes('<circle'));
|
||||
assert(svg.includes('<text'));
|
||||
});
|
||||
|
||||
it('should handle 0% progress', () => {
|
||||
const svg = generateProgressRing(0);
|
||||
assert(svg.includes('0%'));
|
||||
assert(svg.includes('0, 100'));
|
||||
});
|
||||
|
||||
it('should handle 100% progress', () => {
|
||||
const svg = generateProgressRing(100);
|
||||
assert(svg.includes('100%'));
|
||||
assert(svg.includes('100, 100'));
|
||||
});
|
||||
|
||||
it('should have aria-hidden for accessibility', () => {
|
||||
const svg = generateProgressRing(50);
|
||||
assert(svg.includes('aria-hidden="true"'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateProgressBar', () => {
|
||||
it('should generate progress bar with correct percentages', () => {
|
||||
const bar = generateProgressBar({ total: 10, completed: 5, inProgress: 3, pending: 2 });
|
||||
assert(bar.includes('50.0%')); // completed
|
||||
assert(bar.includes('30.0%')); // in-progress
|
||||
assert(bar.includes('20.0%')); // pending
|
||||
});
|
||||
|
||||
it('should have accessibility attributes', () => {
|
||||
const bar = generateProgressBar({ total: 10, completed: 5, inProgress: 3, pending: 2 });
|
||||
assert(bar.includes('role="progressbar"'));
|
||||
assert(bar.includes('aria-valuenow="5"'));
|
||||
assert(bar.includes('aria-valuemin="0"'));
|
||||
assert(bar.includes('aria-valuemax="10"'));
|
||||
});
|
||||
|
||||
it('should handle zero total (fallback)', () => {
|
||||
const bar = generateProgressBar({ total: 0, completed: 0, inProgress: 0, pending: 0 });
|
||||
assert(bar.includes('class="progress-bar"'));
|
||||
});
|
||||
|
||||
it('should create three segments with correct classes', () => {
|
||||
const bar = generateProgressBar({ total: 10, completed: 5, inProgress: 3, pending: 2 });
|
||||
assert(bar.includes('class="bar-segment completed"'));
|
||||
assert(bar.includes('class="bar-segment in-progress"'));
|
||||
assert(bar.includes('class="bar-segment pending"'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateStatusCounts', () => {
|
||||
it('should generate status count HTML', () => {
|
||||
const html = generateStatusCounts({ completed: 3, inProgress: 2, pending: 1 });
|
||||
assert(html.includes('3'));
|
||||
assert(html.includes('2'));
|
||||
assert(html.includes('1'));
|
||||
});
|
||||
|
||||
it('should have accessibility features', () => {
|
||||
const html = generateStatusCounts({ completed: 3, inProgress: 2, pending: 1 });
|
||||
assert(html.includes('visually-hidden'));
|
||||
assert(html.includes('data-tooltip'));
|
||||
});
|
||||
|
||||
it('should have correct status classes', () => {
|
||||
const html = generateStatusCounts({ completed: 3, inProgress: 2, pending: 1 });
|
||||
assert(html.includes('status-count completed'));
|
||||
assert(html.includes('status-count in-progress'));
|
||||
assert(html.includes('status-count pending'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('generatePlanCard', () => {
|
||||
it('should generate card HTML with plan data', () => {
|
||||
const plan = {
|
||||
id: 'plan-001',
|
||||
name: 'Test Plan',
|
||||
status: 'in-progress',
|
||||
progress: 50,
|
||||
lastModified: '2025-12-11T10:00:00Z',
|
||||
path: '/plans/test-plan',
|
||||
phases: { completed: 2, inProgress: 1, pending: 1, total: 4 }
|
||||
};
|
||||
const card = generatePlanCard(plan);
|
||||
assert(card.includes('Test Plan'));
|
||||
assert(card.includes('plan-001'));
|
||||
assert(card.includes('/plans/test-plan'));
|
||||
});
|
||||
|
||||
it('should escape HTML in plan name', () => {
|
||||
const plan = {
|
||||
id: 'plan-001',
|
||||
name: '<script>alert("xss")</script>',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
lastModified: '2025-12-11T10:00:00Z',
|
||||
path: '/plans/test',
|
||||
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
|
||||
};
|
||||
const card = generatePlanCard(plan);
|
||||
assert(!card.includes('<script>'));
|
||||
assert(card.includes('<script>'));
|
||||
});
|
||||
|
||||
it('should escape HTML in plan path', () => {
|
||||
const plan = {
|
||||
id: 'plan-001',
|
||||
name: 'Test',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
lastModified: '2025-12-11T10:00:00Z',
|
||||
path: '"><script>alert(1)</script><"',
|
||||
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
|
||||
};
|
||||
const card = generatePlanCard(plan);
|
||||
assert(!card.includes('<script>'));
|
||||
assert(card.includes('"'));
|
||||
});
|
||||
|
||||
it('should set correct data-status attribute', () => {
|
||||
const planInProgress = generatePlanCard({
|
||||
id: 'p1',
|
||||
name: 'Test',
|
||||
status: 'in-progress',
|
||||
progress: 50,
|
||||
lastModified: '2025-12-11T10:00:00Z',
|
||||
path: '/test',
|
||||
phases: { completed: 0, inProgress: 1, pending: 0, total: 1 }
|
||||
});
|
||||
assert(planInProgress.includes('data-status="in-progress"'));
|
||||
});
|
||||
|
||||
it('should have accessible structure', () => {
|
||||
const plan = {
|
||||
id: 'plan-001',
|
||||
name: 'Test Plan',
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
lastModified: '2025-12-11T10:00:00Z',
|
||||
path: '/plans/test',
|
||||
phases: { completed: 1, inProgress: 0, pending: 0, total: 1 }
|
||||
};
|
||||
const card = generatePlanCard(plan);
|
||||
assert(card.includes('<article'));
|
||||
assert(card.includes('<header'));
|
||||
assert(card.includes('<footer'));
|
||||
assert(card.includes('tabindex="0"'));
|
||||
});
|
||||
|
||||
it('should include time element with datetime attribute', () => {
|
||||
const plan = {
|
||||
id: 'plan-001',
|
||||
name: 'Test',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
lastModified: '2025-12-11T10:00:00Z',
|
||||
path: '/plans/test',
|
||||
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
|
||||
};
|
||||
const card = generatePlanCard(plan);
|
||||
assert(card.includes('<time class="plan-date" datetime='));
|
||||
});
|
||||
});
|
||||
|
||||
describe('generatePlansGrid', () => {
|
||||
it('should generate cards for all plans', () => {
|
||||
const plans = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Plan 1',
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
lastModified: '2025-12-11T10:00:00Z',
|
||||
path: '/plans/1',
|
||||
phases: { completed: 1, inProgress: 0, pending: 0, total: 1 }
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
name: 'Plan 2',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
lastModified: '2025-12-11T09:00:00Z',
|
||||
path: '/plans/2',
|
||||
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
|
||||
}
|
||||
];
|
||||
const grid = generatePlansGrid(plans);
|
||||
assert(grid.includes('Plan 1'));
|
||||
assert(grid.includes('Plan 2'));
|
||||
});
|
||||
|
||||
it('should return empty string for empty plans', () => {
|
||||
assert.strictEqual(generatePlansGrid([]), '');
|
||||
assert.strictEqual(generatePlansGrid(null), '');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateEmptyState', () => {
|
||||
it('should generate empty state HTML', () => {
|
||||
const html = generateEmptyState();
|
||||
assert(html.includes('No plans found'));
|
||||
assert(html.includes('class="empty-state"'));
|
||||
assert(html.includes('hidden'));
|
||||
});
|
||||
|
||||
it('should have accessibility features', () => {
|
||||
const html = generateEmptyState();
|
||||
assert(html.includes('aria-hidden="true"'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderDashboard', () => {
|
||||
it('should render dashboard with plans', () => {
|
||||
const plans = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Test Plan',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
lastModified: '2025-12-11T10:00:00Z',
|
||||
path: '/plans/test',
|
||||
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
|
||||
}
|
||||
];
|
||||
const html = renderDashboard(plans, { assetsDir: '/tmp' });
|
||||
assert(html.includes('Test Plan'));
|
||||
assert(html.includes('<!DOCTYPE html'));
|
||||
});
|
||||
|
||||
it('should embed plans JSON for client-side filtering', () => {
|
||||
const plans = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Test',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
lastModified: '2025-12-11T10:00:00Z',
|
||||
path: '/plans/test',
|
||||
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
|
||||
}
|
||||
];
|
||||
const html = renderDashboard(plans, { assetsDir: '/tmp' });
|
||||
assert(html.includes('window.__plans'));
|
||||
assert(html.includes('"id":"p1"'));
|
||||
});
|
||||
|
||||
it('should set plan count', () => {
|
||||
const plans = Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `p${i}`,
|
||||
name: `Plan ${i}`,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
lastModified: '2025-12-11T10:00:00Z',
|
||||
path: `/plans/${i}`,
|
||||
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
|
||||
}));
|
||||
const html = renderDashboard(plans, { assetsDir: '/tmp' });
|
||||
assert(html.includes('Showing <strong>5</strong>'));
|
||||
});
|
||||
|
||||
it('should use inline template as fallback', () => {
|
||||
const plans = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Test',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
lastModified: '2025-12-11T10:00:00Z',
|
||||
path: '/plans/test',
|
||||
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
|
||||
}
|
||||
];
|
||||
// Non-existent assetsDir forces fallback
|
||||
const html = renderDashboard(plans, { assetsDir: '/nonexistent/path' });
|
||||
assert(html.includes('<!DOCTYPE html'));
|
||||
assert(html.includes('Plans Dashboard'));
|
||||
});
|
||||
|
||||
it('should set has-plans class when plans exist', () => {
|
||||
const plans = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Test',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
lastModified: '2025-12-11T10:00:00Z',
|
||||
path: '/plans/test',
|
||||
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
|
||||
}
|
||||
];
|
||||
const html = renderDashboard(plans, { assetsDir: '/tmp' });
|
||||
assert(html.includes('plans-loaded'));
|
||||
});
|
||||
|
||||
it('should not set has-plans class when no plans', () => {
|
||||
const html = renderDashboard([], { assetsDir: '/tmp' });
|
||||
assert(!html.includes('plans-loaded'));
|
||||
});
|
||||
});
|
||||
|
||||
// Run tests
|
||||
const tests = [
|
||||
'escapeHtml',
|
||||
'formatDate',
|
||||
'generateProgressRing',
|
||||
'generateProgressBar',
|
||||
'generateStatusCounts',
|
||||
'generatePlanCard',
|
||||
'generatePlansGrid',
|
||||
'generateEmptyState',
|
||||
'renderDashboard'
|
||||
];
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('Dashboard Renderer Tests');
|
||||
console.log('='.repeat(60));
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Tests for http-server.cjs
|
||||
* Route testing, security validation, MIME types
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const {
|
||||
createHttpServer,
|
||||
getMimeType,
|
||||
sendResponse,
|
||||
sendError,
|
||||
serveFile,
|
||||
isPathSafe,
|
||||
setAllowedDirs,
|
||||
sanitizeErrorMessage,
|
||||
MIME_TYPES
|
||||
} = require('../scripts/lib/http-server.cjs');
|
||||
const path = require('path');
|
||||
|
||||
describe('MIME_TYPES', () => {
|
||||
it('should have common file types', () => {
|
||||
assert.strictEqual(MIME_TYPES['.html'], 'text/html');
|
||||
assert.strictEqual(MIME_TYPES['.css'], 'text/css');
|
||||
assert.strictEqual(MIME_TYPES['.js'], 'application/javascript');
|
||||
assert.strictEqual(MIME_TYPES['.json'], 'application/json');
|
||||
});
|
||||
|
||||
it('should have image types', () => {
|
||||
assert.strictEqual(MIME_TYPES['.png'], 'image/png');
|
||||
assert.strictEqual(MIME_TYPES['.jpg'], 'image/jpeg');
|
||||
assert.strictEqual(MIME_TYPES['.svg'], 'image/svg+xml');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMimeType', () => {
|
||||
it('should return correct MIME type for HTML', () => {
|
||||
assert.strictEqual(getMimeType('file.html'), 'text/html');
|
||||
});
|
||||
|
||||
it('should return correct MIME type for CSS', () => {
|
||||
assert.strictEqual(getMimeType('style.css'), 'text/css');
|
||||
});
|
||||
|
||||
it('should return correct MIME type for JavaScript', () => {
|
||||
assert.strictEqual(getMimeType('script.js'), 'application/javascript');
|
||||
});
|
||||
|
||||
it('should handle uppercase extensions', () => {
|
||||
assert.strictEqual(getMimeType('FILE.HTML'), 'text/html');
|
||||
assert.strictEqual(getMimeType('style.CSS'), 'text/css');
|
||||
});
|
||||
|
||||
it('should return octet-stream for unknown types', () => {
|
||||
assert.strictEqual(getMimeType('file.xyz'), 'application/octet-stream');
|
||||
});
|
||||
|
||||
it('should handle files without extensions', () => {
|
||||
assert.strictEqual(getMimeType('README'), 'application/octet-stream');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeErrorMessage', () => {
|
||||
it('should remove absolute paths from error messages', () => {
|
||||
const message = 'Error: /home/user/project/file.txt not found';
|
||||
const sanitized = sanitizeErrorMessage(message);
|
||||
assert(!sanitized.includes('/home/user'));
|
||||
assert(sanitized.includes('[path]'));
|
||||
});
|
||||
|
||||
it('should preserve non-path text', () => {
|
||||
const message = 'Error: File not found';
|
||||
const sanitized = sanitizeErrorMessage(message);
|
||||
assert(sanitized.includes('Error'));
|
||||
assert(sanitized.includes('File not found'));
|
||||
});
|
||||
|
||||
it('should handle multiple paths', () => {
|
||||
const message = 'Error comparing /path/one and /path/two';
|
||||
const sanitized = sanitizeErrorMessage(message);
|
||||
assert.strictEqual((sanitized.match(/\[path\]/g) || []).length, 2);
|
||||
});
|
||||
|
||||
it('should not remove text after URL protocols', () => {
|
||||
const message = 'Visit https://example.com for help';
|
||||
const sanitized = sanitizeErrorMessage(message);
|
||||
// Verify message is preserved after sanitization
|
||||
assert(sanitized.length > 0, 'Message should not be empty');
|
||||
assert(sanitized.includes('help'), 'Message text should be preserved');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPathSafe', () => {
|
||||
it('should reject null byte injection', () => {
|
||||
assert.strictEqual(isPathSafe('/var/www/file.txt\0.jpg'), false);
|
||||
});
|
||||
|
||||
it('should allow normal paths with empty allowedDirs', () => {
|
||||
// When allowedDirs is empty (during initialization), allow all
|
||||
setAllowedDirs([]);
|
||||
assert.strictEqual(isPathSafe('/var/www/file.txt'), true);
|
||||
});
|
||||
|
||||
it('should reject paths outside allowed directories when set', () => {
|
||||
setAllowedDirs(['/allowed/dir']);
|
||||
assert.strictEqual(isPathSafe('/other/dir/file.txt'), false);
|
||||
});
|
||||
|
||||
it('should allow paths inside allowed directories', () => {
|
||||
const allowed = '/allowed/dir';
|
||||
setAllowedDirs([allowed]);
|
||||
const filePath = require('path').join(allowed, 'file.txt');
|
||||
assert.strictEqual(isPathSafe(filePath), true);
|
||||
});
|
||||
|
||||
it('should handle multiple allowed directories', () => {
|
||||
const dir1 = '/dir1';
|
||||
const dir2 = '/dir2';
|
||||
setAllowedDirs([dir1, dir2]);
|
||||
// Paths must be absolute and within allowed dirs
|
||||
const path1 = require('path').join(dir1, 'file.txt');
|
||||
const path2 = require('path').join(dir2, 'file.txt');
|
||||
assert.strictEqual(isPathSafe(path1), true);
|
||||
assert.strictEqual(isPathSafe(path2), true);
|
||||
});
|
||||
|
||||
it('should allow empty allowedDirs during initialization', () => {
|
||||
setAllowedDirs([]);
|
||||
assert.strictEqual(isPathSafe('/any/path.txt'), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAllowedDirs', () => {
|
||||
it('should set allowed directories', () => {
|
||||
const dirs = ['/home/user', '/tmp'];
|
||||
setAllowedDirs(dirs);
|
||||
// Verify by testing path safety
|
||||
assert.strictEqual(isPathSafe('/home/user/file.txt'), true);
|
||||
});
|
||||
|
||||
it('should resolve relative paths to absolute', () => {
|
||||
setAllowedDirs(['./relative']);
|
||||
// Should be resolved to absolute path
|
||||
assert(isPathSafe(path.resolve('./relative/file.txt')));
|
||||
});
|
||||
});
|
||||
|
||||
describe('createHttpServer', () => {
|
||||
it('should create an HTTP server', () => {
|
||||
const server = createHttpServer({
|
||||
assetsDir: __dirname,
|
||||
renderMarkdown: (fp) => '<html></html>',
|
||||
allowedDirs: [__dirname]
|
||||
});
|
||||
assert(server);
|
||||
assert(typeof server.listen === 'function');
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('should require assetsDir', () => {
|
||||
const server = createHttpServer({
|
||||
assetsDir: __dirname,
|
||||
renderMarkdown: (fp) => '<html></html>'
|
||||
});
|
||||
assert(server);
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('should accept plansDir option', () => {
|
||||
const server = createHttpServer({
|
||||
assetsDir: __dirname,
|
||||
renderMarkdown: (fp) => '<html></html>',
|
||||
plansDir: '/plans'
|
||||
});
|
||||
assert(server);
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('should accept allowedDirs option', () => {
|
||||
const server = createHttpServer({
|
||||
assetsDir: __dirname,
|
||||
renderMarkdown: (fp) => '<html></html>',
|
||||
allowedDirs: [__dirname, '/tmp']
|
||||
});
|
||||
assert(server);
|
||||
server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Route: /assets/*', () => {
|
||||
it('should prevent directory traversal in assets path', () => {
|
||||
const server = createHttpServer({
|
||||
assetsDir: __dirname,
|
||||
renderMarkdown: (fp) => '<html></html>'
|
||||
});
|
||||
// Route validation happens internally - can't test HTTP response without full setup
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('should validate asset paths for ../', () => {
|
||||
const server = createHttpServer({
|
||||
assetsDir: __dirname,
|
||||
renderMarkdown: (fp) => '<html></html>'
|
||||
});
|
||||
// Security check happens in route handler
|
||||
server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Route: /dashboard', () => {
|
||||
it('should accept plansDir parameter', () => {
|
||||
const server = createHttpServer({
|
||||
assetsDir: __dirname,
|
||||
renderMarkdown: (fp) => '<html></html>',
|
||||
plansDir: __dirname
|
||||
});
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('should validate custom directory parameter', () => {
|
||||
const server = createHttpServer({
|
||||
assetsDir: __dirname,
|
||||
renderMarkdown: (fp) => '<html></html>',
|
||||
allowedDirs: [__dirname]
|
||||
});
|
||||
server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Route: /api/dashboard', () => {
|
||||
it('should return JSON response', () => {
|
||||
const server = createHttpServer({
|
||||
assetsDir: __dirname,
|
||||
renderMarkdown: (fp) => '<html></html>',
|
||||
plansDir: __dirname
|
||||
});
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('should handle missing plansDir gracefully', () => {
|
||||
const server = createHttpServer({
|
||||
assetsDir: __dirname,
|
||||
renderMarkdown: (fp) => '<html></html>'
|
||||
});
|
||||
server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Route: /file/*', () => {
|
||||
it('should validate file path safety', () => {
|
||||
const server = createHttpServer({
|
||||
assetsDir: __dirname,
|
||||
renderMarkdown: (fp) => '<html></html>',
|
||||
allowedDirs: [__dirname]
|
||||
});
|
||||
server.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Route: /api/files', () => {
|
||||
it('should be disabled for security', () => {
|
||||
const server = createHttpServer({
|
||||
assetsDir: __dirname,
|
||||
renderMarkdown: (fp) => '<html></html>'
|
||||
});
|
||||
server.close();
|
||||
});
|
||||
});
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('HTTP Server Tests');
|
||||
console.log('='.repeat(60));
|
||||
51
.opencode/skills/markdown-novel-viewer/tests/run-tests.cjs
Executable file
51
.opencode/skills/markdown-novel-viewer/tests/run-tests.cjs
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Test runner for dashboard tests
|
||||
* Executes all test suites and generates report
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Load test framework first
|
||||
require('./test-framework.cjs');
|
||||
|
||||
const testsDir = __dirname;
|
||||
const testFiles = [
|
||||
'dashboard-renderer.test.cjs',
|
||||
'http-server.test.cjs',
|
||||
'dashboard-assets.test.cjs'
|
||||
];
|
||||
|
||||
console.log('\n' + '='.repeat(70));
|
||||
console.log('Dashboard Implementation Test Suite');
|
||||
console.log('='.repeat(70));
|
||||
|
||||
// Load all test files
|
||||
let loadErrors = [];
|
||||
for (const testFile of testFiles) {
|
||||
const testPath = path.join(testsDir, testFile);
|
||||
|
||||
if (!fs.existsSync(testPath)) {
|
||||
loadErrors.push(`Test file not found: ${testFile}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
require(testPath);
|
||||
} catch (error) {
|
||||
loadErrors.push(`${testFile}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (loadErrors.length > 0) {
|
||||
console.error('\nErrors loading test files:');
|
||||
loadErrors.forEach(err => {
|
||||
console.error(` - ${err}`);
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Run all tests
|
||||
global.runAllTests();
|
||||
154
.opencode/skills/markdown-novel-viewer/tests/test-framework.cjs
Normal file
154
.opencode/skills/markdown-novel-viewer/tests/test-framework.cjs
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Simple test framework for Node.js (mocha-like)
|
||||
*/
|
||||
|
||||
global.testStats = {
|
||||
total: 0,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
suites: [],
|
||||
currentSuite: null
|
||||
};
|
||||
|
||||
class TestSuite {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
this.tests = [];
|
||||
this.beforeFn = null;
|
||||
this.afterFn = null;
|
||||
}
|
||||
|
||||
addTest(name, fn) {
|
||||
this.tests.push({ name, fn });
|
||||
}
|
||||
|
||||
setBefore(fn) {
|
||||
this.beforeFn = fn;
|
||||
}
|
||||
|
||||
setAfter(fn) {
|
||||
this.afterFn = fn;
|
||||
}
|
||||
|
||||
async run() {
|
||||
const results = {
|
||||
name: this.name,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
console.log(`\n ${this.name}`);
|
||||
|
||||
for (const test of this.tests) {
|
||||
try {
|
||||
if (this.beforeFn) {
|
||||
await this.beforeFn();
|
||||
}
|
||||
|
||||
await test.fn();
|
||||
|
||||
if (this.afterFn) {
|
||||
await this.afterFn();
|
||||
}
|
||||
|
||||
results.passed++;
|
||||
process.stdout.write('.');
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
results.errors.push({
|
||||
test: test.name,
|
||||
error: error.message
|
||||
});
|
||||
process.stdout.write('F');
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
global.testSuites = {};
|
||||
|
||||
global.describe = function(name, fn) {
|
||||
const suite = new TestSuite(name);
|
||||
global.testStats.currentSuite = suite;
|
||||
global.testSuites[name] = suite;
|
||||
fn();
|
||||
};
|
||||
|
||||
global.it = function(name, fn) {
|
||||
if (!global.testStats.currentSuite) {
|
||||
throw new Error('it() called outside describe()');
|
||||
}
|
||||
global.testStats.currentSuite.addTest(name, fn);
|
||||
};
|
||||
|
||||
global.before = function(fn) {
|
||||
if (!global.testStats.currentSuite) {
|
||||
throw new Error('before() called outside describe()');
|
||||
}
|
||||
global.testStats.currentSuite.setBefore(fn);
|
||||
};
|
||||
|
||||
global.after = function(fn) {
|
||||
if (!global.testStats.currentSuite) {
|
||||
throw new Error('after() called outside describe()');
|
||||
}
|
||||
global.testStats.currentSuite.setAfter(fn);
|
||||
};
|
||||
|
||||
global.runAllTests = async function() {
|
||||
console.log('\n' + '='.repeat(70));
|
||||
console.log('Running Test Suites');
|
||||
console.log('='.repeat(70));
|
||||
|
||||
const suites = Object.values(global.testSuites);
|
||||
const results = [];
|
||||
|
||||
for (const suite of suites) {
|
||||
const result = await suite.run();
|
||||
results.push(result);
|
||||
global.testStats.passed += result.passed;
|
||||
global.testStats.failed += result.failed;
|
||||
global.testStats.total += result.passed + result.failed;
|
||||
}
|
||||
|
||||
printTestResults(results);
|
||||
|
||||
return global.testStats;
|
||||
};
|
||||
|
||||
function printTestResults(results) {
|
||||
console.log('\n\n' + '='.repeat(70));
|
||||
console.log('Test Results');
|
||||
console.log('='.repeat(70) + '\n');
|
||||
|
||||
let totalPassed = 0;
|
||||
let totalFailed = 0;
|
||||
|
||||
for (const result of results) {
|
||||
const status = result.failed > 0 ? '✗' : '✓';
|
||||
console.log(`${status} ${result.name}`);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
result.errors.forEach(err => {
|
||||
console.log(` ✗ ${err.test}`);
|
||||
console.log(` ${err.error}`);
|
||||
});
|
||||
}
|
||||
|
||||
totalPassed += result.passed;
|
||||
totalFailed += result.failed;
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(70));
|
||||
console.log(`Total: ${totalPassed + totalFailed} | Passed: ${totalPassed} | Failed: ${totalFailed}`);
|
||||
console.log('='.repeat(70) + '\n');
|
||||
|
||||
if (totalFailed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { TestSuite, runAllTests };
|
||||
90
.opencode/skills/markdown-novel-viewer/tests/verify-xss.cjs
Executable file
90
.opencode/skills/markdown-novel-viewer/tests/verify-xss.cjs
Executable file
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* XSS Protection Verification
|
||||
*/
|
||||
|
||||
const renderer = require('../scripts/lib/dashboard-renderer.cjs');
|
||||
|
||||
console.log('\nXSS Protection Verification');
|
||||
console.log('='.repeat(70));
|
||||
|
||||
// Test 1: Image onerror payload
|
||||
const xssPayload1 = '<img src=x onerror="alert(1)">';
|
||||
const result1 = renderer.escapeHtml(xssPayload1);
|
||||
console.log('\nTest 1: Image onerror');
|
||||
console.log(`Input: ${xssPayload1}`);
|
||||
console.log(`Output: ${result1}`);
|
||||
const pass1 = !result1.includes('<img') && result1.includes('<');
|
||||
console.log(`Result: ${pass1 ? 'PASS' : 'FAIL'}`);
|
||||
|
||||
// Test 2: Script tag injection
|
||||
const xssPayload2 = '<script>alert("xss")</script>';
|
||||
const result2 = renderer.escapeHtml(xssPayload2);
|
||||
console.log('\nTest 2: Script tag');
|
||||
console.log(`Input: ${xssPayload2}`);
|
||||
console.log(`Output: ${result2}`);
|
||||
const pass2 = !result2.includes('<script') && result2.includes('<script');
|
||||
console.log(`Result: ${pass2 ? 'PASS' : 'FAIL'}`);
|
||||
|
||||
// Test 3: Full dashboard render with malicious plan
|
||||
const plans = [
|
||||
{
|
||||
id: 'xss-test',
|
||||
name: '<script>alert(1)</script>',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
lastModified: '2025-12-11T10:00:00Z',
|
||||
path: '"><script>alert(1)</script><"',
|
||||
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
|
||||
}
|
||||
];
|
||||
|
||||
const html = renderer.renderDashboard(plans, {
|
||||
assetsDir: 'nonexistent' // Use fallback template
|
||||
});
|
||||
|
||||
console.log('\nTest 3: Full dashboard render');
|
||||
console.log(`Input: Malicious plan name and path`);
|
||||
// Check that the HTML contains escaped version in plan card
|
||||
// JSON will also contain escaped content but that's safe
|
||||
const cardSectionStart = html.indexOf('<article');
|
||||
const cardSectionEnd = html.indexOf('</article>');
|
||||
const cardContent = cardSectionStart !== -1 ? html.substring(cardSectionStart, cardSectionEnd) : '';
|
||||
const hasEscapedInCard = cardContent.includes('<script>');
|
||||
const pass3 = hasEscapedInCard;
|
||||
console.log(`Result: ${pass3 ? 'PASS' : 'FAIL'}`);
|
||||
|
||||
// Test 4: HTML structure
|
||||
console.log('\nTest 4: HTML structure validity');
|
||||
const hasDoctype = html.includes('<!DOCTYPE html');
|
||||
const hasHtmlClose = html.includes('</html>');
|
||||
const hasMain = html.includes('<main');
|
||||
const hasPlans = html.includes('{{plans-grid}}') || html.includes('class="plans-grid"');
|
||||
console.log(`DOCTYPE: ${hasDoctype ? 'PASS' : 'FAIL'}`);
|
||||
console.log(`Closing HTML: ${hasHtmlClose ? 'PASS' : 'FAIL'}`);
|
||||
console.log(`Main element: ${hasMain ? 'PASS' : 'FAIL'}`);
|
||||
console.log(`Plans section: ${hasPlans ? 'PASS' : 'FAIL'}`);
|
||||
const pass4 = hasDoctype && hasHtmlClose && hasMain;
|
||||
|
||||
// Test 5: JSON embedded safely
|
||||
console.log('\nTest 5: JSON embedding');
|
||||
const jsonIncluded = html.includes('window.__plans');
|
||||
const jsonValid = html.includes('"id"') && html.includes('"name"');
|
||||
console.log(`JSON variable: ${jsonIncluded ? 'PASS' : 'FAIL'}`);
|
||||
console.log(`JSON valid: ${jsonValid ? 'PASS' : 'FAIL'}`);
|
||||
const pass5 = jsonIncluded && jsonValid;
|
||||
|
||||
// Summary
|
||||
console.log('\n' + '='.repeat(70));
|
||||
console.log('Summary');
|
||||
console.log('='.repeat(70));
|
||||
const allPass = pass1 && pass2 && pass3 && pass4 && pass5;
|
||||
console.log(`XSS Escaping: ${pass1 && pass2 ? 'PASS (2/2)' : 'FAIL'}`);
|
||||
console.log(`Dashboard Render: ${pass3 ? 'PASS' : 'FAIL'}`);
|
||||
console.log(`HTML Structure: ${pass4 ? 'PASS' : 'FAIL'}`);
|
||||
console.log(`JSON Embedding: ${pass5 ? 'PASS' : 'FAIL'}`);
|
||||
console.log(`\nOverall: ${allPass ? 'PASS' : 'FAIL'}`);
|
||||
console.log('='.repeat(70) + '\n');
|
||||
|
||||
process.exit(allPass ? 0 : 1);
|
||||
Reference in New Issue
Block a user