feat: implement E2E testing workflow and enhance XML syntax editor

- Added a new GitHub Actions workflow for end-to-end (E2E) testing, including setup for Node.js, Playwright, and server initialization.
- Introduced a setup script for E2E test fixtures to create necessary directories and files.
- Integrated CodeMirror for XML syntax editing in the XmlSyntaxEditor component, improving code highlighting and editing experience.
- Updated package dependencies in package.json and package-lock.json to include new libraries for XML handling and theming.
- Refactored various components for improved readability and consistency, including the sidebar and file browser dialog.
- Added tests for spec editor persistence to ensure data integrity across sessions.
This commit is contained in:
Cody Seibert
2025-12-14 14:12:38 -05:00
parent ae13551033
commit 20a7c8b5a8
16 changed files with 2132 additions and 893 deletions

View File

@@ -0,0 +1,88 @@
/**
* XML Template Format Specification for app_spec.txt
*
* This format must be included in all prompts that generate, modify, or regenerate
* app specifications to ensure consistency across the application.
*/
export const APP_SPEC_XML_FORMAT = `
The app_spec.txt file MUST follow this exact XML format:
<project_specification>
<project_name>Project Name</project_name>
<overview>
A comprehensive description of what the project does, its purpose, and key goals.
</overview>
<technology_stack>
<technology>Technology 1</technology>
<technology>Technology 2</technology>
<!-- List all technologies, frameworks, libraries, and tools used -->
</technology_stack>
<core_capabilities>
<capability>Core capability 1</capability>
<capability>Core capability 2</capability>
<!-- List main features and capabilities the project provides -->
</core_capabilities>
<implemented_features>
<!-- Features that have been implemented (populated by AI agent based on code analysis) -->
</implemented_features>
<!-- Optional sections that may be included: -->
<additional_requirements>
<!-- Any additional requirements or constraints -->
</additional_requirements>
<development_guidelines>
<guideline>Guideline 1</guideline>
<guideline>Guideline 2</guideline>
<!-- Development standards and practices -->
</development_guidelines>
<implementation_roadmap>
<!-- Phases or roadmap items for implementation -->
</implementation_roadmap>
</project_specification>
IMPORTANT:
- All content must be wrapped in valid XML tags
- Use proper XML escaping for special characters (&lt;, &gt;, &amp;)
- Maintain proper indentation (2 spaces)
- All sections should be populated based on project analysis
- The format must be strictly followed - do not use markdown, JSON, or any other format
`;
/**
* Returns a prompt suffix that instructs the AI to format the response as XML
* following the app_spec.txt template format.
*/
export function getAppSpecFormatInstruction(): string {
return `
${APP_SPEC_XML_FORMAT}
CRITICAL FORMATTING REQUIREMENTS:
- Your ENTIRE response MUST be valid XML following the exact template structure above
- Do NOT use markdown formatting (no # headers, no **bold**, no - lists, etc.)
- Do NOT include any explanatory text, prefix, or suffix outside the XML tags
- Do NOT include phrases like "Based on my analysis..." or "I'll create..." before the XML
- Do NOT include any text before <project_specification> or after </project_specification>
- Your response must start IMMEDIATELY with <project_specification> with no preceding text
- Your response must end IMMEDIATELY with </project_specification> with no following text
- Use ONLY XML tags as shown in the template
- Properly escape XML special characters (&lt; for <, &gt; for >, &amp; for &)
- Maintain 2-space indentation for readability
- The output will be saved directly to app_spec.txt and must be parseable as valid XML
- The response must contain exactly ONE root XML element: <project_specification>
- Do not include code blocks, markdown fences, or any other formatting
VERIFICATION: Before responding, verify that:
1. Your response starts with <project_specification> (no spaces, no text before it)
2. Your response ends with </project_specification> (no spaces, no text after it)
3. There is exactly one root XML element
4. There is no explanatory text, analysis, or commentary outside the XML tags
Your response should be ONLY the XML content, nothing else.
`;
}

