feat: sitemap validattion
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtemp, readFile, rm, readdir, stat } from 'node:fs/promises';
|
||||
import { mkdtemp, readFile, rm, readdir, stat, mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import type { PostData } from '../../src/main/engine/PostEngine';
|
||||
@@ -442,6 +442,171 @@ describe('BlogGenerationEngine', () => {
|
||||
expect(result.pagesGenerated).toBe(7);
|
||||
});
|
||||
|
||||
it('validates sitemap against html folder without rendering missing pages', async () => {
|
||||
const posts = [
|
||||
makePost({
|
||||
id: '1',
|
||||
slug: 'validation-main-post',
|
||||
title: 'Validation Main Post',
|
||||
categories: ['news'],
|
||||
tags: ['validation-tag'],
|
||||
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||
}),
|
||||
makePost({
|
||||
id: '2',
|
||||
slug: 'validation-page',
|
||||
title: 'Validation Page',
|
||||
categories: ['page'],
|
||||
tags: [],
|
||||
createdAt: new Date('2025-01-16T10:00:00Z'),
|
||||
}),
|
||||
];
|
||||
setupPosts(posts);
|
||||
|
||||
await mkdir(path.join(tempDir, 'html', 'stale'), { recursive: true });
|
||||
await writeFile(path.join(tempDir, 'html', 'stale', 'index.html'), '<html>stale</html>', 'utf-8');
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine();
|
||||
|
||||
const report = await engine.validateSite({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
}, vi.fn());
|
||||
|
||||
expect(report.missingUrlPaths).toContain('/2025/01/15/validation-main-post');
|
||||
expect(report.missingUrlPaths).toContain('/category/news');
|
||||
expect(report.missingUrlPaths).toContain('/tag/validation-tag');
|
||||
expect(report.missingUrlPaths).toContain('/validation-page');
|
||||
expect(report.extraUrlPaths).toContain('/stale');
|
||||
|
||||
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'validation-main-post', 'index.html'))).toBe(false);
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'sitemap.xml'))).toBe(true);
|
||||
});
|
||||
|
||||
it('applies validation by rendering missing pages and deleting extra pages with folder pruning', async () => {
|
||||
const posts = [
|
||||
makePost({
|
||||
id: '1',
|
||||
slug: 'apply-post',
|
||||
title: 'Apply Post',
|
||||
categories: ['news'],
|
||||
tags: ['apply-tag'],
|
||||
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||
}),
|
||||
];
|
||||
setupPosts(posts);
|
||||
|
||||
await mkdir(path.join(tempDir, 'html', 'obsolete', 'deep'), { recursive: true });
|
||||
await writeFile(path.join(tempDir, 'html', 'obsolete', 'deep', 'index.html'), '<html>obsolete</html>', 'utf-8');
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine();
|
||||
|
||||
const report = await engine.validateSite({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
}, vi.fn());
|
||||
|
||||
const applyResult = await engine.applyValidation({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
}, report, vi.fn());
|
||||
|
||||
expect(applyResult.deletedUrlCount).toBeGreaterThan(0);
|
||||
expect(applyResult.renderedUrlCount).toBeGreaterThan(0);
|
||||
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'obsolete', 'deep', 'index.html'))).toBe(false);
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'obsolete', 'deep'))).toBe(false);
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'obsolete'))).toBe(false);
|
||||
expect(await fileExists(path.join(tempDir, 'html'))).toBe(true);
|
||||
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'apply-post', 'index.html'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not report valid pagination routes as extra html content', async () => {
|
||||
const posts = [
|
||||
makePost({ id: '1', slug: 'p1', categories: ['news'], tags: ['tag-news'], createdAt: new Date('2025-01-15T10:00:00Z') }),
|
||||
makePost({ id: '2', slug: 'p2', categories: ['news'], tags: ['tag-news'], createdAt: new Date('2025-01-14T10:00:00Z') }),
|
||||
makePost({ id: '3', slug: 'p3', categories: ['news'], tags: ['tag-news'], createdAt: new Date('2025-01-13T10:00:00Z') }),
|
||||
];
|
||||
|
||||
setupPosts(posts);
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine();
|
||||
|
||||
await engine.generate({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
maxPostsPerPage: 2,
|
||||
}, vi.fn());
|
||||
|
||||
const report = await engine.validateSite({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
maxPostsPerPage: 2,
|
||||
}, vi.fn());
|
||||
|
||||
expect(report.extraUrlPaths).not.toContain('/page/2');
|
||||
expect(report.extraUrlPaths).not.toContain('/category/news/page/2');
|
||||
expect(report.extraUrlPaths).not.toContain('/tag/tag-news/page/2');
|
||||
});
|
||||
|
||||
it('emits sitemap urls with trailing slash canonical form', async () => {
|
||||
const posts = [
|
||||
makePost({
|
||||
id: '1',
|
||||
slug: 'canonical-post',
|
||||
categories: ['news'],
|
||||
tags: ['canonical-tag'],
|
||||
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||
}),
|
||||
makePost({
|
||||
id: '2',
|
||||
slug: 'canonical-post-2',
|
||||
categories: ['news'],
|
||||
tags: ['canonical-tag'],
|
||||
createdAt: new Date('2025-01-14T10:00:00Z'),
|
||||
}),
|
||||
makePost({
|
||||
id: '3',
|
||||
slug: 'canonical-post-3',
|
||||
categories: ['news'],
|
||||
tags: ['canonical-tag'],
|
||||
createdAt: new Date('2025-01-13T10:00:00Z'),
|
||||
}),
|
||||
makePost({
|
||||
id: '4',
|
||||
slug: 'canonical-page',
|
||||
categories: ['page'],
|
||||
tags: [],
|
||||
createdAt: new Date('2025-01-12T10:00:00Z'),
|
||||
}),
|
||||
];
|
||||
|
||||
await generate(posts, { maxPostsPerPage: 2 });
|
||||
|
||||
const sitemap = await readFile(path.join(tempDir, 'html', 'sitemap.xml'), 'utf-8');
|
||||
|
||||
expect(sitemap).toContain('<loc>https://example.com/</loc>');
|
||||
expect(sitemap).toContain('<loc>https://example.com/2025/01/15/canonical-post/</loc>');
|
||||
expect(sitemap).toContain('<loc>https://example.com/category/news/</loc>');
|
||||
expect(sitemap).toContain('<loc>https://example.com/category/news/page/2/</loc>');
|
||||
expect(sitemap).toContain('<loc>https://example.com/tag/canonical-tag/</loc>');
|
||||
expect(sitemap).toContain('<loc>https://example.com/canonical-page/</loc>');
|
||||
expect(sitemap).toContain('<loc>https://example.com/page/2/</loc>');
|
||||
});
|
||||
|
||||
it('generates HTML that references local assets not CDN', async () => {
|
||||
const posts = [makePost({ id: '1', slug: 'test' })];
|
||||
await generate(posts);
|
||||
|
||||
@@ -2246,6 +2246,64 @@ describe('IPC Handlers', () => {
|
||||
expect(sitemapXml).not.toContain('http://127.0.0.1:4123/2024/03/25/public-url-test-post');
|
||||
});
|
||||
});
|
||||
|
||||
describe('blog:validateSite', () => {
|
||||
it('should generate sitemap-only validation report against html folder', async () => {
|
||||
const mockProject = createMockProject({
|
||||
id: 'test-project',
|
||||
dataPath: '/mock/data',
|
||||
});
|
||||
mockProjectEngine.getActiveProject.mockResolvedValue(mockProject);
|
||||
mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir');
|
||||
mockMetaEngine.getProjectMetadata.mockResolvedValue({
|
||||
name: 'Test Project',
|
||||
publicUrl: 'https://blog.example.com',
|
||||
});
|
||||
|
||||
mockPostEngine.getPostsFiltered.mockImplementation(async (filter: { status?: string }) => {
|
||||
if (filter.status === 'published') {
|
||||
return [
|
||||
{
|
||||
id: 'post-1',
|
||||
projectId: 'test-project',
|
||||
title: 'Test Post',
|
||||
slug: 'test-post',
|
||||
excerpt: '',
|
||||
content: '# Test',
|
||||
status: 'published',
|
||||
createdAt: new Date('2024-01-15T10:00:00Z'),
|
||||
updatedAt: new Date('2024-01-20T15:00:00Z'),
|
||||
publishedAt: new Date('2024-01-15T10:00:00Z'),
|
||||
tags: ['tag1'],
|
||||
categories: ['category1'],
|
||||
},
|
||||
];
|
||||
}
|
||||
if (filter.status === 'draft') {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
mockPostEngine.getPublishedVersion.mockResolvedValue(null);
|
||||
|
||||
const { writeFile, mkdir, readdir } = await import('fs/promises');
|
||||
vi.mocked(mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(readdir).mockResolvedValue([] as never);
|
||||
|
||||
const result = await invokeHandler('blog:validateSite');
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
missingUrlPaths: expect.any(Array),
|
||||
extraUrlPaths: expect.any(Array),
|
||||
}));
|
||||
expect(writeFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('sitemap.xml'),
|
||||
expect.stringContaining('<?xml version="1.0" encoding="UTF-8"?>'),
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============ Error Handling ============
|
||||
|
||||
@@ -40,11 +40,15 @@ describe('Help menu documentation entry', () => {
|
||||
expect(viewGroup?.items.some((item) => item.action === 'toggleFullScreen')).toBe(true);
|
||||
});
|
||||
|
||||
it('assigns Command/Ctrl+R shortcut for generateSitemap menu item', () => {
|
||||
it('includes Validate Site action in Blog menu with a V shortcut', () => {
|
||||
const blogGroup = APP_MENU_GROUPS.find((group) => group.label === 'Blog');
|
||||
const generateSiteItem = blogGroup?.items.find((item) => item.action === 'generateSitemap');
|
||||
const validateSiteItem = blogGroup?.items.find((item) => item.action === 'validateSite');
|
||||
|
||||
expect(generateSiteItem).toBeDefined();
|
||||
expect(generateSiteItem?.accelerator).toBe('CmdOrCtrl+R');
|
||||
expect(validateSiteItem).toBeDefined();
|
||||
expect(validateSiteItem?.accelerator).toContain('V');
|
||||
});
|
||||
|
||||
it('maps Validate Site to a renderer menu event', () => {
|
||||
expect(APP_MENU_ACTION_EVENT_MAP.validateSite).toBe('menu:validateSite');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,4 +47,23 @@ describe('tabPersistence', () => {
|
||||
|
||||
expect(() => saveTabsForProject(projectId, sampleTabState)).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not persist transient tabs', () => {
|
||||
const tabStateWithTransient = {
|
||||
tabs: [
|
||||
{ id: 'documentation', type: 'documentation', isTransient: false },
|
||||
{ id: 'site-validation-report', type: 'site-validation', isTransient: true },
|
||||
],
|
||||
activeTabId: 'site-validation-report',
|
||||
} as unknown as TabState;
|
||||
|
||||
saveTabsForProject(projectId, tabStateWithTransient);
|
||||
|
||||
const loaded = loadTabsForProject(projectId);
|
||||
|
||||
expect(loaded?.tabs).toEqual([
|
||||
{ id: 'documentation', type: 'documentation', isTransient: false },
|
||||
]);
|
||||
expect(loaded?.activeTabId).toBe('documentation');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user