diff --git a/apps/app/playwright.config.ts b/apps/app/playwright.config.ts index d4d0f866..8e653ad1 100644 --- a/apps/app/playwright.config.ts +++ b/apps/app/playwright.config.ts @@ -1,7 +1,9 @@ import { defineConfig, devices } from "@playwright/test"; const port = process.env.TEST_PORT || 3007; +const serverPort = process.env.TEST_SERVER_PORT || 3008; const reuseServer = process.env.TEST_REUSE_SERVER === "true"; +const mockAgent = process.env.CI === "true" || process.env.AUTOMAKER_MOCK_AGENT === "true"; export default defineConfig({ testDir: "./tests", @@ -25,15 +27,33 @@ export default defineConfig({ ...(reuseServer ? {} : { - webServer: { - command: `npx next dev -p ${port}`, - url: `http://localhost:${port}`, - reuseExistingServer: !process.env.CI, - timeout: 120000, - env: { - ...process.env, - NEXT_PUBLIC_SKIP_SETUP: "true", + webServer: [ + // Backend server - runs with mock agent enabled in CI + { + command: `cd ../server && npm run dev`, + url: `http://localhost:${serverPort}/api/health`, + reuseExistingServer: !process.env.CI, + timeout: 60000, + env: { + ...process.env, + PORT: String(serverPort), + // Enable mock agent in CI to avoid real API calls + AUTOMAKER_MOCK_AGENT: mockAgent ? "true" : "false", + // Allow access to test directories and common project paths + ALLOWED_PROJECT_DIRS: "/Users,/home,/tmp,/var/folders", + }, }, - }, + // Frontend Next.js server + { + command: `npx next dev -p ${port}`, + url: `http://localhost:${port}`, + reuseExistingServer: !process.env.CI, + timeout: 120000, + env: { + ...process.env, + NEXT_PUBLIC_SKIP_SETUP: "true", + }, + }, + ], }), }); diff --git a/apps/app/src/components/ui/autocomplete.tsx b/apps/app/src/components/ui/autocomplete.tsx new file mode 100644 index 00000000..23e094c6 --- /dev/null +++ b/apps/app/src/components/ui/autocomplete.tsx @@ -0,0 +1,223 @@ +"use client"; + +import * as React from "react"; +import { Check, ChevronsUpDown, LucideIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +export interface AutocompleteOption { + value: string; + label?: string; + badge?: string; + isDefault?: boolean; +} + +interface AutocompleteProps { + value: string; + onChange: (value: string) => void; + options: (string | AutocompleteOption)[]; + placeholder?: string; + searchPlaceholder?: string; + emptyMessage?: string; + className?: string; + disabled?: boolean; + icon?: LucideIcon; + allowCreate?: boolean; + createLabel?: (value: string) => string; + "data-testid"?: string; + itemTestIdPrefix?: string; +} + +function normalizeOption(opt: string | AutocompleteOption): AutocompleteOption { + if (typeof opt === "string") { + return { value: opt, label: opt }; + } + return { ...opt, label: opt.label ?? opt.value }; +} + +export function Autocomplete({ + value, + onChange, + options, + placeholder = "Select an option...", + searchPlaceholder = "Search...", + emptyMessage = "No results found.", + className, + disabled = false, + icon: Icon, + allowCreate = false, + createLabel = (v) => `Create "${v}"`, + "data-testid": testId, + itemTestIdPrefix = "option", +}: AutocompleteProps) { + const [open, setOpen] = React.useState(false); + const [inputValue, setInputValue] = React.useState(""); + const [triggerWidth, setTriggerWidth] = React.useState(0); + const triggerRef = React.useRef(null); + + const normalizedOptions = React.useMemo( + () => options.map(normalizeOption), + [options] + ); + + // Update trigger width when component mounts or value changes + React.useEffect(() => { + if (triggerRef.current) { + const updateWidth = () => { + setTriggerWidth(triggerRef.current?.offsetWidth || 0); + }; + + updateWidth(); + + const resizeObserver = new ResizeObserver(updateWidth); + resizeObserver.observe(triggerRef.current); + + return () => { + resizeObserver.disconnect(); + }; + } + }, [value]); + + // Filter options based on input + const filteredOptions = React.useMemo(() => { + if (!inputValue) return normalizedOptions; + const lower = inputValue.toLowerCase(); + return normalizedOptions.filter( + (opt) => + opt.value.toLowerCase().includes(lower) || + opt.label?.toLowerCase().includes(lower) + ); + }, [normalizedOptions, inputValue]); + + // Check if user typed a new value that doesn't exist + const isNewValue = + allowCreate && + inputValue.trim() && + !normalizedOptions.some( + (opt) => opt.value.toLowerCase() === inputValue.toLowerCase() + ); + + // Get display value + const displayValue = React.useMemo(() => { + if (!value) return null; + const found = normalizedOptions.find((opt) => opt.value === value); + return found?.label ?? value; + }, [value, normalizedOptions]); + + return ( + + + + + + + + + + {isNewValue ? ( +
+ Press enter to create{" "} + {inputValue} +
+ ) : ( + emptyMessage + )} +
+ + {/* Show "Create new" option if typing a new value */} + {isNewValue && ( + { + onChange(inputValue); + setInputValue(""); + setOpen(false); + }} + className="text-[var(--status-success)]" + data-testid={`${itemTestIdPrefix}-create-new`} + > + {Icon && } + {createLabel(inputValue)} + + (new) + + + )} + {filteredOptions.map((option) => ( + { + onChange(currentValue === value ? "" : currentValue); + setInputValue(""); + setOpen(false); + }} + data-testid={`${itemTestIdPrefix}-${option.value.toLowerCase().replace(/[\s/\\]+/g, "-")}`} + > + {Icon && } + {option.label} + + {option.badge && ( + + ({option.badge}) + + )} + + ))} + +
+
+
+
+ ); +} diff --git a/apps/app/src/components/ui/branch-autocomplete.tsx b/apps/app/src/components/ui/branch-autocomplete.tsx index 1d5758d8..60838354 100644 --- a/apps/app/src/components/ui/branch-autocomplete.tsx +++ b/apps/app/src/components/ui/branch-autocomplete.tsx @@ -1,23 +1,8 @@ "use client"; import * as React from "react"; -import { Check, ChevronsUpDown, GitBranch } from "lucide-react"; - -import { cn } from "@/lib/utils"; -import { Button } from "@/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; +import { GitBranch } from "lucide-react"; +import { Autocomplete, AutocompleteOption } from "@/components/ui/autocomplete"; interface BranchAutocompleteProps { value: string; @@ -38,114 +23,31 @@ export function BranchAutocomplete({ disabled = false, "data-testid": testId, }: BranchAutocompleteProps) { - const [open, setOpen] = React.useState(false); - const [inputValue, setInputValue] = React.useState(""); - // Always include "main" at the top of suggestions - const allBranches = React.useMemo(() => { + const branchOptions: AutocompleteOption[] = React.useMemo(() => { const branchSet = new Set(["main", ...branches]); - return Array.from(branchSet); + return Array.from(branchSet).map((branch) => ({ + value: branch, + label: branch, + badge: branch === "main" ? "default" : undefined, + })); }, [branches]); - // Filter branches based on input - const filteredBranches = React.useMemo(() => { - if (!inputValue) return allBranches; - const lower = inputValue.toLowerCase(); - return allBranches.filter((b) => b.toLowerCase().includes(lower)); - }, [allBranches, inputValue]); - - // Check if user typed a new branch name that doesn't exist - const isNewBranch = - inputValue.trim() && - !allBranches.some((b) => b.toLowerCase() === inputValue.toLowerCase()); - return ( - - - - - - - - - - {inputValue.trim() ? ( -
- Press enter to create{" "} - {inputValue} -
- ) : ( - "No branches found." - )} -
- - {/* Show "Create new" option if typing a new branch name */} - {isNewBranch && ( - { - onChange(inputValue); - setInputValue(""); - setOpen(false); - }} - className="text-[var(--status-success)]" - data-testid="branch-option-create-new" - > - - Create "{inputValue}" - - (new) - - - )} - {filteredBranches.map((branch) => ( - { - onChange(currentValue); - setInputValue(""); - setOpen(false); - }} - data-testid={`branch-option-${branch.replace(/[/\\]/g, "-")}`} - > - - {branch} - - {branch === "main" && ( - - (default) - - )} - - ))} - -
-
-
-
+ `Create "${v}"`} + data-testid={testId} + itemTestIdPrefix="branch-option" + /> ); } diff --git a/apps/app/src/components/ui/category-autocomplete.tsx b/apps/app/src/components/ui/category-autocomplete.tsx index 8f4b0054..125a15b7 100644 --- a/apps/app/src/components/ui/category-autocomplete.tsx +++ b/apps/app/src/components/ui/category-autocomplete.tsx @@ -1,23 +1,7 @@ "use client"; import * as React from "react"; -import { Check, ChevronsUpDown } from "lucide-react"; - -import { cn } from "@/lib/utils"; -import { Button } from "@/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; +import { Autocomplete } from "@/components/ui/autocomplete"; interface CategoryAutocompleteProps { value: string; @@ -38,81 +22,18 @@ export function CategoryAutocomplete({ disabled = false, "data-testid": testId, }: CategoryAutocompleteProps) { - const [open, setOpen] = React.useState(false); - const [triggerWidth, setTriggerWidth] = React.useState(0); - const triggerRef = React.useRef(null); - - // Update trigger width when component mounts or value changes - React.useEffect(() => { - if (triggerRef.current) { - const updateWidth = () => { - setTriggerWidth(triggerRef.current?.offsetWidth || 0); - }; - - updateWidth(); - - // Listen for resize events to handle responsive behavior - const resizeObserver = new ResizeObserver(updateWidth); - resizeObserver.observe(triggerRef.current); - - return () => { - resizeObserver.disconnect(); - }; - } - }, [value]); - return ( - - - - - - - - - No category found. - - {suggestions.map((suggestion) => ( - { - onChange(currentValue === value ? "" : currentValue); - setOpen(false); - }} - data-testid={`category-option-${suggestion.toLowerCase().replace(/\s+/g, "-")}`} - > - {suggestion} - - - ))} - - - - - + ); } diff --git a/apps/app/tests/feature-lifecycle.spec.ts b/apps/app/tests/feature-lifecycle.spec.ts new file mode 100644 index 00000000..9b42aa5e --- /dev/null +++ b/apps/app/tests/feature-lifecycle.spec.ts @@ -0,0 +1,294 @@ +/** + * Feature Lifecycle End-to-End Tests + * + * Tests the complete feature lifecycle flow: + * 1. Create a feature in backlog + * 2. Drag to in_progress and wait for agent to finish + * 3. Verify it moves to waiting_approval (manual review) + * 4. Click commit and verify git status shows committed changes + * 5. Drag to verified column + * 6. Archive (complete) the feature + * 7. Open archive modal and restore the feature + * 8. Delete the feature + * + * NOTE: This test uses AUTOMAKER_MOCK_AGENT=true to mock the agent + * so it doesn't make real API calls during CI/CD runs. + */ + +import { test, expect } from "@playwright/test"; +import * as fs from "fs"; +import * as path from "path"; +import { exec } from "child_process"; +import { promisify } from "util"; + +import { + waitForNetworkIdle, + createTestGitRepo, + cleanupTempDir, + createTempDirPath, + setupProjectWithPath, + waitForBoardView, + clickAddFeature, + fillAddFeatureDialog, + confirmAddFeature, + dragAndDropWithDndKit, +} from "./utils"; + +const execAsync = promisify(exec); + +// Create unique temp dir for this test run +const TEST_TEMP_DIR = createTempDirPath("feature-lifecycle-tests"); + +interface TestRepo { + path: string; + cleanup: () => Promise; +} + +// Configure all tests to run serially +test.describe.configure({ mode: "serial" }); + +test.describe("Feature Lifecycle Tests", () => { + let testRepo: TestRepo; + let featureId: string; + + test.beforeAll(async () => { + // Create test temp directory + if (!fs.existsSync(TEST_TEMP_DIR)) { + fs.mkdirSync(TEST_TEMP_DIR, { recursive: true }); + } + }); + + test.beforeEach(async () => { + // Create a fresh test repo for each test + testRepo = await createTestGitRepo(TEST_TEMP_DIR); + }); + + test.afterEach(async () => { + // Cleanup test repo after each test + if (testRepo) { + await testRepo.cleanup(); + } + }); + + test.afterAll(async () => { + // Cleanup temp directory + cleanupTempDir(TEST_TEMP_DIR); + }); + + test("complete feature lifecycle: create -> in_progress -> waiting_approval -> commit -> verified -> archive -> restore -> delete", async ({ + page, + }) => { + // Increase timeout for this comprehensive test + test.setTimeout(120000); + + // ========================================================================== + // Step 1: Setup and create a feature in backlog + // ========================================================================== + await setupProjectWithPath(page, testRepo.path); + await page.goto("/"); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Wait a bit for the UI to fully load + await page.waitForTimeout(1000); + + // Click add feature button + await clickAddFeature(page); + + // Fill in the feature details - requesting a file with "yellow" content + const featureDescription = "Create a file named yellow.txt that contains the text yellow"; + const descriptionInput = page.locator('[data-testid="add-feature-dialog"] textarea').first(); + await descriptionInput.fill(featureDescription); + + // Confirm the feature creation + await confirmAddFeature(page); + + // Debug: Check the filesystem to see if feature was created + const featuresDir = path.join(testRepo.path, ".automaker", "features"); + + // Wait for the feature to be created in the filesystem + await expect(async () => { + const dirs = fs.readdirSync(featuresDir); + expect(dirs.length).toBeGreaterThan(0); + }).toPass({ timeout: 10000 }); + + // Reload to force features to load from filesystem + await page.reload(); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // Wait for the feature card to appear on the board + const featureCard = page.getByText(featureDescription).first(); + await expect(featureCard).toBeVisible({ timeout: 15000 }); + + // Get the feature ID from the filesystem + const featureDirs = fs.readdirSync(featuresDir); + featureId = featureDirs[0]; + + // Now get the actual card element by testid + const featureCardByTestId = page.locator(`[data-testid="kanban-card-${featureId}"]`); + await expect(featureCardByTestId).toBeVisible({ timeout: 10000 }); + + // ========================================================================== + // Step 2: Drag feature to in_progress and wait for agent to finish + // ========================================================================== + const dragHandle = page.locator(`[data-testid="drag-handle-${featureId}"]`); + const inProgressColumn = page.locator('[data-testid="kanban-column-in_progress"]'); + + // Perform the drag and drop using dnd-kit compatible method + await dragAndDropWithDndKit(page, dragHandle, inProgressColumn); + + // Wait for the feature to move to in_progress + await page.waitForTimeout(500); + + // The mock agent should complete quickly (about 1.3 seconds based on the sleep times) + // Wait for the feature to move to waiting_approval (manual review) + // The status changes are: in_progress -> waiting_approval after agent completes + await expect(async () => { + const featureData = JSON.parse( + fs.readFileSync(path.join(featuresDir, featureId, "feature.json"), "utf-8") + ); + expect(featureData.status).toBe("waiting_approval"); + }).toPass({ timeout: 30000 }); + + // Refresh page to ensure UI reflects the status change + await page.reload(); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + // ========================================================================== + // Step 3: Verify feature is in waiting_approval (manual review) column + // ========================================================================== + const waitingApprovalColumn = page.locator('[data-testid="kanban-column-waiting_approval"]'); + const cardInWaitingApproval = waitingApprovalColumn.locator(`[data-testid="kanban-card-${featureId}"]`); + await expect(cardInWaitingApproval).toBeVisible({ timeout: 10000 }); + + // Verify the mock agent created the yellow.txt file + const yellowFilePath = path.join(testRepo.path, "yellow.txt"); + expect(fs.existsSync(yellowFilePath)).toBe(true); + const yellowContent = fs.readFileSync(yellowFilePath, "utf-8"); + expect(yellowContent).toBe("yellow"); + + // ========================================================================== + // Step 4: Click commit and verify git status shows committed changes + // ========================================================================== + // The commit button should be visible on the card in waiting_approval + const commitButton = page.locator(`[data-testid="commit-${featureId}"]`); + await expect(commitButton).toBeVisible({ timeout: 5000 }); + await commitButton.click(); + + // Wait for the commit to process + await page.waitForTimeout(2000); + + // Verify git status shows clean (changes committed) + const { stdout: gitStatus } = await execAsync("git status --porcelain", { + cwd: testRepo.path, + }); + // After commit, the yellow.txt file should be committed, so git status should be clean + // (only .automaker directory might have changes) + expect(gitStatus.includes("yellow.txt")).toBe(false); + + // Verify the commit exists in git log + const { stdout: gitLog } = await execAsync("git log --oneline -1", { + cwd: testRepo.path, + }); + expect(gitLog.toLowerCase()).toContain("yellow"); + + // ========================================================================== + // Step 5: Verify feature moved to verified column after commit + // ========================================================================== + // Feature should automatically move to verified after commit + await page.reload(); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + const verifiedColumn = page.locator('[data-testid="kanban-column-verified"]'); + const cardInVerified = verifiedColumn.locator(`[data-testid="kanban-card-${featureId}"]`); + await expect(cardInVerified).toBeVisible({ timeout: 10000 }); + + // ========================================================================== + // Step 6: Archive (complete) the feature + // ========================================================================== + // Click the Complete button on the verified card + const completeButton = page.locator(`[data-testid="complete-${featureId}"]`); + await expect(completeButton).toBeVisible({ timeout: 5000 }); + await completeButton.click(); + + // Wait for the archive action to complete + await page.waitForTimeout(1000); + + // Verify the feature is no longer visible on the board (it's archived) + await expect(cardInVerified).not.toBeVisible({ timeout: 5000 }); + + // Verify feature status is completed in filesystem + const featureData = JSON.parse( + fs.readFileSync(path.join(featuresDir, featureId, "feature.json"), "utf-8") + ); + expect(featureData.status).toBe("completed"); + + // ========================================================================== + // Step 7: Open archive modal and restore the feature + // ========================================================================== + // Click the completed features button to open the archive modal + const completedFeaturesButton = page.locator('[data-testid="completed-features-button"]'); + await expect(completedFeaturesButton).toBeVisible({ timeout: 5000 }); + await completedFeaturesButton.click(); + + // Wait for the modal to open + const completedModal = page.locator('[data-testid="completed-features-modal"]'); + await expect(completedModal).toBeVisible({ timeout: 5000 }); + + // Verify the archived feature is shown in the modal + const archivedCard = completedModal.locator(`[data-testid="completed-card-${featureId}"]`); + await expect(archivedCard).toBeVisible({ timeout: 5000 }); + + // Click the restore button + const restoreButton = page.locator(`[data-testid="unarchive-${featureId}"]`); + await expect(restoreButton).toBeVisible({ timeout: 5000 }); + await restoreButton.click(); + + // Wait for the restore action to complete + await page.waitForTimeout(1000); + + // Close the modal + const closeButton = completedModal.locator('button:has-text("Close")'); + await closeButton.click(); + await expect(completedModal).not.toBeVisible({ timeout: 5000 }); + + // Verify the feature is back in the verified column + const restoredCard = verifiedColumn.locator(`[data-testid="kanban-card-${featureId}"]`); + await expect(restoredCard).toBeVisible({ timeout: 10000 }); + + // Verify feature status is verified in filesystem + const restoredFeatureData = JSON.parse( + fs.readFileSync(path.join(featuresDir, featureId, "feature.json"), "utf-8") + ); + expect(restoredFeatureData.status).toBe("verified"); + + // ========================================================================== + // Step 8: Delete the feature and verify it's removed + // ========================================================================== + // Click the delete button on the verified card + const deleteButton = page.locator(`[data-testid="delete-verified-${featureId}"]`); + await expect(deleteButton).toBeVisible({ timeout: 5000 }); + await deleteButton.click(); + + // Wait for the confirmation dialog + const confirmDialog = page.locator('[data-testid="delete-confirmation-dialog"]'); + await expect(confirmDialog).toBeVisible({ timeout: 5000 }); + + // Click the confirm delete button + const confirmDeleteButton = page.locator('[data-testid="confirm-delete-button"]'); + await confirmDeleteButton.click(); + + // Wait for the delete action to complete + await page.waitForTimeout(1000); + + // Verify the feature is no longer visible on the board + await expect(restoredCard).not.toBeVisible({ timeout: 5000 }); + + // Verify the feature directory is deleted from filesystem + const featureDirExists = fs.existsSync(path.join(featuresDir, featureId)); + expect(featureDirExists).toBe(false); + }); +}); diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 56f52a1d..24212514 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -1156,6 +1156,64 @@ When done, summarize what you implemented and any notes for the developer.`; imagePaths?: string[], model?: string ): Promise { + // CI/CD Mock Mode: Return early with mock response when AUTOMAKER_MOCK_AGENT is set + // This prevents actual API calls during automated testing + if (process.env.AUTOMAKER_MOCK_AGENT === "true") { + console.log(`[AutoMode] MOCK MODE: Skipping real agent execution for feature ${featureId}`); + + // Simulate some work being done + await this.sleep(500); + + // Emit mock progress events to simulate agent activity + this.emitAutoModeEvent("auto_mode_progress", { + featureId, + content: "Mock agent: Analyzing the codebase...", + }); + + await this.sleep(300); + + this.emitAutoModeEvent("auto_mode_progress", { + featureId, + content: "Mock agent: Implementing the feature...", + }); + + await this.sleep(300); + + // Create a mock file with "yellow" content as requested in the test + const mockFilePath = path.join(workDir, "yellow.txt"); + await fs.writeFile(mockFilePath, "yellow"); + + this.emitAutoModeEvent("auto_mode_progress", { + featureId, + content: "Mock agent: Created yellow.txt file with content 'yellow'", + }); + + await this.sleep(200); + + // Save mock agent output + const configProjectPath = this.config?.projectPath || workDir; + const featureDirForOutput = getFeatureDir(configProjectPath, featureId); + const outputPath = path.join(featureDirForOutput, "agent-output.md"); + + const mockOutput = `# Mock Agent Output + +## Summary +This is a mock agent response for CI/CD testing. + +## Changes Made +- Created \`yellow.txt\` with content "yellow" + +## Notes +This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. +`; + + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, mockOutput); + + console.log(`[AutoMode] MOCK MODE: Completed mock execution for feature ${featureId}`); + return; + } + // Build SDK options using centralized configuration for feature implementation const sdkOptions = createAutoModeOptions({ cwd: workDir,