mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
feat: implement XML extraction utilities and enhance feature handling
- Introduced a new xml-extractor module with functions for XML parsing, including escaping/unescaping XML characters, extracting sections and elements, and managing implemented features. - Added functionality to add, remove, update, and check for implemented features in the app_spec.txt file. - Enhanced the create and update feature handlers to check for duplicate titles and trigger synchronization with app_spec.txt on status changes. - Updated tests to cover new XML extraction utilities and feature handling logic, ensuring robust functionality and reliability.
This commit is contained in:
1026
apps/server/tests/unit/lib/xml-extractor.test.ts
Normal file
1026
apps/server/tests/unit/lib/xml-extractor.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -442,4 +442,471 @@ describe('feature-loader.ts', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByTitle', () => {
|
||||
it('should find feature by exact title match (case-insensitive)', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'feature-1', isDirectory: () => true } as any,
|
||||
{ name: 'feature-2', isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
vi.mocked(fs.readFile)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-1000-abc',
|
||||
title: 'Login Feature',
|
||||
category: 'auth',
|
||||
description: 'Login implementation',
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-2000-def',
|
||||
title: 'Logout Feature',
|
||||
category: 'auth',
|
||||
description: 'Logout implementation',
|
||||
})
|
||||
);
|
||||
|
||||
const result = await loader.findByTitle(testProjectPath, 'LOGIN FEATURE');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.id).toBe('feature-1000-abc');
|
||||
expect(result?.title).toBe('Login Feature');
|
||||
});
|
||||
|
||||
it('should return null when title is not found', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'feature-1', isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-1000-abc',
|
||||
title: 'Login Feature',
|
||||
category: 'auth',
|
||||
description: 'Login implementation',
|
||||
})
|
||||
);
|
||||
|
||||
const result = await loader.findByTitle(testProjectPath, 'Nonexistent Feature');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty or whitespace title', async () => {
|
||||
const result1 = await loader.findByTitle(testProjectPath, '');
|
||||
const result2 = await loader.findByTitle(testProjectPath, ' ');
|
||||
|
||||
expect(result1).toBeNull();
|
||||
expect(result2).toBeNull();
|
||||
});
|
||||
|
||||
it('should skip features without titles', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'feature-1', isDirectory: () => true } as any,
|
||||
{ name: 'feature-2', isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
vi.mocked(fs.readFile)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-1000-abc',
|
||||
// no title
|
||||
category: 'auth',
|
||||
description: 'Login implementation',
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-2000-def',
|
||||
title: 'Login Feature',
|
||||
category: 'auth',
|
||||
description: 'Another login',
|
||||
})
|
||||
);
|
||||
|
||||
const result = await loader.findByTitle(testProjectPath, 'Login Feature');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.id).toBe('feature-2000-def');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findDuplicateTitle', () => {
|
||||
it('should find duplicate title', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'feature-1', isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-1000-abc',
|
||||
title: 'My Feature',
|
||||
category: 'ui',
|
||||
description: 'Feature description',
|
||||
})
|
||||
);
|
||||
|
||||
const result = await loader.findDuplicateTitle(testProjectPath, 'my feature');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.id).toBe('feature-1000-abc');
|
||||
});
|
||||
|
||||
it('should exclude specified feature ID from duplicate check', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'feature-1', isDirectory: () => true } as any,
|
||||
{ name: 'feature-2', isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
vi.mocked(fs.readFile)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-1000-abc',
|
||||
title: 'My Feature',
|
||||
category: 'ui',
|
||||
description: 'Feature 1',
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-2000-def',
|
||||
title: 'Other Feature',
|
||||
category: 'ui',
|
||||
description: 'Feature 2',
|
||||
})
|
||||
);
|
||||
|
||||
// Should not find duplicate when excluding the feature that has the title
|
||||
const result = await loader.findDuplicateTitle(
|
||||
testProjectPath,
|
||||
'My Feature',
|
||||
'feature-1000-abc'
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should find duplicate when title exists on different feature', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'feature-1', isDirectory: () => true } as any,
|
||||
{ name: 'feature-2', isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
vi.mocked(fs.readFile)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-1000-abc',
|
||||
title: 'My Feature',
|
||||
category: 'ui',
|
||||
description: 'Feature 1',
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-2000-def',
|
||||
title: 'Other Feature',
|
||||
category: 'ui',
|
||||
description: 'Feature 2',
|
||||
})
|
||||
);
|
||||
|
||||
// Should find duplicate because feature-1000-abc has the title and we're excluding feature-2000-def
|
||||
const result = await loader.findDuplicateTitle(
|
||||
testProjectPath,
|
||||
'My Feature',
|
||||
'feature-2000-def'
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.id).toBe('feature-1000-abc');
|
||||
});
|
||||
|
||||
it('should return null for empty or whitespace title', async () => {
|
||||
const result1 = await loader.findDuplicateTitle(testProjectPath, '');
|
||||
const result2 = await loader.findDuplicateTitle(testProjectPath, ' ');
|
||||
|
||||
expect(result1).toBeNull();
|
||||
expect(result2).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle titles with leading/trailing whitespace', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'feature-1', isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-1000-abc',
|
||||
title: 'My Feature',
|
||||
category: 'ui',
|
||||
description: 'Feature description',
|
||||
})
|
||||
);
|
||||
|
||||
const result = await loader.findDuplicateTitle(testProjectPath, ' My Feature ');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.id).toBe('feature-1000-abc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncFeatureToAppSpec', () => {
|
||||
const sampleAppSpec = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project_specification>
|
||||
<project_name>Test Project</project_name>
|
||||
<core_capabilities>
|
||||
<capability>Testing</capability>
|
||||
</core_capabilities>
|
||||
<implemented_features>
|
||||
<feature>
|
||||
<name>Existing Feature</name>
|
||||
<description>Already implemented</description>
|
||||
</feature>
|
||||
</implemented_features>
|
||||
</project_specification>`;
|
||||
|
||||
const appSpecWithoutFeatures = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project_specification>
|
||||
<project_name>Test Project</project_name>
|
||||
<core_capabilities>
|
||||
<capability>Testing</capability>
|
||||
</core_capabilities>
|
||||
</project_specification>`;
|
||||
|
||||
it('should add feature to app_spec.txt', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
title: 'New Feature',
|
||||
category: 'ui',
|
||||
description: 'A new feature description',
|
||||
};
|
||||
|
||||
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('app_spec.txt'),
|
||||
expect.stringContaining('New Feature'),
|
||||
'utf-8'
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('A new feature description'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should add feature with file locations', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
title: 'Feature With Locations',
|
||||
category: 'backend',
|
||||
description: 'Feature with file locations',
|
||||
};
|
||||
|
||||
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature, [
|
||||
'src/feature.ts',
|
||||
'src/utils/helper.ts',
|
||||
]);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('src/feature.ts'),
|
||||
'utf-8'
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('src/utils/helper.ts'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when app_spec.txt does not exist', async () => {
|
||||
const error: any = new Error('File not found');
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValueOnce(error);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
title: 'New Feature',
|
||||
category: 'ui',
|
||||
description: 'A new feature description',
|
||||
};
|
||||
|
||||
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false when feature already exists (duplicate)', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-5678-xyz',
|
||||
title: 'Existing Feature', // Same name as existing feature
|
||||
category: 'ui',
|
||||
description: 'Different description',
|
||||
};
|
||||
|
||||
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use feature ID as fallback name when title is missing', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
category: 'ui',
|
||||
description: 'Feature without title',
|
||||
// No title property
|
||||
};
|
||||
|
||||
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('Feature: feature-1234-abc'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle app_spec without implemented_features section', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(appSpecWithoutFeatures);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
title: 'First Feature',
|
||||
category: 'ui',
|
||||
description: 'First implemented feature',
|
||||
};
|
||||
|
||||
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('<implemented_features>'),
|
||||
'utf-8'
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('First Feature'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw on non-ENOENT file read errors', async () => {
|
||||
const error = new Error('Permission denied');
|
||||
vi.mocked(fs.readFile).mockRejectedValueOnce(error);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
title: 'New Feature',
|
||||
category: 'ui',
|
||||
description: 'A new feature description',
|
||||
};
|
||||
|
||||
await expect(loader.syncFeatureToAppSpec(testProjectPath, feature)).rejects.toThrow(
|
||||
'Permission denied'
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve existing features when adding a new one', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
title: 'New Feature',
|
||||
category: 'ui',
|
||||
description: 'A new feature',
|
||||
};
|
||||
|
||||
await loader.syncFeatureToAppSpec(testProjectPath, feature);
|
||||
|
||||
// Verify both old and new features are in the output
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('Existing Feature'),
|
||||
'utf-8'
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('New Feature'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should escape special characters in feature name and description', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
title: 'Feature with <special> & "chars"',
|
||||
category: 'ui',
|
||||
description: 'Description with <tags> & "quotes"',
|
||||
};
|
||||
|
||||
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
|
||||
|
||||
expect(result).toBe(true);
|
||||
// The XML should have escaped characters
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('<special>'),
|
||||
'utf-8'
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('&'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should not add empty file_locations array', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
title: 'Feature Without Locations',
|
||||
category: 'ui',
|
||||
description: 'No file locations',
|
||||
};
|
||||
|
||||
await loader.syncFeatureToAppSpec(testProjectPath, feature, []);
|
||||
|
||||
// File locations should not be included when array is empty
|
||||
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
|
||||
const writtenContent = writeCall[1] as string;
|
||||
|
||||
// Count occurrences of file_locations - should only have the one from Existing Feature if any
|
||||
// The new feature should not add file_locations
|
||||
expect(writtenContent).toContain('Feature Without Locations');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user