mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +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:
@@ -13,7 +13,7 @@ export { specOutputSchema } from '@automaker/types';
|
|||||||
* Escape special XML characters
|
* Escape special XML characters
|
||||||
* Handles undefined/null values by converting them to empty strings
|
* Handles undefined/null values by converting them to empty strings
|
||||||
*/
|
*/
|
||||||
function escapeXml(str: string | undefined | null): string {
|
export function escapeXml(str: string | undefined | null): string {
|
||||||
if (str == null) {
|
if (str == null) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
466
apps/server/src/lib/xml-extractor.ts
Normal file
466
apps/server/src/lib/xml-extractor.ts
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
/**
|
||||||
|
* XML Extraction Utilities
|
||||||
|
*
|
||||||
|
* Robust XML parsing utilities for extracting and updating sections
|
||||||
|
* from app_spec.txt XML content. Uses regex-based parsing which is
|
||||||
|
* sufficient for our controlled XML structure.
|
||||||
|
*
|
||||||
|
* Note: If more complex XML parsing is needed in the future, consider
|
||||||
|
* using a library like 'fast-xml-parser' or 'xml2js'.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
import type { SpecOutput } from '@automaker/types';
|
||||||
|
|
||||||
|
const logger = createLogger('XmlExtractor');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an implemented feature extracted from XML
|
||||||
|
*/
|
||||||
|
export interface ImplementedFeature {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
file_locations?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logger interface for optional custom logging
|
||||||
|
*/
|
||||||
|
export interface XmlExtractorLogger {
|
||||||
|
debug: (message: string, ...args: unknown[]) => void;
|
||||||
|
warn?: (message: string, ...args: unknown[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for XML extraction operations
|
||||||
|
*/
|
||||||
|
export interface ExtractXmlOptions {
|
||||||
|
/** Custom logger (defaults to internal logger) */
|
||||||
|
logger?: XmlExtractorLogger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape special XML characters
|
||||||
|
* Handles undefined/null values by converting them to empty strings
|
||||||
|
*/
|
||||||
|
export function escapeXml(str: string | undefined | null): string {
|
||||||
|
if (str == null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unescape XML entities back to regular characters
|
||||||
|
*/
|
||||||
|
export function unescapeXml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/&/g, '&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the content of a specific XML section
|
||||||
|
*
|
||||||
|
* @param xmlContent - The full XML content
|
||||||
|
* @param tagName - The tag name to extract (e.g., 'implemented_features')
|
||||||
|
* @param options - Optional extraction options
|
||||||
|
* @returns The content between the tags, or null if not found
|
||||||
|
*/
|
||||||
|
export function extractXmlSection(
|
||||||
|
xmlContent: string,
|
||||||
|
tagName: string,
|
||||||
|
options: ExtractXmlOptions = {}
|
||||||
|
): string | null {
|
||||||
|
const log = options.logger || logger;
|
||||||
|
|
||||||
|
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, 'i');
|
||||||
|
const match = xmlContent.match(regex);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
log.debug(`Extracted <${tagName}> section`);
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug(`Section <${tagName}> not found`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract all values from repeated XML elements
|
||||||
|
*
|
||||||
|
* @param xmlContent - The XML content to search
|
||||||
|
* @param tagName - The tag name to extract values from
|
||||||
|
* @param options - Optional extraction options
|
||||||
|
* @returns Array of extracted values (unescaped)
|
||||||
|
*/
|
||||||
|
export function extractXmlElements(
|
||||||
|
xmlContent: string,
|
||||||
|
tagName: string,
|
||||||
|
options: ExtractXmlOptions = {}
|
||||||
|
): string[] {
|
||||||
|
const log = options.logger || logger;
|
||||||
|
const values: string[] = [];
|
||||||
|
|
||||||
|
const regex = new RegExp(`<${tagName}>(.*?)<\\/${tagName}>`, 'g');
|
||||||
|
const matches = xmlContent.matchAll(regex);
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
values.push(unescapeXml(match[1].trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug(`Extracted ${values.length} <${tagName}> elements`);
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract implemented features from app_spec.txt XML content
|
||||||
|
*
|
||||||
|
* @param specContent - The full XML content of app_spec.txt
|
||||||
|
* @param options - Optional extraction options
|
||||||
|
* @returns Array of implemented features with name, description, and optional file_locations
|
||||||
|
*/
|
||||||
|
export function extractImplementedFeatures(
|
||||||
|
specContent: string,
|
||||||
|
options: ExtractXmlOptions = {}
|
||||||
|
): ImplementedFeature[] {
|
||||||
|
const log = options.logger || logger;
|
||||||
|
const features: ImplementedFeature[] = [];
|
||||||
|
|
||||||
|
// Match <implemented_features>...</implemented_features> section
|
||||||
|
const implementedSection = extractXmlSection(specContent, 'implemented_features', options);
|
||||||
|
|
||||||
|
if (!implementedSection) {
|
||||||
|
log.debug('No implemented_features section found');
|
||||||
|
return features;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract individual feature blocks
|
||||||
|
const featureRegex = /<feature>([\s\S]*?)<\/feature>/g;
|
||||||
|
const featureMatches = implementedSection.matchAll(featureRegex);
|
||||||
|
|
||||||
|
for (const featureMatch of featureMatches) {
|
||||||
|
const featureContent = featureMatch[1];
|
||||||
|
|
||||||
|
// Extract name
|
||||||
|
const nameMatch = featureContent.match(/<name>(.*?)<\/name>/);
|
||||||
|
const name = nameMatch ? unescapeXml(nameMatch[1].trim()) : '';
|
||||||
|
|
||||||
|
// Extract description
|
||||||
|
const descMatch = featureContent.match(/<description>(.*?)<\/description>/);
|
||||||
|
const description = descMatch ? unescapeXml(descMatch[1].trim()) : '';
|
||||||
|
|
||||||
|
// Extract file_locations if present
|
||||||
|
const locationsSection = extractXmlSection(featureContent, 'file_locations', options);
|
||||||
|
const file_locations = locationsSection
|
||||||
|
? extractXmlElements(locationsSection, 'location', options)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
features.push({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
...(file_locations && file_locations.length > 0 ? { file_locations } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug(`Extracted ${features.length} implemented features`);
|
||||||
|
return features;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract only the feature names from implemented_features section
|
||||||
|
*
|
||||||
|
* @param specContent - The full XML content of app_spec.txt
|
||||||
|
* @param options - Optional extraction options
|
||||||
|
* @returns Array of feature names
|
||||||
|
*/
|
||||||
|
export function extractImplementedFeatureNames(
|
||||||
|
specContent: string,
|
||||||
|
options: ExtractXmlOptions = {}
|
||||||
|
): string[] {
|
||||||
|
const features = extractImplementedFeatures(specContent, options);
|
||||||
|
return features.map((f) => f.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate XML for a single implemented feature
|
||||||
|
*
|
||||||
|
* @param feature - The feature to convert to XML
|
||||||
|
* @param indent - The base indentation level (default: 2 spaces)
|
||||||
|
* @returns XML string for the feature
|
||||||
|
*/
|
||||||
|
export function featureToXml(feature: ImplementedFeature, indent: string = ' '): string {
|
||||||
|
const i1 = indent;
|
||||||
|
const i2 = indent + indent;
|
||||||
|
const i3 = indent + indent + indent;
|
||||||
|
const i4 = indent + indent + indent + indent;
|
||||||
|
|
||||||
|
let xml = `${i2}<feature>
|
||||||
|
${i3}<name>${escapeXml(feature.name)}</name>
|
||||||
|
${i3}<description>${escapeXml(feature.description)}</description>`;
|
||||||
|
|
||||||
|
if (feature.file_locations && feature.file_locations.length > 0) {
|
||||||
|
xml += `
|
||||||
|
${i3}<file_locations>
|
||||||
|
${feature.file_locations.map((loc) => `${i4}<location>${escapeXml(loc)}</location>`).join('\n')}
|
||||||
|
${i3}</file_locations>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
xml += `
|
||||||
|
${i2}</feature>`;
|
||||||
|
|
||||||
|
return xml;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate XML for an array of implemented features
|
||||||
|
*
|
||||||
|
* @param features - Array of features to convert to XML
|
||||||
|
* @param indent - The base indentation level (default: 2 spaces)
|
||||||
|
* @returns XML string for the implemented_features section content
|
||||||
|
*/
|
||||||
|
export function featuresToXml(features: ImplementedFeature[], indent: string = ' '): string {
|
||||||
|
return features.map((f) => featureToXml(f, indent)).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the implemented_features section in XML content
|
||||||
|
*
|
||||||
|
* @param specContent - The full XML content
|
||||||
|
* @param newFeatures - The new features to set
|
||||||
|
* @param options - Optional extraction options
|
||||||
|
* @returns Updated XML content with the new implemented_features section
|
||||||
|
*/
|
||||||
|
export function updateImplementedFeaturesSection(
|
||||||
|
specContent: string,
|
||||||
|
newFeatures: ImplementedFeature[],
|
||||||
|
options: ExtractXmlOptions = {}
|
||||||
|
): string {
|
||||||
|
const log = options.logger || logger;
|
||||||
|
const indent = ' ';
|
||||||
|
|
||||||
|
// Generate new section content
|
||||||
|
const newSectionContent = featuresToXml(newFeatures, indent);
|
||||||
|
|
||||||
|
// Build the new section
|
||||||
|
const newSection = `<implemented_features>
|
||||||
|
${newSectionContent}
|
||||||
|
${indent}</implemented_features>`;
|
||||||
|
|
||||||
|
// Check if section exists
|
||||||
|
const sectionRegex = /<implemented_features>[\s\S]*?<\/implemented_features>/;
|
||||||
|
|
||||||
|
if (sectionRegex.test(specContent)) {
|
||||||
|
log.debug('Replacing existing implemented_features section');
|
||||||
|
return specContent.replace(sectionRegex, newSection);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If section doesn't exist, try to insert after core_capabilities
|
||||||
|
const coreCapabilitiesEnd = '</core_capabilities>';
|
||||||
|
const insertIndex = specContent.indexOf(coreCapabilitiesEnd);
|
||||||
|
|
||||||
|
if (insertIndex !== -1) {
|
||||||
|
const insertPosition = insertIndex + coreCapabilitiesEnd.length;
|
||||||
|
log.debug('Inserting implemented_features after core_capabilities');
|
||||||
|
return (
|
||||||
|
specContent.slice(0, insertPosition) +
|
||||||
|
'\n\n' +
|
||||||
|
indent +
|
||||||
|
newSection +
|
||||||
|
specContent.slice(insertPosition)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// As a fallback, insert before </project_specification>
|
||||||
|
const projectSpecEnd = '</project_specification>';
|
||||||
|
const fallbackIndex = specContent.indexOf(projectSpecEnd);
|
||||||
|
|
||||||
|
if (fallbackIndex !== -1) {
|
||||||
|
log.debug('Inserting implemented_features before </project_specification>');
|
||||||
|
return (
|
||||||
|
specContent.slice(0, fallbackIndex) +
|
||||||
|
indent +
|
||||||
|
newSection +
|
||||||
|
'\n' +
|
||||||
|
specContent.slice(fallbackIndex)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.warn?.('Could not find appropriate insertion point for implemented_features');
|
||||||
|
log.debug('Could not find appropriate insertion point for implemented_features');
|
||||||
|
return specContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new feature to the implemented_features section
|
||||||
|
*
|
||||||
|
* @param specContent - The full XML content
|
||||||
|
* @param newFeature - The feature to add
|
||||||
|
* @param options - Optional extraction options
|
||||||
|
* @returns Updated XML content with the new feature added
|
||||||
|
*/
|
||||||
|
export function addImplementedFeature(
|
||||||
|
specContent: string,
|
||||||
|
newFeature: ImplementedFeature,
|
||||||
|
options: ExtractXmlOptions = {}
|
||||||
|
): string {
|
||||||
|
const log = options.logger || logger;
|
||||||
|
|
||||||
|
// Extract existing features
|
||||||
|
const existingFeatures = extractImplementedFeatures(specContent, options);
|
||||||
|
|
||||||
|
// Check for duplicates by name
|
||||||
|
const isDuplicate = existingFeatures.some(
|
||||||
|
(f) => f.name.toLowerCase() === newFeature.name.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isDuplicate) {
|
||||||
|
log.debug(`Feature "${newFeature.name}" already exists, skipping`);
|
||||||
|
return specContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the new feature
|
||||||
|
const updatedFeatures = [...existingFeatures, newFeature];
|
||||||
|
|
||||||
|
log.debug(`Adding feature "${newFeature.name}"`);
|
||||||
|
return updateImplementedFeaturesSection(specContent, updatedFeatures, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a feature from the implemented_features section by name
|
||||||
|
*
|
||||||
|
* @param specContent - The full XML content
|
||||||
|
* @param featureName - The name of the feature to remove
|
||||||
|
* @param options - Optional extraction options
|
||||||
|
* @returns Updated XML content with the feature removed
|
||||||
|
*/
|
||||||
|
export function removeImplementedFeature(
|
||||||
|
specContent: string,
|
||||||
|
featureName: string,
|
||||||
|
options: ExtractXmlOptions = {}
|
||||||
|
): string {
|
||||||
|
const log = options.logger || logger;
|
||||||
|
|
||||||
|
// Extract existing features
|
||||||
|
const existingFeatures = extractImplementedFeatures(specContent, options);
|
||||||
|
|
||||||
|
// Filter out the feature to remove
|
||||||
|
const updatedFeatures = existingFeatures.filter(
|
||||||
|
(f) => f.name.toLowerCase() !== featureName.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updatedFeatures.length === existingFeatures.length) {
|
||||||
|
log.debug(`Feature "${featureName}" not found, no changes made`);
|
||||||
|
return specContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug(`Removing feature "${featureName}"`);
|
||||||
|
return updateImplementedFeaturesSection(specContent, updatedFeatures, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing feature in the implemented_features section
|
||||||
|
*
|
||||||
|
* @param specContent - The full XML content
|
||||||
|
* @param featureName - The name of the feature to update
|
||||||
|
* @param updates - Partial updates to apply to the feature
|
||||||
|
* @param options - Optional extraction options
|
||||||
|
* @returns Updated XML content with the feature modified
|
||||||
|
*/
|
||||||
|
export function updateImplementedFeature(
|
||||||
|
specContent: string,
|
||||||
|
featureName: string,
|
||||||
|
updates: Partial<ImplementedFeature>,
|
||||||
|
options: ExtractXmlOptions = {}
|
||||||
|
): string {
|
||||||
|
const log = options.logger || logger;
|
||||||
|
|
||||||
|
// Extract existing features
|
||||||
|
const existingFeatures = extractImplementedFeatures(specContent, options);
|
||||||
|
|
||||||
|
// Find and update the feature
|
||||||
|
let found = false;
|
||||||
|
const updatedFeatures = existingFeatures.map((f) => {
|
||||||
|
if (f.name.toLowerCase() === featureName.toLowerCase()) {
|
||||||
|
found = true;
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
...updates,
|
||||||
|
// Preserve the original name if not explicitly updated
|
||||||
|
name: updates.name ?? f.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
log.debug(`Feature "${featureName}" not found, no changes made`);
|
||||||
|
return specContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug(`Updating feature "${featureName}"`);
|
||||||
|
return updateImplementedFeaturesSection(specContent, updatedFeatures, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a feature exists in the implemented_features section
|
||||||
|
*
|
||||||
|
* @param specContent - The full XML content
|
||||||
|
* @param featureName - The name of the feature to check
|
||||||
|
* @param options - Optional extraction options
|
||||||
|
* @returns True if the feature exists
|
||||||
|
*/
|
||||||
|
export function hasImplementedFeature(
|
||||||
|
specContent: string,
|
||||||
|
featureName: string,
|
||||||
|
options: ExtractXmlOptions = {}
|
||||||
|
): boolean {
|
||||||
|
const features = extractImplementedFeatures(specContent, options);
|
||||||
|
return features.some((f) => f.name.toLowerCase() === featureName.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert extracted features to SpecOutput.implemented_features format
|
||||||
|
*
|
||||||
|
* @param features - Array of extracted features
|
||||||
|
* @returns Features in SpecOutput format
|
||||||
|
*/
|
||||||
|
export function toSpecOutputFeatures(
|
||||||
|
features: ImplementedFeature[]
|
||||||
|
): SpecOutput['implemented_features'] {
|
||||||
|
return features.map((f) => ({
|
||||||
|
name: f.name,
|
||||||
|
description: f.description,
|
||||||
|
...(f.file_locations && f.file_locations.length > 0
|
||||||
|
? { file_locations: f.file_locations }
|
||||||
|
: {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert SpecOutput.implemented_features to ImplementedFeature format
|
||||||
|
*
|
||||||
|
* @param specFeatures - Features from SpecOutput
|
||||||
|
* @returns Features in ImplementedFeature format
|
||||||
|
*/
|
||||||
|
export function fromSpecOutputFeatures(
|
||||||
|
specFeatures: SpecOutput['implemented_features']
|
||||||
|
): ImplementedFeature[] {
|
||||||
|
return specFeatures.map((f) => ({
|
||||||
|
name: f.name,
|
||||||
|
description: f.description,
|
||||||
|
...(f.file_locations && f.file_locations.length > 0
|
||||||
|
? { file_locations: f.file_locations }
|
||||||
|
: {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -23,6 +23,19 @@ export function createCreateHandler(featureLoader: FeatureLoader) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for duplicate title if title is provided
|
||||||
|
if (feature.title && feature.title.trim()) {
|
||||||
|
const duplicate = await featureLoader.findDuplicateTitle(projectPath, feature.title);
|
||||||
|
if (duplicate) {
|
||||||
|
res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
error: `A feature with title "${feature.title}" already exists`,
|
||||||
|
duplicateFeatureId: duplicate.id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const created = await featureLoader.create(projectPath, feature);
|
const created = await featureLoader.create(projectPath, feature);
|
||||||
res.json({ success: true, feature: created });
|
res.json({ success: true, feature: created });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,8 +4,14 @@
|
|||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||||
import type { Feature } from '@automaker/types';
|
import type { Feature, FeatureStatus } from '@automaker/types';
|
||||||
import { getErrorMessage, logError } from '../common.js';
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
|
const logger = createLogger('features/update');
|
||||||
|
|
||||||
|
// Statuses that should trigger syncing to app_spec.txt
|
||||||
|
const SYNC_TRIGGER_STATUSES: FeatureStatus[] = ['verified', 'completed'];
|
||||||
|
|
||||||
export function createUpdateHandler(featureLoader: FeatureLoader) {
|
export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
@@ -34,6 +40,28 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for duplicate title if title is being updated
|
||||||
|
if (updates.title && updates.title.trim()) {
|
||||||
|
const duplicate = await featureLoader.findDuplicateTitle(
|
||||||
|
projectPath,
|
||||||
|
updates.title,
|
||||||
|
featureId // Exclude the current feature from duplicate check
|
||||||
|
);
|
||||||
|
if (duplicate) {
|
||||||
|
res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
error: `A feature with title "${updates.title}" already exists`,
|
||||||
|
duplicateFeatureId: duplicate.id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current feature to detect status changes
|
||||||
|
const currentFeature = await featureLoader.get(projectPath, featureId);
|
||||||
|
const previousStatus = currentFeature?.status as FeatureStatus | undefined;
|
||||||
|
const newStatus = updates.status as FeatureStatus | undefined;
|
||||||
|
|
||||||
const updated = await featureLoader.update(
|
const updated = await featureLoader.update(
|
||||||
projectPath,
|
projectPath,
|
||||||
featureId,
|
featureId,
|
||||||
@@ -42,6 +70,22 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
|||||||
enhancementMode,
|
enhancementMode,
|
||||||
preEnhancementDescription
|
preEnhancementDescription
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Trigger sync to app_spec.txt when status changes to verified or completed
|
||||||
|
if (newStatus && SYNC_TRIGGER_STATUSES.includes(newStatus) && previousStatus !== newStatus) {
|
||||||
|
try {
|
||||||
|
const synced = await featureLoader.syncFeatureToAppSpec(projectPath, updated);
|
||||||
|
if (synced) {
|
||||||
|
logger.info(
|
||||||
|
`Synced feature "${updated.title || updated.id}" to app_spec.txt on status change to ${newStatus}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (syncError) {
|
||||||
|
// Log the sync error but don't fail the update operation
|
||||||
|
logger.error(`Failed to sync feature to app_spec.txt:`, syncError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ success: true, feature: updated });
|
res.json({ success: true, feature: updated });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, 'Update feature failed');
|
logError(error, 'Update feature failed');
|
||||||
|
|||||||
@@ -2101,6 +2101,16 @@ Format your response as a structured markdown document.`;
|
|||||||
feature.justFinishedAt = undefined;
|
feature.justFinishedAt = undefined;
|
||||||
}
|
}
|
||||||
await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2));
|
await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2));
|
||||||
|
|
||||||
|
// Sync completed/verified features to app_spec.txt
|
||||||
|
if (status === 'verified' || status === 'completed') {
|
||||||
|
try {
|
||||||
|
await this.featureLoader.syncFeatureToAppSpec(projectPath, feature);
|
||||||
|
} catch (syncError) {
|
||||||
|
// Log but don't fail the status update if sync fails
|
||||||
|
logger.warn(`Failed to sync feature ${featureId} to app_spec.txt:`, syncError);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Feature file may not exist
|
// Feature file may not exist
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import {
|
|||||||
getFeaturesDir,
|
getFeaturesDir,
|
||||||
getFeatureDir,
|
getFeatureDir,
|
||||||
getFeatureImagesDir,
|
getFeatureImagesDir,
|
||||||
|
getAppSpecPath,
|
||||||
ensureAutomakerDir,
|
ensureAutomakerDir,
|
||||||
} from '@automaker/platform';
|
} from '@automaker/platform';
|
||||||
|
import { addImplementedFeature, type ImplementedFeature } from '../lib/xml-extractor.js';
|
||||||
|
|
||||||
const logger = createLogger('FeatureLoader');
|
const logger = createLogger('FeatureLoader');
|
||||||
|
|
||||||
@@ -236,6 +238,69 @@ export class FeatureLoader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a title for comparison (case-insensitive, trimmed)
|
||||||
|
*/
|
||||||
|
private normalizeTitle(title: string): string {
|
||||||
|
return title.toLowerCase().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a feature by its title (case-insensitive match)
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @param title - Title to search for
|
||||||
|
* @returns The matching feature or null if not found
|
||||||
|
*/
|
||||||
|
async findByTitle(projectPath: string, title: string): Promise<Feature | null> {
|
||||||
|
if (!title || !title.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedTitle = this.normalizeTitle(title);
|
||||||
|
const features = await this.getAll(projectPath);
|
||||||
|
|
||||||
|
for (const feature of features) {
|
||||||
|
if (feature.title && this.normalizeTitle(feature.title) === normalizedTitle) {
|
||||||
|
return feature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a title already exists on another feature (for duplicate detection)
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @param title - Title to check
|
||||||
|
* @param excludeFeatureId - Optional feature ID to exclude from the check (for updates)
|
||||||
|
* @returns The duplicate feature if found, null otherwise
|
||||||
|
*/
|
||||||
|
async findDuplicateTitle(
|
||||||
|
projectPath: string,
|
||||||
|
title: string,
|
||||||
|
excludeFeatureId?: string
|
||||||
|
): Promise<Feature | null> {
|
||||||
|
if (!title || !title.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedTitle = this.normalizeTitle(title);
|
||||||
|
const features = await this.getAll(projectPath);
|
||||||
|
|
||||||
|
for (const feature of features) {
|
||||||
|
// Skip the feature being updated (if provided)
|
||||||
|
if (excludeFeatureId && feature.id === excludeFeatureId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feature.title && this.normalizeTitle(feature.title) === normalizedTitle) {
|
||||||
|
return feature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a single feature by ID
|
* Get a single feature by ID
|
||||||
*/
|
*/
|
||||||
@@ -460,4 +525,64 @@ export class FeatureLoader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync a completed feature to the app_spec.txt implemented_features section
|
||||||
|
*
|
||||||
|
* When a feature is completed, this method adds it to the implemented_features
|
||||||
|
* section of the project's app_spec.txt file. This keeps the spec in sync
|
||||||
|
* with the actual state of the codebase.
|
||||||
|
*
|
||||||
|
* @param projectPath - Path to the project
|
||||||
|
* @param feature - The feature to sync (must have title or description)
|
||||||
|
* @param fileLocations - Optional array of file paths where the feature was implemented
|
||||||
|
* @returns True if the spec was updated, false if no spec exists or feature was skipped
|
||||||
|
*/
|
||||||
|
async syncFeatureToAppSpec(
|
||||||
|
projectPath: string,
|
||||||
|
feature: Feature,
|
||||||
|
fileLocations?: string[]
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const appSpecPath = getAppSpecPath(projectPath);
|
||||||
|
|
||||||
|
// Read the current app_spec.txt
|
||||||
|
let specContent: string;
|
||||||
|
try {
|
||||||
|
specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
logger.info(`No app_spec.txt found for project, skipping sync for feature ${feature.id}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the implemented feature entry
|
||||||
|
const featureName = feature.title || `Feature: ${feature.id}`;
|
||||||
|
const implementedFeature: ImplementedFeature = {
|
||||||
|
name: featureName,
|
||||||
|
description: feature.description,
|
||||||
|
...(fileLocations && fileLocations.length > 0 ? { file_locations: fileLocations } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add the feature to the implemented_features section
|
||||||
|
const updatedSpecContent = addImplementedFeature(specContent, implementedFeature);
|
||||||
|
|
||||||
|
// Check if the content actually changed (feature might already exist)
|
||||||
|
if (updatedSpecContent === specContent) {
|
||||||
|
logger.info(`Feature "${featureName}" already exists in app_spec.txt, skipping`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the updated spec back to the file
|
||||||
|
await secureFs.writeFile(appSpecPath, updatedSpecContent, 'utf-8');
|
||||||
|
|
||||||
|
logger.info(`Synced feature "${featureName}" to app_spec.txt`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to sync feature ${feature.id} to app_spec.txt:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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