feat(frontmatter/file-io-and-serialize): implement parseTaskFile, parseTaskDirectory, and serializeFrontmatter
This commit is contained in:
515
test/frontmatter-fileio-serialize.test.ts
Normal file
515
test/frontmatter-fileio-serialize.test.ts
Normal file
@@ -0,0 +1,515 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { parseTaskFile, parseTaskDirectory } from '../src/frontmatter/file-io.js';
|
||||
import { serializeFrontmatter } from '../src/frontmatter/serialize.js';
|
||||
import { parseFrontmatter } from '../src/frontmatter/parse.js';
|
||||
import type { TaskInput } from '../src/schema/task.js';
|
||||
import { InvalidInputError } from '../src/error/index.js';
|
||||
|
||||
describe('parseTaskFile', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'taskgraph-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reads and parses a valid markdown file with frontmatter', async () => {
|
||||
const filePath = join(tempDir, 'task.md');
|
||||
const content = `---
|
||||
id: file-task
|
||||
name: File Task
|
||||
dependsOn: []
|
||||
---
|
||||
Some body content`;
|
||||
await writeFile(filePath, content, 'utf-8');
|
||||
|
||||
const result = await parseTaskFile(filePath);
|
||||
expect(result.id).toBe('file-task');
|
||||
expect(result.name).toBe('File Task');
|
||||
expect(result.dependsOn).toEqual([]);
|
||||
});
|
||||
|
||||
it('reads and parses a file with all optional fields', async () => {
|
||||
const filePath = join(tempDir, 'full-task.md');
|
||||
const content = `---
|
||||
id: full-file-task
|
||||
name: Full File Task
|
||||
dependsOn:
|
||||
- task-a
|
||||
status: in-progress
|
||||
scope: narrow
|
||||
risk: high
|
||||
impact: component
|
||||
level: implementation
|
||||
priority: critical
|
||||
tags:
|
||||
- urgent
|
||||
assignee: alice
|
||||
due: "2026-06-01"
|
||||
---
|
||||
Body here`;
|
||||
await writeFile(filePath, content, 'utf-8');
|
||||
|
||||
const result = await parseTaskFile(filePath);
|
||||
expect(result.id).toBe('full-file-task');
|
||||
expect(result.status).toBe('in-progress');
|
||||
expect(result.risk).toBe('high');
|
||||
expect(result.tags).toEqual(['urgent']);
|
||||
});
|
||||
|
||||
it('throws ENOENT error for non-existent file', async () => {
|
||||
const filePath = join(tempDir, 'does-not-exist.md');
|
||||
await expect(parseTaskFile(filePath)).rejects.toThrow();
|
||||
try {
|
||||
await parseTaskFile(filePath);
|
||||
expect.unreachable('Should have thrown');
|
||||
} catch (err: any) {
|
||||
expect(err.code).toBe('ENOENT');
|
||||
}
|
||||
});
|
||||
|
||||
it('throws InvalidInputError for file with no valid frontmatter', async () => {
|
||||
const filePath = join(tempDir, 'no-frontmatter.md');
|
||||
await writeFile(filePath, 'Just some markdown content\nNo frontmatter', 'utf-8');
|
||||
|
||||
await expect(parseTaskFile(filePath)).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
it('throws InvalidInputError for file with invalid YAML frontmatter', async () => {
|
||||
const filePath = join(tempDir, 'invalid-yaml.md');
|
||||
await writeFile(filePath, `---
|
||||
id: bad yaml: [unclosed
|
||||
name: Broken
|
||||
dependsOn: []
|
||||
---`, 'utf-8');
|
||||
|
||||
await expect(parseTaskFile(filePath)).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
it('handles UTF-8 BOM in file', async () => {
|
||||
const filePath = join(tempDir, 'bom-task.md');
|
||||
const content = '\uFEFF---\nid: bom-task\nname: BOM Task\ndependsOn: []\n---\nBody';
|
||||
await writeFile(filePath, content, 'utf-8');
|
||||
|
||||
const result = await parseTaskFile(filePath);
|
||||
expect(result.id).toBe('bom-task');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTaskDirectory', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'taskgraph-dir-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('parses all .md files with valid frontmatter in a directory', async () => {
|
||||
await writeFile(join(tempDir, 'task-a.md'), `---
|
||||
id: task-a
|
||||
name: Task A
|
||||
dependsOn: []
|
||||
---`);
|
||||
await writeFile(join(tempDir, 'task-b.md'), `---
|
||||
id: task-b
|
||||
name: Task B
|
||||
dependsOn:
|
||||
- task-a
|
||||
---`);
|
||||
|
||||
const results = await parseTaskDirectory(tempDir);
|
||||
expect(results).toHaveLength(2);
|
||||
const ids = results.map(r => r.id).sort();
|
||||
expect(ids).toEqual(['task-a', 'task-b']);
|
||||
});
|
||||
|
||||
it('silently skips non-.md files', async () => {
|
||||
await writeFile(join(tempDir, 'task.md'), `---
|
||||
id: task-1
|
||||
name: Task 1
|
||||
dependsOn: []
|
||||
---`);
|
||||
await writeFile(join(tempDir, 'notes.txt'), 'Some notes');
|
||||
await writeFile(join(tempDir, 'config.json'), '{"key": "value"}');
|
||||
await writeFile(join(tempDir, 'script.js'), 'console.log("hi")');
|
||||
|
||||
const results = await parseTaskDirectory(tempDir);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]!.id).toBe('task-1');
|
||||
});
|
||||
|
||||
it('silently skips .md files without valid frontmatter', async () => {
|
||||
await writeFile(join(tempDir, 'valid.md'), `---
|
||||
id: valid-task
|
||||
name: Valid
|
||||
dependsOn: []
|
||||
---`);
|
||||
await writeFile(join(tempDir, 'no-frontmatter.md'), 'Just some text\nNo frontmatter here');
|
||||
await writeFile(join(tempDir, 'invalid-schema.md'), `---
|
||||
id: broken
|
||||
name: Missing dependsOn
|
||||
---`);
|
||||
|
||||
const results = await parseTaskDirectory(tempDir);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]!.id).toBe('valid-task');
|
||||
});
|
||||
|
||||
it('recursively scans subdirectories', async () => {
|
||||
const subDir = join(tempDir, 'subdir');
|
||||
const deepDir = join(subDir, 'deep');
|
||||
await mkdir(subDir, { recursive: true });
|
||||
await mkdir(deepDir, { recursive: true });
|
||||
|
||||
await writeFile(join(tempDir, 'root.md'), `---
|
||||
id: root-task
|
||||
name: Root
|
||||
dependsOn: []
|
||||
---`);
|
||||
await writeFile(join(subDir, 'sub.md'), `---
|
||||
id: sub-task
|
||||
name: Sub
|
||||
dependsOn: []
|
||||
---`);
|
||||
await writeFile(join(deepDir, 'deep.md'), `---
|
||||
id: deep-task
|
||||
name: Deep
|
||||
dependsOn: []
|
||||
---`);
|
||||
|
||||
const results = await parseTaskDirectory(tempDir);
|
||||
expect(results).toHaveLength(3);
|
||||
const ids = results.map(r => r.id).sort();
|
||||
expect(ids).toEqual(['deep-task', 'root-task', 'sub-task']);
|
||||
});
|
||||
|
||||
it('handles empty directory', async () => {
|
||||
const emptyDir = join(tempDir, 'empty');
|
||||
await mkdir(emptyDir);
|
||||
|
||||
const results = await parseTaskDirectory(emptyDir);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('throws ENOENT for non-existent directory', async () => {
|
||||
const badPath = join(tempDir, 'no-such-dir');
|
||||
await expect(parseTaskDirectory(badPath)).rejects.toThrow();
|
||||
try {
|
||||
await parseTaskDirectory(badPath);
|
||||
expect.unreachable('Should have thrown');
|
||||
} catch (err: any) {
|
||||
expect(err.code).toBe('ENOENT');
|
||||
}
|
||||
});
|
||||
|
||||
it('handles mixed valid, invalid, and non-.md files in subdirectories', async () => {
|
||||
const subDir = join(tempDir, 'mixed');
|
||||
await mkdir(subDir);
|
||||
|
||||
await writeFile(join(tempDir, 'root-valid.md'), `---
|
||||
id: root
|
||||
name: Root Task
|
||||
dependsOn: []
|
||||
---`);
|
||||
await writeFile(join(subDir, 'sub-valid.md'), `---
|
||||
id: sub
|
||||
name: Sub Task
|
||||
dependsOn: []
|
||||
---`);
|
||||
await writeFile(join(subDir, 'no-fm.md'), 'No frontmatter');
|
||||
await writeFile(join(subDir, 'data.csv'), 'a,b,c');
|
||||
|
||||
const results = await parseTaskDirectory(tempDir);
|
||||
expect(results).toHaveLength(2);
|
||||
const ids = results.map(r => r.id).sort();
|
||||
expect(ids).toEqual(['root', 'sub']);
|
||||
});
|
||||
|
||||
it('skips invalid YAML files (not valid frontmatter) silently', async () => {
|
||||
await writeFile(join(tempDir, 'valid.md'), `---
|
||||
id: good
|
||||
name: Good
|
||||
dependsOn: []
|
||||
---`);
|
||||
await writeFile(join(tempDir, 'bad-risk.md'), `---
|
||||
id: bad-risk
|
||||
name: Bad Risk
|
||||
dependsOn: []
|
||||
risk: extreme-invalid-value
|
||||
---`);
|
||||
|
||||
const results = await parseTaskDirectory(tempDir);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]!.id).toBe('good');
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeFrontmatter', () => {
|
||||
it('serializes a minimal TaskInput with required fields only', () => {
|
||||
const task: TaskInput = {
|
||||
id: 'minimal',
|
||||
name: 'Minimal Task',
|
||||
dependsOn: [],
|
||||
};
|
||||
|
||||
const result = serializeFrontmatter(task);
|
||||
expect(result).toContain('---');
|
||||
expect(result).toContain('id: minimal');
|
||||
expect(result).toContain('name: Minimal Task');
|
||||
});
|
||||
|
||||
it('serializes a TaskInput with all fields populated', () => {
|
||||
const task: TaskInput = {
|
||||
id: 'full',
|
||||
name: 'Full Task',
|
||||
dependsOn: ['task-a', 'task-b'],
|
||||
status: 'in-progress',
|
||||
scope: 'narrow',
|
||||
risk: 'high',
|
||||
impact: 'component',
|
||||
level: 'implementation',
|
||||
priority: 'critical',
|
||||
tags: ['urgent', 'backend'],
|
||||
assignee: 'alice',
|
||||
due: '2026-06-01',
|
||||
created: '2026-04-01',
|
||||
modified: '2026-04-20',
|
||||
};
|
||||
|
||||
const result = serializeFrontmatter(task);
|
||||
expect(result).toContain('id: full');
|
||||
expect(result).toContain('status: in-progress');
|
||||
expect(result).toContain('risk: high');
|
||||
expect(result).toContain('assignee: alice');
|
||||
});
|
||||
|
||||
it('omits undefined fields from YAML output', () => {
|
||||
const task: TaskInput = {
|
||||
id: 'partial',
|
||||
name: 'Partial Task',
|
||||
dependsOn: [],
|
||||
// status, scope, risk, etc. are undefined
|
||||
};
|
||||
|
||||
const result = serializeFrontmatter(task);
|
||||
// The YAML should NOT contain keys for undefined fields
|
||||
expect(result).not.toContain('status:');
|
||||
expect(result).not.toContain('risk:');
|
||||
expect(result).not.toContain('scope:');
|
||||
expect(result).not.toContain('impact:');
|
||||
expect(result).not.toContain('tags:');
|
||||
});
|
||||
|
||||
it('serializes explicit null values as null in YAML', () => {
|
||||
const task: TaskInput = {
|
||||
id: 'null-task',
|
||||
name: 'Null Task',
|
||||
dependsOn: [],
|
||||
risk: null,
|
||||
scope: null,
|
||||
status: null,
|
||||
};
|
||||
|
||||
const result = serializeFrontmatter(task);
|
||||
// YAML null representation (empty value or explicit null)
|
||||
expect(result).toContain('risk:');
|
||||
expect(result).toContain('scope:');
|
||||
expect(result).toContain('status:');
|
||||
});
|
||||
|
||||
it('starts with opening --- delimiter', () => {
|
||||
const task: TaskInput = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
dependsOn: [],
|
||||
};
|
||||
|
||||
const result = serializeFrontmatter(task);
|
||||
expect(result.startsWith('---\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('includes closing --- delimiter before body', () => {
|
||||
const task: TaskInput = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
dependsOn: [],
|
||||
};
|
||||
|
||||
const result = serializeFrontmatter(task);
|
||||
// The closing --- must appear after the YAML content
|
||||
const lines = result.split('\n');
|
||||
// Find the second --- (closing delimiter)
|
||||
const delimiterCount = lines.filter(l => l === '---').length;
|
||||
expect(delimiterCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('appends body content after closing delimiter', () => {
|
||||
const task: TaskInput = {
|
||||
id: 'body-test',
|
||||
name: 'Body Test',
|
||||
dependsOn: [],
|
||||
};
|
||||
|
||||
const result = serializeFrontmatter(task, '# My Heading\n\nSome content.');
|
||||
expect(result).toContain('# My Heading');
|
||||
expect(result).toContain('Some content.');
|
||||
});
|
||||
|
||||
it('default body is empty string', () => {
|
||||
const task: TaskInput = {
|
||||
id: 'no-body',
|
||||
name: 'No Body',
|
||||
dependsOn: [],
|
||||
};
|
||||
|
||||
const result = serializeFrontmatter(task);
|
||||
// After the closing --- there should be just a trailing newline
|
||||
const closingIndex = result.lastIndexOf('---');
|
||||
const afterClosing = result.slice(closingIndex + 3).trim();
|
||||
expect(afterClosing).toBe('');
|
||||
});
|
||||
|
||||
// ─── Round-trip tests: parseFrontmatter(serializeFrontmatter(task)) ≈ task ──
|
||||
|
||||
describe('round-trip: parseFrontmatter(serializeFrontmatter(task))', () => {
|
||||
it('round-trips a minimal TaskInput', () => {
|
||||
const original: TaskInput = {
|
||||
id: 'rt-minimal',
|
||||
name: 'Round Trip Minimal',
|
||||
dependsOn: [],
|
||||
};
|
||||
|
||||
const serialized = serializeFrontmatter(original);
|
||||
const parsed = parseFrontmatter(serialized);
|
||||
|
||||
expect(parsed.id).toBe(original.id);
|
||||
expect(parsed.name).toBe(original.name);
|
||||
expect(parsed.dependsOn).toEqual(original.dependsOn);
|
||||
});
|
||||
|
||||
it('round-trips a TaskInput with all fields', () => {
|
||||
const original: TaskInput = {
|
||||
id: 'rt-full',
|
||||
name: 'Round Trip Full',
|
||||
dependsOn: ['dep-a', 'dep-b'],
|
||||
status: 'in-progress',
|
||||
scope: 'narrow',
|
||||
risk: 'high',
|
||||
impact: 'component',
|
||||
level: 'implementation',
|
||||
priority: 'critical',
|
||||
tags: ['urgent'],
|
||||
assignee: 'bob',
|
||||
due: '2026-06-01',
|
||||
created: '2026-04-01',
|
||||
modified: '2026-04-20',
|
||||
};
|
||||
|
||||
const serialized = serializeFrontmatter(original);
|
||||
const parsed = parseFrontmatter(serialized);
|
||||
|
||||
expect(parsed.id).toBe(original.id);
|
||||
expect(parsed.name).toBe(original.name);
|
||||
expect(parsed.dependsOn).toEqual(original.dependsOn);
|
||||
expect(parsed.status).toBe(original.status);
|
||||
expect(parsed.scope).toBe(original.scope);
|
||||
expect(parsed.risk).toBe(original.risk);
|
||||
expect(parsed.impact).toBe(original.impact);
|
||||
expect(parsed.level).toBe(original.level);
|
||||
expect(parsed.priority).toBe(original.priority);
|
||||
expect(parsed.tags).toEqual(original.tags);
|
||||
expect(parsed.assignee).toBe(original.assignee);
|
||||
expect(parsed.due).toBe(original.due);
|
||||
expect(parsed.created).toBe(original.created);
|
||||
expect(parsed.modified).toBe(original.modified);
|
||||
});
|
||||
|
||||
it('round-trips nullable fields set to null', () => {
|
||||
const original: TaskInput = {
|
||||
id: 'rt-null',
|
||||
name: 'Round Trip Null',
|
||||
dependsOn: [],
|
||||
risk: null,
|
||||
status: null,
|
||||
assignee: null,
|
||||
};
|
||||
|
||||
const serialized = serializeFrontmatter(original);
|
||||
const parsed = parseFrontmatter(serialized);
|
||||
|
||||
expect(parsed.id).toBe(original.id);
|
||||
expect(parsed.risk).toBeNull();
|
||||
expect(parsed.status).toBeNull();
|
||||
expect(parsed.assignee).toBeNull();
|
||||
// Fields that were undefined in original should be undefined after round-trip
|
||||
expect(parsed.scope).toBeUndefined();
|
||||
expect(parsed.impact).toBeUndefined();
|
||||
});
|
||||
|
||||
it('round-trips with body content preserved separately', () => {
|
||||
const task: TaskInput = {
|
||||
id: 'rt-body',
|
||||
name: 'Round Trip Body',
|
||||
dependsOn: [],
|
||||
};
|
||||
const body = '# Implementation Notes\n\nSome details here.';
|
||||
|
||||
const serialized = serializeFrontmatter(task, body);
|
||||
// The serialized string should contain the body
|
||||
expect(serialized).toContain('# Implementation Notes');
|
||||
|
||||
// Parse only the frontmatter portion (parseFrontmatter ignores body)
|
||||
const parsed = parseFrontmatter(serialized);
|
||||
expect(parsed.id).toBe('rt-body');
|
||||
});
|
||||
|
||||
it('round-trips with empty dependsOn array', () => {
|
||||
const original: TaskInput = {
|
||||
id: 'rt-empty-deps',
|
||||
name: 'Empty Deps',
|
||||
dependsOn: [],
|
||||
};
|
||||
|
||||
const serialized = serializeFrontmatter(original);
|
||||
const parsed = parseFrontmatter(serialized);
|
||||
|
||||
expect(parsed.dependsOn).toEqual([]);
|
||||
});
|
||||
|
||||
it('round-trips with populated dependsOn array', () => {
|
||||
const original: TaskInput = {
|
||||
id: 'rt-deps',
|
||||
name: 'With Deps',
|
||||
dependsOn: ['alpha', 'beta', 'gamma'],
|
||||
};
|
||||
|
||||
const serialized = serializeFrontmatter(original);
|
||||
const parsed = parseFrontmatter(serialized);
|
||||
|
||||
expect(parsed.dependsOn).toEqual(['alpha', 'beta', 'gamma']);
|
||||
});
|
||||
|
||||
it('round-trips with tags array', () => {
|
||||
const original: TaskInput = {
|
||||
id: 'rt-tags',
|
||||
name: 'With Tags',
|
||||
dependsOn: [],
|
||||
tags: ['urgent', 'backend', 'security'],
|
||||
};
|
||||
|
||||
const serialized = serializeFrontmatter(original);
|
||||
const parsed = parseFrontmatter(serialized);
|
||||
|
||||
expect(parsed.tags).toEqual(['urgent', 'backend', 'security']);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user