View File

@@ -15,13 +15,29 @@ let currentAbortController: AbortController | null = null;
function logAuthStatus(context: string): void {
const hasOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
console.log(`[SpecRegeneration] ${context} - Auth Status:`);
console.log(`[SpecRegeneration] CLAUDE_CODE_OAUTH_TOKEN: ${hasOAuthToken ? 'SET (' + process.env.CLAUDE_CODE_OAUTH_TOKEN?.substring(0, 20) + '...)' : 'NOT SET'}`);
console.log(`[SpecRegeneration] ANTHROPIC_API_KEY: ${hasApiKey ? 'SET (' + process.env.ANTHROPIC_API_KEY?.substring(0, 20) + '...)' : 'NOT SET'}`);
console.log(
`[SpecRegeneration] CLAUDE_CODE_OAUTH_TOKEN: ${
hasOAuthToken
? "SET (" +
process.env.CLAUDE_CODE_OAUTH_TOKEN?.substring(0, 20) +
"...)"
: "NOT SET"
}`
);
console.log(
`[SpecRegeneration] ANTHROPIC_API_KEY: ${
hasApiKey
? "SET (" + process.env.ANTHROPIC_API_KEY?.substring(0, 20) + "...)"
: "NOT SET"
}`
);
if (!hasOAuthToken && !hasApiKey) {
console.error(`[SpecRegeneration] ⚠️ WARNING: No authentication configured! SDK will fail.`);
console.error(
`[SpecRegeneration] ⚠️ WARNING: No authentication configured! SDK will fail.`
);
}
}
@@ -30,9 +46,14 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
// Create project spec from overview
router.post("/create", async (req: Request, res: Response) => {
console.log("[SpecRegeneration] ========== /create endpoint called ==========");
console.log("[SpecRegeneration] Request body:", JSON.stringify(req.body, null, 2));
console.log(
"[SpecRegeneration] ========== /create endpoint called =========="
);
console.log(
"[SpecRegeneration] Request body:",
JSON.stringify(req.body, null, 2)
);
try {
const { projectPath, projectOverview, generateFeatures } = req.body as {
projectPath: string;
@@ -42,7 +63,11 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
console.log(`[SpecRegeneration] Parsed params:`);
console.log(`[SpecRegeneration] projectPath: ${projectPath}`);
console.log(`[SpecRegeneration] projectOverview length: ${projectOverview?.length || 0} chars`);
console.log(
`[SpecRegeneration] projectOverview length: ${
projectOverview?.length || 0
} chars`
);
console.log(`[SpecRegeneration] generateFeatures: ${generateFeatures}`);
if (!projectPath || !projectOverview) {
@@ -55,7 +80,9 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
}
if (isRunning) {
console.warn("[SpecRegeneration] Generation already running, rejecting request");
console.warn(
"[SpecRegeneration] Generation already running, rejecting request"
);
res.json({ success: false, error: "Spec generation already running" });
return;
}
@@ -79,19 +106,26 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
console.error("[SpecRegeneration] Error name:", error?.name);
console.error("[SpecRegeneration] Error message:", error?.message);
console.error("[SpecRegeneration] Error stack:", error?.stack);
console.error("[SpecRegeneration] Full error object:", JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
console.error(
"[SpecRegeneration] Full error object:",
JSON.stringify(error, Object.getOwnPropertyNames(error), 2)
);
events.emit("spec-regeneration:event", {
type: "spec_error",
error: error.message || String(error),
});
})
.finally(() => {
console.log("[SpecRegeneration] Generation task finished (success or error)");
console.log(
"[SpecRegeneration] Generation task finished (success or error)"
);
isRunning = false;
currentAbortController = null;
});
console.log("[SpecRegeneration] Returning success response (generation running in background)");
console.log(
"[SpecRegeneration] Returning success response (generation running in background)"
);
res.json({ success: true });
} catch (error) {
console.error("[SpecRegeneration] ❌ Route handler exception:");
@@ -103,9 +137,14 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
// Generate from project definition
router.post("/generate", async (req: Request, res: Response) => {
console.log("[SpecRegeneration] ========== /generate endpoint called ==========");
console.log("[SpecRegeneration] Request body:", JSON.stringify(req.body, null, 2));
console.log(
"[SpecRegeneration] ========== /generate endpoint called =========="
);
console.log(
"[SpecRegeneration] Request body:",
JSON.stringify(req.body, null, 2)
);
try {
const { projectPath, projectDefinition } = req.body as {
projectPath: string;
@@ -114,7 +153,11 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
console.log(`[SpecRegeneration] Parsed params:`);
console.log(`[SpecRegeneration] projectPath: ${projectPath}`);
console.log(`[SpecRegeneration] projectDefinition length: ${projectDefinition?.length || 0} chars`);
console.log(
`[SpecRegeneration] projectDefinition length: ${
projectDefinition?.length || 0
} chars`
);
if (!projectPath || !projectDefinition) {
console.error("[SpecRegeneration] Missing required parameters");
@@ -126,7 +169,9 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
}
if (isRunning) {
console.warn("[SpecRegeneration] Generation already running, rejecting request");
console.warn(
"[SpecRegeneration] Generation already running, rejecting request"
);
res.json({ success: false, error: "Spec generation already running" });
return;
}
@@ -149,19 +194,26 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
console.error("[SpecRegeneration] Error name:", error?.name);
console.error("[SpecRegeneration] Error message:", error?.message);
console.error("[SpecRegeneration] Error stack:", error?.stack);
console.error("[SpecRegeneration] Full error object:", JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
console.error(
"[SpecRegeneration] Full error object:",
JSON.stringify(error, Object.getOwnPropertyNames(error), 2)
);
events.emit("spec-regeneration:event", {
type: "spec_error",
error: error.message || String(error),
});
})
.finally(() => {
console.log("[SpecRegeneration] Generation task finished (success or error)");
console.log(
"[SpecRegeneration] Generation task finished (success or error)"
);
isRunning = false;
currentAbortController = null;
});
console.log("[SpecRegeneration] Returning success response (generation running in background)");
console.log(
"[SpecRegeneration] Returning success response (generation running in background)"
);
res.json({ success: true });
} catch (error) {
console.error("[SpecRegeneration] ❌ Route handler exception:");
@@ -173,9 +225,14 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
// Generate features from existing spec
router.post("/generate-features", async (req: Request, res: Response) => {
console.log("[SpecRegeneration] ========== /generate-features endpoint called ==========");
console.log("[SpecRegeneration] Request body:", JSON.stringify(req.body, null, 2));
console.log(
"[SpecRegeneration] ========== /generate-features endpoint called =========="
);
console.log(
"[SpecRegeneration] Request body:",
JSON.stringify(req.body, null, 2)
);
try {
const { projectPath } = req.body as { projectPath: string };
@@ -188,7 +245,9 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
}
if (isRunning) {
console.warn("[SpecRegeneration] Generation already running, rejecting request");
console.warn(
"[SpecRegeneration] Generation already running, rejecting request"
);
res.json({ success: false, error: "Generation already running" });
return;
}
@@ -197,27 +256,38 @@ export function createSpecRegenerationRoutes(events: EventEmitter): Router {
isRunning = true;
currentAbortController = new AbortController();
console.log("[SpecRegeneration] Starting background feature generation task...");
console.log(
"[SpecRegeneration] Starting background feature generation task..."
);
generateFeaturesFromSpec(projectPath, events, currentAbortController)
.catch((error) => {
console.error("[SpecRegeneration] ❌ Feature generation failed with error:");
console.error(
"[SpecRegeneration] ❌ Feature generation failed with error:"
);
console.error("[SpecRegeneration] Error name:", error?.name);
console.error("[SpecRegeneration] Error message:", error?.message);
console.error("[SpecRegeneration] Error stack:", error?.stack);
console.error("[SpecRegeneration] Full error object:", JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
console.error(
"[SpecRegeneration] Full error object:",
JSON.stringify(error, Object.getOwnPropertyNames(error), 2)
);
events.emit("spec-regeneration:event", {
type: "features_error",
error: error.message || String(error),
});
})
.finally(() => {
console.log("[SpecRegeneration] Feature generation task finished (success or error)");
console.log(
"[SpecRegeneration] Feature generation task finished (success or error)"
);
isRunning = false;
currentAbortController = null;
});
console.log("[SpecRegeneration] Returning success response (generation running in background)");
console.log(
"[SpecRegeneration] Returning success response (generation running in background)"
);
res.json({ success: true });
} catch (error) {
console.error("[SpecRegeneration] ❌ Route handler exception:");
@@ -261,11 +331,15 @@ async function generateSpec(
abortController: AbortController,
generateFeatures?: boolean
) {
console.log("[SpecRegeneration] ========== generateSpec() started ==========");
console.log(
"[SpecRegeneration] ========== generateSpec() started =========="
);
console.log(`[SpecRegeneration] projectPath: ${projectPath}`);
console.log(`[SpecRegeneration] projectOverview length: ${projectOverview.length} chars`);
console.log(
`[SpecRegeneration] projectOverview length: ${projectOverview.length} chars`
);
console.log(`[SpecRegeneration] generateFeatures: ${generateFeatures}`);
const prompt = `You are helping to define a software project specification.
Project Overview:
@@ -281,19 +355,23 @@ Based on this overview, analyze the project and create a comprehensive specifica
6. **API Design** - Main endpoints/interfaces needed
7. **User Experience** - Key user flows and interactions
${generateFeatures ? `
${
generateFeatures
? `
Also generate a list of features to implement. For each feature provide:
- ID (lowercase-hyphenated)
- Title
- Description
- Priority (1=high, 2=medium, 3=low)
- Estimated complexity (simple, moderate, complex)
` : ""}
`
: ""
}
Format your response as markdown. Be specific and actionable.`;
console.log(`[SpecRegeneration] Prompt length: ${prompt.length} chars`);
events.emit("spec-regeneration:event", {
type: "spec_progress",
content: "Starting spec generation...\n",
@@ -308,9 +386,12 @@ Format your response as markdown. Be specific and actionable.`;
abortController,
};
console.log("[SpecRegeneration] SDK Options:", JSON.stringify(options, null, 2));
console.log(
"[SpecRegeneration] SDK Options:",
JSON.stringify(options, null, 2)
);
console.log("[SpecRegeneration] Calling Claude Agent SDK query()...");
// Log auth status right before the SDK call
logAuthStatus("Right before SDK query()");
@@ -332,13 +413,22 @@ Format your response as markdown. Be specific and actionable.`;
try {
for await (const msg of stream) {
messageCount++;
console.log(`[SpecRegeneration] Stream message #${messageCount}:`, JSON.stringify({ type: msg.type, subtype: (msg as any).subtype }, null, 2));
console.log(
`[SpecRegeneration] Stream message #${messageCount}:`,
JSON.stringify(
{ type: msg.type, subtype: (msg as any).subtype },
null,
2
)
);
if (msg.type === "assistant" && msg.message.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
responseText = block.text;
console.log(`[SpecRegeneration] Text block received (${block.text.length} chars)`);
console.log(
`[SpecRegeneration] Text block received (${block.text.length} chars)`
);
events.emit("spec-regeneration:event", {
type: "spec_progress",
content: block.text,
@@ -356,8 +446,13 @@ Format your response as markdown. Be specific and actionable.`;
console.log("[SpecRegeneration] Received success result");
responseText = (msg as any).result || responseText;
} else if ((msg as { type: string }).type === "error") {
console.error("[SpecRegeneration] ❌ Received error message from stream:");
console.error("[SpecRegeneration] Error message:", JSON.stringify(msg, null, 2));
console.error(
"[SpecRegeneration] ❌ Received error message from stream:"
);
console.error(
"[SpecRegeneration] Error message:",
JSON.stringify(msg, null, 2)
);
}
}
} catch (streamError) {
@@ -366,15 +461,19 @@ Format your response as markdown. Be specific and actionable.`;
throw streamError;
}
console.log(`[SpecRegeneration] Stream iteration complete. Total messages: ${messageCount}`);
console.log(`[SpecRegeneration] Response text length: ${responseText.length} chars`);
console.log(
`[SpecRegeneration] Stream iteration complete. Total messages: ${messageCount}`
);
console.log(
`[SpecRegeneration] Response text length: ${responseText.length} chars`
);
// Save spec
const specDir = path.join(projectPath, ".automaker");
const specPath = path.join(specDir, "app_spec.txt");
console.log(`[SpecRegeneration] Saving spec to: ${specPath}`);
await fs.mkdir(specDir, { recursive: true });
await fs.writeFile(specPath, responseText);
@@ -391,8 +490,10 @@ Format your response as markdown. Be specific and actionable.`;
console.log("[SpecRegeneration] Starting feature generation...");
await parseAndCreateFeatures(projectPath, responseText, events);
}
console.log("[SpecRegeneration] ========== generateSpec() completed ==========");
console.log(
"[SpecRegeneration] ========== generateSpec() completed =========="
);
}
async function generateFeaturesFromSpec(
@@ -400,9 +501,11 @@ async function generateFeaturesFromSpec(
events: EventEmitter,
abortController: AbortController
) {
console.log("[SpecRegeneration] ========== generateFeaturesFromSpec() started ==========");
console.log(
"[SpecRegeneration] ========== generateFeaturesFromSpec() started =========="
);
console.log(`[SpecRegeneration] projectPath: ${projectPath}`);
// Read existing spec
const specPath = path.join(projectPath, ".automaker", "app_spec.txt");
let spec: string;
@@ -411,7 +514,9 @@ async function generateFeaturesFromSpec(
try {
spec = await fs.readFile(specPath, "utf-8");
console.log(`[SpecRegeneration] Spec loaded successfully (${spec.length} chars)`);
console.log(
`[SpecRegeneration] Spec loaded successfully (${spec.length} chars)`
);
} catch (readError) {
console.error("[SpecRegeneration] ❌ Failed to read spec file:", readError);
events.emit("spec-regeneration:event", {
@@ -466,9 +571,14 @@ Generate 5-15 features that build on each other logically.`;
abortController,
};
console.log("[SpecRegeneration] SDK Options:", JSON.stringify(options, null, 2));
console.log("[SpecRegeneration] Calling Claude Agent SDK query() for features...");
console.log(
"[SpecRegeneration] SDK Options:",
JSON.stringify(options, null, 2)
);
console.log(
"[SpecRegeneration] Calling Claude Agent SDK query() for features..."
);
logAuthStatus("Right before SDK query() for features");
let stream;
@@ -489,13 +599,22 @@ Generate 5-15 features that build on each other logically.`;
try {
for await (const msg of stream) {
messageCount++;
console.log(`[SpecRegeneration] Feature stream message #${messageCount}:`, JSON.stringify({ type: msg.type, subtype: (msg as any).subtype }, null, 2));
console.log(
`[SpecRegeneration] Feature stream message #${messageCount}:`,
JSON.stringify(
{ type: msg.type, subtype: (msg as any).subtype },
null,
2
)
);
if (msg.type === "assistant" && msg.message.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
responseText = block.text;
console.log(`[SpecRegeneration] Feature text block received (${block.text.length} chars)`);
console.log(
`[SpecRegeneration] Feature text block received (${block.text.length} chars)`
);
events.emit("spec-regeneration:event", {
type: "features_progress",
content: block.text,
@@ -506,22 +625,35 @@ Generate 5-15 features that build on each other logically.`;
console.log("[SpecRegeneration] Received success result for features");
responseText = (msg as any).result || responseText;
} else if ((msg as { type: string }).type === "error") {
console.error("[SpecRegeneration] ❌ Received error message from feature stream:");
console.error("[SpecRegeneration] Error message:", JSON.stringify(msg, null, 2));
console.error(
"[SpecRegeneration] ❌ Received error message from feature stream:"
);
console.error(
"[SpecRegeneration] Error message:",
JSON.stringify(msg, null, 2)
);
}
}
} catch (streamError) {
console.error("[SpecRegeneration] ❌ Error while iterating feature stream:");
console.error(
"[SpecRegeneration] ❌ Error while iterating feature stream:"
);
console.error("[SpecRegeneration] Stream error:", streamError);
throw streamError;
}
console.log(`[SpecRegeneration] Feature stream complete. Total messages: ${messageCount}`);
console.log(`[SpecRegeneration] Feature response length: ${responseText.length} chars`);
console.log(
`[SpecRegeneration] Feature stream complete. Total messages: ${messageCount}`
);
console.log(
`[SpecRegeneration] Feature response length: ${responseText.length} chars`
);
await parseAndCreateFeatures(projectPath, responseText, events);
console.log("[SpecRegeneration] ========== generateFeaturesFromSpec() completed ==========");
console.log(
"[SpecRegeneration] ========== generateFeaturesFromSpec() completed =========="
);
}
async function parseAndCreateFeatures(
@@ -529,24 +661,33 @@ async function parseAndCreateFeatures(
content: string,
events: EventEmitter
) {
console.log("[SpecRegeneration] ========== parseAndCreateFeatures() started ==========");
console.log(
"[SpecRegeneration] ========== parseAndCreateFeatures() started =========="
);
console.log(`[SpecRegeneration] Content length: ${content.length} chars`);
try {
// Extract JSON from response
console.log("[SpecRegeneration] Extracting JSON from response...");
const jsonMatch = content.match(/\{[\s\S]*"features"[\s\S]*\}/);
if (!jsonMatch) {
console.error("[SpecRegeneration] ❌ No valid JSON found in response");
console.error("[SpecRegeneration] Content preview:", content.substring(0, 500));
console.error(
"[SpecRegeneration] Content preview:",
content.substring(0, 500)
);
throw new Error("No valid JSON found in response");
}
console.log(`[SpecRegeneration] JSON match found (${jsonMatch[0].length} chars)`);
console.log(
`[SpecRegeneration] JSON match found (${jsonMatch[0].length} chars)`
);
const parsed = JSON.parse(jsonMatch[0]);
console.log(`[SpecRegeneration] Parsed ${parsed.features?.length || 0} features`);
console.log(
`[SpecRegeneration] Parsed ${parsed.features?.length || 0} features`
);
const featuresDir = path.join(projectPath, ".automaker", "features");
await fs.mkdir(featuresDir, { recursive: true });
@@ -561,7 +702,7 @@ async function parseAndCreateFeatures(
id: feature.id,
title: feature.title,
description: feature.description,
status: "backlog", // Features go to backlog - user must manually start them
status: "backlog", // Features go to backlog - user must manually start them
priority: feature.priority || 2,
complexity: feature.complexity || "moderate",
dependencies: feature.dependencies || [],
@@ -577,7 +718,9 @@ async function parseAndCreateFeatures(
createdFeatures.push({ id: feature.id, title: feature.title });
}
console.log(`[SpecRegeneration] ✓ Created ${createdFeatures.length} features successfully`);
console.log(
`[SpecRegeneration] ✓ Created ${createdFeatures.length} features successfully`
);
events.emit("spec-regeneration:event", {
type: "features_complete",
@@ -592,6 +735,8 @@ async function parseAndCreateFeatures(
error: (error as Error).message,
});
}
console.log("[SpecRegeneration] ========== parseAndCreateFeatures() completed ==========");
console.log(
"[SpecRegeneration] ========== parseAndCreateFeatures() completed =========="
);
}