diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..6c71a884 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,111 @@ +name: Release Build + +on: + release: + types: [published] + +jobs: + build: + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Extract version from tag + id: version + shell: bash + run: | + # Remove 'v' prefix if present (e.g., "v1.2.3" -> "1.2.3") + VERSION="${{ github.event.release.tag_name }}" + VERSION="${VERSION#v}" + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "Extracted version: ${VERSION}" + + - name: Update package.json version + shell: bash + run: | + node apps/ui/scripts/update-version.mjs "${{ steps.version.outputs.version }}" + + - name: Setup project + uses: ./.github/actions/setup-project + with: + check-lockfile: 'true' + + - name: Build Electron app (macOS) + if: matrix.os == 'macos-latest' + shell: bash + run: npm run build:electron:mac --workspace=apps/ui + env: + CSC_IDENTITY_AUTO_DISCOVERY: false + + - name: Build Electron app (Windows) + if: matrix.os == 'windows-latest' + shell: bash + run: npm run build:electron:win --workspace=apps/ui + + - name: Build Electron app (Linux) + if: matrix.os == 'ubuntu-latest' + shell: bash + run: npm run build:electron:linux --workspace=apps/ui + + - name: Upload macOS artifacts + if: matrix.os == 'macos-latest' + uses: actions/upload-artifact@v4 + with: + name: macos-builds + path: apps/ui/release/*.{dmg,zip} + retention-days: 30 + + - name: Upload Windows artifacts + if: matrix.os == 'windows-latest' + uses: actions/upload-artifact@v4 + with: + name: windows-builds + path: apps/ui/release/*.exe + retention-days: 30 + + - name: Upload Linux artifacts + if: matrix.os == 'ubuntu-latest' + uses: actions/upload-artifact@v4 + with: + name: linux-builds + path: apps/ui/release/*.{AppImage,deb} + retention-days: 30 + + upload: + needs: build + runs-on: ubuntu-latest + if: github.event.release.draft == false + + steps: + - name: Download macOS artifacts + uses: actions/download-artifact@v4 + with: + name: macos-builds + path: artifacts/macos-builds + + - name: Download Windows artifacts + uses: actions/download-artifact@v4 + with: + name: windows-builds + path: artifacts/windows-builds + + - name: Download Linux artifacts + uses: actions/download-artifact@v4 + with: + name: linux-builds + path: artifacts/linux-builds + + - name: Upload to GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: | + artifacts/macos-builds/* + artifacts/windows-builds/* + artifacts/linux-builds/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/apps/ui/scripts/update-version.mjs b/apps/ui/scripts/update-version.mjs new file mode 100755 index 00000000..ad6d47b9 --- /dev/null +++ b/apps/ui/scripts/update-version.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node +/** + * Updates the version in apps/ui/package.json + * Usage: node scripts/update-version.mjs + * Example: node scripts/update-version.mjs 1.2.3 + */ + +import { readFileSync, writeFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const version = process.argv[2]; + +if (!version) { + console.error('Error: Version argument is required'); + console.error('Usage: node scripts/update-version.mjs '); + process.exit(1); +} + +// Remove 'v' prefix if present (e.g., "v1.2.3" -> "1.2.3") +const cleanVersion = version.startsWith('v') ? version.slice(1) : version; + +// Validate version format (basic semver check) +if (!/^\d+\.\d+\.\d+/.test(cleanVersion)) { + console.error(`Error: Invalid version format: ${cleanVersion}`); + console.error('Expected format: X.Y.Z (e.g., 1.2.3)'); + process.exit(1); +} + +const packageJsonPath = join(__dirname, '..', 'package.json'); + +try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + const oldVersion = packageJson.version; + packageJson.version = cleanVersion; + + writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf8'); + + console.log(`Updated version from ${oldVersion} to ${cleanVersion}`); +} catch (error) { + console.error(`Error updating version: ${error.message}`); + process.exit(1); +} diff --git a/apps/ui/tests/agent/start-new-chat-session.spec.ts b/apps/ui/tests/agent/start-new-chat-session.spec.ts new file mode 100644 index 00000000..add1444d --- /dev/null +++ b/apps/ui/tests/agent/start-new-chat-session.spec.ts @@ -0,0 +1,89 @@ +/** + * Start New Chat Session E2E Test + * + * Happy path: Start a new agent chat session + */ + +import { test, expect } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + createTempDirPath, + cleanupTempDir, + setupRealProject, + waitForNetworkIdle, + navigateToAgent, + clickNewSessionButton, + waitForNewSession, + countSessionItems, +} from '../utils'; + +const TEST_TEMP_DIR = createTempDirPath('agent-session-test'); + +test.describe('Agent Chat Session', () => { + let projectPath: string; + const projectName = `test-project-${Date.now()}`; + + test.beforeAll(async () => { + if (!fs.existsSync(TEST_TEMP_DIR)) { + fs.mkdirSync(TEST_TEMP_DIR, { recursive: true }); + } + + projectPath = path.join(TEST_TEMP_DIR, projectName); + fs.mkdirSync(projectPath, { recursive: true }); + + fs.writeFileSync( + path.join(projectPath, 'package.json'), + JSON.stringify({ name: projectName, version: '1.0.0' }, null, 2) + ); + + const automakerDir = path.join(projectPath, '.automaker'); + fs.mkdirSync(automakerDir, { recursive: true }); + fs.mkdirSync(path.join(automakerDir, 'features'), { recursive: true }); + fs.mkdirSync(path.join(automakerDir, 'context'), { recursive: true }); + fs.mkdirSync(path.join(automakerDir, 'sessions'), { recursive: true }); + + fs.writeFileSync( + path.join(automakerDir, 'categories.json'), + JSON.stringify({ categories: [] }, null, 2) + ); + + fs.writeFileSync( + path.join(automakerDir, 'app_spec.txt'), + `# ${projectName}\n\nA test project for e2e testing.` + ); + }); + + test.afterAll(async () => { + cleanupTempDir(TEST_TEMP_DIR); + }); + + test('should start a new agent chat session', async ({ page }) => { + await setupRealProject(page, projectPath, projectName, { setAsCurrent: true }); + + await page.goto('/'); + await waitForNetworkIdle(page); + + // Navigate to agent view + await navigateToAgent(page); + + // Verify we're on the agent view + await expect(page.locator('[data-testid="agent-view"]')).toBeVisible({ timeout: 10000 }); + + // Click new session button + await clickNewSessionButton(page); + + // Wait for new session to appear in the list + await waitForNewSession(page, { timeout: 10000 }); + + // Verify at least one session exists + const sessionCount = await countSessionItems(page); + expect(sessionCount).toBeGreaterThanOrEqual(1); + + // Verify the message list is visible (indicates a session is selected) + await expect(page.locator('[data-testid="message-list"]')).toBeVisible({ timeout: 5000 }); + + // Verify the agent input is visible + await expect(page.locator('[data-testid="agent-input"]')).toBeVisible(); + }); +}); diff --git a/apps/ui/tests/context-view.spec.ts b/apps/ui/tests/context-view.spec.ts deleted file mode 100644 index ed71b8ff..00000000 --- a/apps/ui/tests/context-view.spec.ts +++ /dev/null @@ -1,642 +0,0 @@ -import { test, expect } from '@playwright/test'; -import * as fs from 'fs'; -import * as path from 'path'; -import { - resetContextDirectory, - createContextFileOnDisk, - contextFileExistsOnDisk, - setupProjectWithFixture, - getFixturePath, - navigateToContext, - waitForFileContentToLoad, - switchToEditMode, - waitForContextFile, - selectContextFile, - simulateFileDrop, - setContextEditorContent, - getContextEditorContent, - clickElement, - fillInput, - getByTestId, - waitForNetworkIdle, -} from './utils'; - -const WORKSPACE_ROOT = path.resolve(process.cwd(), '../..'); -const TEST_IMAGE_SRC = path.join(WORKSPACE_ROOT, 'apps/ui/public/logo.png'); - -// Configure all tests to run serially to prevent interference with shared context directory -test.describe.configure({ mode: 'serial' }); - -// ============================================================================ -// Test Suite 1: Context View - File Management -// ============================================================================ -test.describe('Context View - File Management', () => { - test.beforeEach(async () => { - resetContextDirectory(); - }); - - test.afterEach(async () => { - resetContextDirectory(); - }); - - test('should create a new MD context file', async ({ page }) => { - await setupProjectWithFixture(page, getFixturePath()); - await page.goto('/'); - await waitForNetworkIdle(page); - - await navigateToContext(page); - - // Click Create Markdown button - await clickElement(page, 'create-markdown-button'); - await page.waitForSelector('[data-testid="create-markdown-dialog"]', { - timeout: 5000, - }); - - // Enter filename - await fillInput(page, 'new-markdown-name', 'test-context.md'); - - // Enter content - const testContent = '# Test Context\n\nThis is test content'; - await fillInput(page, 'new-markdown-content', testContent); - - // Click confirm - await clickElement(page, 'confirm-create-markdown'); - - // Wait for dialog to close - await page.waitForFunction( - () => !document.querySelector('[data-testid="create-markdown-dialog"]'), - { timeout: 5000 } - ); - - // Wait for file list to refresh (file should appear) - await waitForContextFile(page, 'test-context.md', 10000); - - // Verify file appears in list - const fileButton = await getByTestId(page, 'context-file-test-context.md'); - await expect(fileButton).toBeVisible(); - - // Click on the file and wait for it to be selected - await selectContextFile(page, 'test-context.md'); - - // Wait for content to load - await waitForFileContentToLoad(page); - - // Switch to edit mode if in preview mode (markdown files default to preview) - await switchToEditMode(page); - - // Wait for editor to be visible - await page.waitForSelector('[data-testid="context-editor"]', { - timeout: 5000, - }); - - // Verify content in editor - const editorContent = await getContextEditorContent(page); - expect(editorContent).toBe(testContent); - }); - - test('should edit an existing MD context file', async ({ page }) => { - // Create a test file on disk first - const originalContent = '# Original Content\n\nThis will be edited.'; - createContextFileOnDisk('edit-test.md', originalContent); - - await setupProjectWithFixture(page, getFixturePath()); - await page.goto('/'); - await waitForNetworkIdle(page); - - await navigateToContext(page); - - // Click on the existing file and wait for it to be selected - await selectContextFile(page, 'edit-test.md'); - - // Wait for file content to load - await waitForFileContentToLoad(page); - - // Switch to edit mode (markdown files open in preview mode by default) - await switchToEditMode(page); - - // Wait for editor - await page.waitForSelector('[data-testid="context-editor"]', { - timeout: 5000, - }); - - // Modify content - const newContent = '# Modified Content\n\nThis has been edited.'; - await setContextEditorContent(page, newContent); - - // Click save - await clickElement(page, 'save-context-file'); - - // Wait for save to complete - await page.waitForFunction( - () => - document.querySelector('[data-testid="save-context-file"]')?.textContent?.includes('Saved'), - { timeout: 5000 } - ); - - // Reload page - await page.reload(); - await waitForNetworkIdle(page); - - // Navigate back to context view - await navigateToContext(page); - - // Wait for file to appear after reload and select it - await selectContextFile(page, 'edit-test.md'); - - // Wait for content to load - await waitForFileContentToLoad(page); - - // Switch to edit mode (markdown files open in preview mode) - await switchToEditMode(page); - - await page.waitForSelector('[data-testid="context-editor"]', { - timeout: 5000, - }); - - // Verify content persisted - const persistedContent = await getContextEditorContent(page); - expect(persistedContent).toBe(newContent); - }); - - test('should remove an MD context file', async ({ page }) => { - // Create a test file on disk first - createContextFileOnDisk('delete-test.md', '# Delete Me'); - - await setupProjectWithFixture(page, getFixturePath()); - await page.goto('/'); - await waitForNetworkIdle(page); - - await navigateToContext(page); - - // Click on the file to select it - const fileButton = await getByTestId(page, 'context-file-delete-test.md'); - await fileButton.waitFor({ state: 'visible', timeout: 5000 }); - await fileButton.click(); - - // Click delete button - await clickElement(page, 'delete-context-file'); - - // Wait for delete dialog - await page.waitForSelector('[data-testid="delete-context-dialog"]', { - timeout: 5000, - }); - - // Confirm deletion - await clickElement(page, 'confirm-delete-file'); - - // Wait for dialog to close - await page.waitForFunction( - () => !document.querySelector('[data-testid="delete-context-dialog"]'), - { timeout: 5000 } - ); - - // Verify file is removed from list - const deletedFile = await getByTestId(page, 'context-file-delete-test.md'); - await expect(deletedFile).not.toBeVisible(); - - // Verify file is removed from disk - expect(contextFileExistsOnDisk('delete-test.md')).toBe(false); - }); - - test('should upload an image context file', async ({ page }) => { - await setupProjectWithFixture(page, getFixturePath()); - await page.goto('/'); - await waitForNetworkIdle(page); - - await navigateToContext(page); - - // Use the hidden file input to upload an image directly - // The "Import File" button triggers this input - const fileInput = page.locator('[data-testid="file-import-input"]'); - await fileInput.setInputFiles(TEST_IMAGE_SRC); - - // Wait for file to appear in the list (filename is extracted from path) - await waitForContextFile(page, 'logo.png', 10000); - - // Verify file appears in list - const fileButton = await getByTestId(page, 'context-file-logo.png'); - await expect(fileButton).toBeVisible(); - - // Click on the image to view it - await fileButton.click(); - - // Verify image preview is displayed - await page.waitForSelector('[data-testid="image-preview"]', { - timeout: 5000, - }); - const imagePreview = await getByTestId(page, 'image-preview'); - await expect(imagePreview).toBeVisible(); - }); - - test('should remove an image context file', async ({ page }) => { - // Create a test image file on disk as base64 data URL (matching app's storage format) - const imageContent = fs.readFileSync(TEST_IMAGE_SRC); - const base64DataUrl = `data:image/png;base64,${imageContent.toString('base64')}`; - const contextPath = path.join(getFixturePath(), '.automaker/context'); - fs.writeFileSync(path.join(contextPath, 'delete-image.png'), base64DataUrl); - - await setupProjectWithFixture(page, getFixturePath()); - await page.goto('/'); - await waitForNetworkIdle(page); - - await navigateToContext(page); - - // Wait for the image file and select it - await selectContextFile(page, 'delete-image.png'); - - // Wait for file content (image preview) to load - await waitForFileContentToLoad(page); - - // Click delete button - await clickElement(page, 'delete-context-file'); - - // Wait for delete dialog - await page.waitForSelector('[data-testid="delete-context-dialog"]', { - timeout: 5000, - }); - - // Confirm deletion - await clickElement(page, 'confirm-delete-file'); - - // Wait for dialog to close - await page.waitForFunction( - () => !document.querySelector('[data-testid="delete-context-dialog"]'), - { timeout: 5000 } - ); - - // Verify file is removed from list - const deletedImageFile = await getByTestId(page, 'context-file-delete-image.png'); - await expect(deletedImageFile).not.toBeVisible(); - }); - - test('should toggle markdown preview mode', async ({ page }) => { - // Create a markdown file with content - const mdContent = - '# Heading\n\n**Bold text** and *italic text*\n\n- List item 1\n- List item 2'; - createContextFileOnDisk('preview-test.md', mdContent); - - await setupProjectWithFixture(page, getFixturePath()); - await page.goto('/'); - await waitForNetworkIdle(page); - - await navigateToContext(page); - - // Click on the markdown file - const fileButton = await getByTestId(page, 'context-file-preview-test.md'); - await fileButton.waitFor({ state: 'visible', timeout: 5000 }); - await fileButton.click(); - - // Wait for content to load (markdown files open in preview mode by default) - await waitForFileContentToLoad(page); - - // Check if preview button is visible (indicates it's a markdown file) - const previewToggle = await getByTestId(page, 'toggle-preview-mode'); - await expect(previewToggle).toBeVisible(); - - // Markdown files always open in preview mode by default (see context-view.tsx:163) - // Verify we're in preview mode - const markdownPreview = await getByTestId(page, 'markdown-preview'); - await expect(markdownPreview).toBeVisible(); - - // Click to switch to edit mode - await previewToggle.click(); - await page.waitForSelector('[data-testid="context-editor"]', { - timeout: 5000, - }); - - // Verify editor is shown - const editor = await getByTestId(page, 'context-editor'); - await expect(editor).toBeVisible(); - await expect(markdownPreview).not.toBeVisible(); - - // Click to switch back to preview mode - await previewToggle.click(); - await page.waitForSelector('[data-testid="markdown-preview"]', { - timeout: 5000, - }); - - // Verify preview is shown - await expect(markdownPreview).toBeVisible(); - }); -}); - -// ============================================================================ -// Test Suite 2: Context View - Drag and Drop -// ============================================================================ -test.describe('Context View - Drag and Drop', () => { - test.beforeEach(async () => { - resetContextDirectory(); - }); - - test.afterEach(async () => { - resetContextDirectory(); - }); - - test('should handle drag and drop of MD file onto textarea in add dialog', async ({ page }) => { - await setupProjectWithFixture(page, getFixturePath()); - await page.goto('/'); - await waitForNetworkIdle(page); - - await navigateToContext(page); - - // Open create markdown dialog - await clickElement(page, 'create-markdown-button'); - await page.waitForSelector('[data-testid="create-markdown-dialog"]', { - timeout: 5000, - }); - - // Simulate drag and drop of a .md file onto the textarea - const droppedContent = '# Dropped Content\n\nThis was dragged and dropped.'; - await simulateFileDrop( - page, - '[data-testid="new-markdown-content"]', - 'dropped-file.md', - droppedContent - ); - - // Wait for content to be populated in textarea - const textarea = await getByTestId(page, 'new-markdown-content'); - await textarea.waitFor({ state: 'visible' }); - await expect(textarea).toHaveValue(droppedContent); - - // Verify content is populated in textarea - const textareaContent = await textarea.inputValue(); - expect(textareaContent).toBe(droppedContent); - - // Verify filename is auto-filled - const filenameValue = await page.locator('[data-testid="new-markdown-name"]').inputValue(); - expect(filenameValue).toBe('dropped-file.md'); - - // Confirm and create the file - await clickElement(page, 'confirm-create-markdown'); - - // Wait for dialog to close - await page.waitForFunction( - () => !document.querySelector('[data-testid="create-markdown-dialog"]'), - { timeout: 5000 } - ); - - // Verify file was created - const droppedFile = await getByTestId(page, 'context-file-dropped-file.md'); - await expect(droppedFile).toBeVisible(); - }); - - test('should handle drag and drop of file onto main view', async ({ page }) => { - await setupProjectWithFixture(page, getFixturePath()); - await page.goto('/'); - await waitForNetworkIdle(page); - - await navigateToContext(page); - - // Wait for the context view to be fully loaded - await page.waitForSelector('[data-testid="context-file-list"]', { - timeout: 5000, - }); - - // Simulate drag and drop onto the drop zone - const droppedContent = 'This is a text file dropped onto the main view.'; - await simulateFileDrop( - page, - '[data-testid="context-drop-zone"]', - 'main-drop.txt', - droppedContent - ); - - // Wait for file to appear in the list (drag-drop triggers file creation) - await waitForContextFile(page, 'main-drop.txt', 15000); - - // Verify file appears in the file list - const fileButton = await getByTestId(page, 'context-file-main-drop.txt'); - await expect(fileButton).toBeVisible(); - - // Select file and verify content - await fileButton.click(); - await page.waitForSelector('[data-testid="context-editor"]', { - timeout: 5000, - }); - - const editorContent = await getContextEditorContent(page); - expect(editorContent).toBe(droppedContent); - }); -}); - -// ============================================================================ -// Test Suite 3: Context View - Edge Cases -// ============================================================================ -test.describe('Context View - Edge Cases', () => { - test.beforeEach(async () => { - resetContextDirectory(); - }); - - test.afterEach(async () => { - resetContextDirectory(); - }); - - test('should handle duplicate filename (overwrite behavior)', async ({ page }) => { - // Create an existing file - createContextFileOnDisk('test.md', '# Original Content'); - - await setupProjectWithFixture(page, getFixturePath()); - await page.goto('/'); - await waitForNetworkIdle(page); - - await navigateToContext(page); - - // Verify the original file exists - const originalFile = await getByTestId(page, 'context-file-test.md'); - await expect(originalFile).toBeVisible(); - - // Try to create another file with the same name - await clickElement(page, 'create-markdown-button'); - await page.waitForSelector('[data-testid="create-markdown-dialog"]', { - timeout: 5000, - }); - - await fillInput(page, 'new-markdown-name', 'test.md'); - await fillInput(page, 'new-markdown-content', '# New Content - Overwritten'); - - await clickElement(page, 'confirm-create-markdown'); - - // Wait for dialog to close - await page.waitForFunction( - () => !document.querySelector('[data-testid="create-markdown-dialog"]'), - { timeout: 5000 } - ); - - // File should still exist (was overwritten) - await expect(originalFile).toBeVisible(); - - // Select the file and verify the new content - await originalFile.click(); - - // Wait for content to load - await waitForFileContentToLoad(page); - - // Switch to edit mode (markdown files open in preview mode) - await switchToEditMode(page); - - await page.waitForSelector('[data-testid="context-editor"]', { - timeout: 5000, - }); - - const editorContent = await getContextEditorContent(page); - expect(editorContent).toBe('# New Content - Overwritten'); - }); - - test('should handle special characters in filename', async ({ page }) => { - await setupProjectWithFixture(page, getFixturePath()); - await page.goto('/'); - await waitForNetworkIdle(page); - - await navigateToContext(page); - - // Test file with parentheses - await clickElement(page, 'create-markdown-button'); - await page.waitForSelector('[data-testid="create-markdown-dialog"]', { - timeout: 5000, - }); - - await fillInput(page, 'new-markdown-name', 'context (1).md'); - await fillInput(page, 'new-markdown-content', 'Content with parentheses in filename'); - - await clickElement(page, 'confirm-create-markdown'); - await page.waitForFunction( - () => !document.querySelector('[data-testid="create-markdown-dialog"]'), - { timeout: 5000 } - ); - - // Verify file is created - use CSS escape for special characters - const fileWithParens = await getByTestId(page, 'context-file-context (1).md'); - await expect(fileWithParens).toBeVisible(); - - // Test file with hyphens and underscores - await clickElement(page, 'create-markdown-button'); - await page.waitForSelector('[data-testid="create-markdown-dialog"]', { - timeout: 5000, - }); - - await fillInput(page, 'new-markdown-name', 'test-file_v2.md'); - await fillInput(page, 'new-markdown-content', 'Content with hyphens and underscores'); - - await clickElement(page, 'confirm-create-markdown'); - await page.waitForFunction( - () => !document.querySelector('[data-testid="create-markdown-dialog"]'), - { timeout: 5000 } - ); - - // Verify file is created - const fileWithHyphens = await getByTestId(page, 'context-file-test-file_v2.md'); - await expect(fileWithHyphens).toBeVisible(); - - // Verify both files are accessible - await fileWithHyphens.click(); - - // Wait for content to load - await waitForFileContentToLoad(page); - - // Switch to edit mode (markdown files open in preview mode) - await switchToEditMode(page); - - await page.waitForSelector('[data-testid="context-editor"]', { - timeout: 5000, - }); - - const content = await getContextEditorContent(page); - expect(content).toBe('Content with hyphens and underscores'); - }); - - test('should handle empty content', async ({ page }) => { - await setupProjectWithFixture(page, getFixturePath()); - await page.goto('/'); - await waitForNetworkIdle(page); - - await navigateToContext(page); - - // Create file with empty content - await clickElement(page, 'create-markdown-button'); - await page.waitForSelector('[data-testid="create-markdown-dialog"]', { - timeout: 5000, - }); - - await fillInput(page, 'new-markdown-name', 'empty-file.md'); - // Don't fill any content - leave it empty - - await clickElement(page, 'confirm-create-markdown'); - await page.waitForFunction( - () => !document.querySelector('[data-testid="create-markdown-dialog"]'), - { timeout: 5000 } - ); - - // Verify file is created - const emptyFile = await getByTestId(page, 'context-file-empty-file.md'); - await expect(emptyFile).toBeVisible(); - - // Select file and verify editor shows empty content - await emptyFile.click(); - - // Wait for content to load - await waitForFileContentToLoad(page); - - // Switch to edit mode (markdown files open in preview mode) - await switchToEditMode(page); - - await page.waitForSelector('[data-testid="context-editor"]', { - timeout: 5000, - }); - - const editorContent = await getContextEditorContent(page); - expect(editorContent).toBe(''); - - // Verify save works with empty content - // The save button should be disabled when there are no changes - // Let's add some content first, then clear it and save - await setContextEditorContent(page, 'temporary'); - await setContextEditorContent(page, ''); - - // Save should work - await clickElement(page, 'save-context-file'); - await page.waitForFunction( - () => - document.querySelector('[data-testid="save-context-file"]')?.textContent?.includes('Saved'), - { timeout: 5000 } - ); - }); - - test('should verify persistence across page refresh', async ({ page }) => { - // Create a file directly on disk to ensure it persists across refreshes - const testContent = '# Persistence Test\n\nThis content should persist.'; - createContextFileOnDisk('persist-test.md', testContent); - - await setupProjectWithFixture(page, getFixturePath()); - await page.goto('/'); - await waitForNetworkIdle(page); - - await navigateToContext(page); - - // Verify file exists before refresh - await waitForContextFile(page, 'persist-test.md', 10000); - - // Refresh the page - await page.reload(); - await waitForNetworkIdle(page); - - // Navigate back to context view - await navigateToContext(page); - - // Select the file after refresh (uses robust clicking mechanism) - await selectContextFile(page, 'persist-test.md'); - - // Wait for file content to load - await waitForFileContentToLoad(page); - - // Switch to edit mode (markdown files open in preview mode) - await switchToEditMode(page); - - await page.waitForSelector('[data-testid="context-editor"]', { - timeout: 5000, - }); - - const persistedContent = await getContextEditorContent(page); - expect(persistedContent).toBe(testContent); - }); -}); diff --git a/apps/ui/tests/context/add-context-image.spec.ts b/apps/ui/tests/context/add-context-image.spec.ts new file mode 100644 index 00000000..ce07ce65 --- /dev/null +++ b/apps/ui/tests/context/add-context-image.spec.ts @@ -0,0 +1,146 @@ +/** + * Add Context Image E2E Test + * + * Happy path: Import an image file to the context via the UI + */ + +import { test, expect } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + resetContextDirectory, + setupProjectWithFixture, + getFixturePath, + navigateToContext, + waitForContextFile, + waitForNetworkIdle, +} from '../utils'; + +test.describe('Add Context Image', () => { + let testImagePath: string; + + test.beforeAll(async () => { + // Create a simple test image (1x1 red PNG) + const fixturePath = getFixturePath(); + testImagePath = path.join(fixturePath, '..', 'test-image.png'); + + // Create a minimal PNG (1x1 pixel red image) + const pngHeader = Buffer.from([ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, // PNG signature + 0x00, + 0x00, + 0x00, + 0x0d, // IHDR chunk length + 0x49, + 0x48, + 0x44, + 0x52, // IHDR + 0x00, + 0x00, + 0x00, + 0x01, // width: 1 + 0x00, + 0x00, + 0x00, + 0x01, // height: 1 + 0x08, + 0x02, // bit depth: 8, color type: 2 (RGB) + 0x00, + 0x00, + 0x00, // compression, filter, interlace + 0x90, + 0x77, + 0x53, + 0xde, // IHDR CRC + 0x00, + 0x00, + 0x00, + 0x0c, // IDAT chunk length + 0x49, + 0x44, + 0x41, + 0x54, // IDAT + 0x08, + 0xd7, + 0x63, + 0xf8, + 0xcf, + 0xc0, + 0x00, + 0x00, + 0x01, + 0x01, + 0x01, + 0x00, // compressed data + 0x18, + 0xdd, + 0x8d, + 0xb4, // IDAT CRC + 0x00, + 0x00, + 0x00, + 0x00, // IEND chunk length + 0x49, + 0x45, + 0x4e, + 0x44, // IEND + 0xae, + 0x42, + 0x60, + 0x82, // IEND CRC + ]); + + fs.writeFileSync(testImagePath, pngHeader); + }); + + test.beforeEach(async () => { + resetContextDirectory(); + }); + + test.afterEach(async () => { + resetContextDirectory(); + }); + + test.afterAll(async () => { + // Clean up test image + if (fs.existsSync(testImagePath)) { + fs.unlinkSync(testImagePath); + } + }); + + test('should import an image file to context', async ({ page }) => { + await setupProjectWithFixture(page, getFixturePath()); + await page.goto('/'); + await waitForNetworkIdle(page); + + await navigateToContext(page); + + // Get the file input element and set the file + const fileInput = page.locator('[data-testid="file-import-input"]'); + + // Use setInputFiles to upload the image + await fileInput.setInputFiles(testImagePath); + + // Wait for the file to appear in the list (filename should be the base name) + const fileName = path.basename(testImagePath); + await waitForContextFile(page, fileName, 15000); + + // Verify the file appears in the list + const fileButton = page.locator(`[data-testid="context-file-${fileName}"]`); + await expect(fileButton).toBeVisible(); + + // Verify the file exists on disk + const fixturePath = getFixturePath(); + const contextImagePath = path.join(fixturePath, '.automaker', 'context', fileName); + await expect(async () => { + expect(fs.existsSync(contextImagePath)).toBe(true); + }).toPass({ timeout: 5000 }); + }); +}); diff --git a/apps/ui/tests/context/context-file-management.spec.ts b/apps/ui/tests/context/context-file-management.spec.ts new file mode 100644 index 00000000..220b345d --- /dev/null +++ b/apps/ui/tests/context/context-file-management.spec.ts @@ -0,0 +1,67 @@ +/** + * Context File Management E2E Test + * + * Happy path: Create a markdown context file + */ + +import { test, expect } from '@playwright/test'; +import { + resetContextDirectory, + setupProjectWithFixture, + getFixturePath, + navigateToContext, + waitForFileContentToLoad, + switchToEditMode, + waitForContextFile, + clickElement, + fillInput, + getByTestId, + waitForNetworkIdle, + getContextEditorContent, +} from '../utils'; + +test.describe('Context File Management', () => { + test.beforeEach(async () => { + resetContextDirectory(); + }); + + test.afterEach(async () => { + resetContextDirectory(); + }); + + test('should create a new markdown context file', async ({ page }) => { + await setupProjectWithFixture(page, getFixturePath()); + await page.goto('/'); + await waitForNetworkIdle(page); + + await navigateToContext(page); + + await clickElement(page, 'create-markdown-button'); + await page.waitForSelector('[data-testid="create-markdown-dialog"]', { timeout: 5000 }); + + await fillInput(page, 'new-markdown-name', 'test-context.md'); + const testContent = '# Test Context\n\nThis is test content'; + await fillInput(page, 'new-markdown-content', testContent); + + await clickElement(page, 'confirm-create-markdown'); + + await page.waitForFunction( + () => !document.querySelector('[data-testid="create-markdown-dialog"]'), + { timeout: 5000 } + ); + + await waitForContextFile(page, 'test-context.md', 10000); + + const fileButton = await getByTestId(page, 'context-file-test-context.md'); + await expect(fileButton).toBeVisible(); + + await fileButton.click(); + await waitForFileContentToLoad(page); + await switchToEditMode(page); + + await page.waitForSelector('[data-testid="context-editor"]', { timeout: 5000 }); + + const editorContent = await getContextEditorContent(page); + expect(editorContent).toBe(testContent); + }); +}); diff --git a/apps/ui/tests/context/delete-context-file.spec.ts b/apps/ui/tests/context/delete-context-file.spec.ts new file mode 100644 index 00000000..0dc0ade5 --- /dev/null +++ b/apps/ui/tests/context/delete-context-file.spec.ts @@ -0,0 +1,75 @@ +/** + * Delete Context File E2E Test + * + * Happy path: Delete a context file via the UI + */ + +import { test, expect } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + resetContextDirectory, + setupProjectWithFixture, + getFixturePath, + navigateToContext, + waitForContextFile, + selectContextFile, + deleteSelectedContextFile, + clickElement, + fillInput, + waitForNetworkIdle, +} from '../utils'; + +test.describe('Delete Context File', () => { + test.beforeEach(async () => { + resetContextDirectory(); + }); + + test.afterEach(async () => { + resetContextDirectory(); + }); + + test('should delete a context file via the UI', async ({ page }) => { + const fileName = 'to-delete.md'; + + await setupProjectWithFixture(page, getFixturePath()); + await page.goto('/'); + await waitForNetworkIdle(page); + + await navigateToContext(page); + + // First create a context file to delete + await clickElement(page, 'create-markdown-button'); + await page.waitForSelector('[data-testid="create-markdown-dialog"]', { timeout: 5000 }); + + await fillInput(page, 'new-markdown-name', fileName); + await fillInput(page, 'new-markdown-content', '# Test File\n\nThis file will be deleted.'); + + await clickElement(page, 'confirm-create-markdown'); + + await page.waitForFunction( + () => !document.querySelector('[data-testid="create-markdown-dialog"]'), + { timeout: 5000 } + ); + + // Wait for the file to appear in the list + await waitForContextFile(page, fileName, 10000); + + // Select the file + await selectContextFile(page, fileName); + + // Delete the selected file + await deleteSelectedContextFile(page); + + // Verify the file is no longer in the list + await expect(async () => { + const fileButton = page.locator(`[data-testid="context-file-${fileName}"]`); + expect(await fileButton.count()).toBe(0); + }).toPass({ timeout: 10000 }); + + // Verify the file is deleted from the filesystem + const fixturePath = getFixturePath(); + const contextPath = path.join(fixturePath, '.automaker', 'context', fileName); + expect(fs.existsSync(contextPath)).toBe(false); + }); +}); diff --git a/apps/ui/tests/feature-lifecycle.spec.ts b/apps/ui/tests/feature-lifecycle.spec.ts deleted file mode 100644 index 0eba0b80..00000000 --- a/apps/ui/tests/feature-lifecycle.spec.ts +++ /dev/null @@ -1,459 +0,0 @@ -/** - * 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, - setupProjectWithPathNoWorktrees, - waitForBoardView, - clickAddFeature, - 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); - }); - - // this one fails in github actions for some reason - test.skip('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 - // ========================================================================== - // Use no-worktrees setup to avoid worktree-related filtering/initialization issues - await setupProjectWithPathNoWorktrees(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); - - // First verify that the drag succeeded by checking for in_progress status - // This helps diagnose if the drag-drop is working or not - await expect(async () => { - const featureData = JSON.parse( - fs.readFileSync(path.join(featuresDir, featureId, 'feature.json'), 'utf-8') - ); - // Feature should be either in_progress (agent running) or waiting_approval (agent done) - expect(['in_progress', 'waiting_approval']).toContain(featureData.status); - }).toPass({ timeout: 15000 }); - - // 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 - use first() to select the footer Close button, not the X button - const closeButton = completedModal.locator('button:has-text("Close")').first(); - 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); - }); - - // this one fails in github actions for some reason - test.skip("stop and restart feature: create -> in_progress -> stop -> restart should work without 'Feature not found' error", async ({ - page, - }) => { - // This test verifies that stopping a feature and restarting it works correctly - // Bug: Previously, stopping a feature and immediately restarting could cause - // "Feature not found" error due to race conditions - test.setTimeout(120000); - - // ========================================================================== - // Step 1: Setup and create a feature in backlog - // ========================================================================== - await setupProjectWithPathNoWorktrees(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - await page.waitForTimeout(1000); - - // Click add feature button - await clickAddFeature(page); - - // Fill in the feature details - const featureDescription = 'Create a file named test-restart.txt'; - const descriptionInput = page.locator('[data-testid="add-feature-dialog"] textarea').first(); - await descriptionInput.fill(featureDescription); - - // Confirm the feature creation - await confirmAddFeature(page); - - // Wait for the feature to be created in the filesystem - const featuresDir = path.join(testRepo.path, '.automaker', 'features'); - await expect(async () => { - const dirs = fs.readdirSync(featuresDir); - expect(dirs.length).toBeGreaterThan(0); - }).toPass({ timeout: 10000 }); - - // Get the feature ID - const featureDirs = fs.readdirSync(featuresDir); - const testFeatureId = featureDirs[0]; - - // Reload to ensure features are loaded - await page.reload(); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - // Wait for the feature card to appear - const featureCard = page.locator(`[data-testid="kanban-card-${testFeatureId}"]`); - await expect(featureCard).toBeVisible({ timeout: 10000 }); - - // ========================================================================== - // Step 2: Drag feature to in_progress (first start) - // ========================================================================== - const dragHandle = page.locator(`[data-testid="drag-handle-${testFeatureId}"]`); - const inProgressColumn = page.locator('[data-testid="kanban-column-in_progress"]'); - - await dragAndDropWithDndKit(page, dragHandle, inProgressColumn); - - // Verify feature file still exists and is readable - const featureFilePath = path.join(featuresDir, testFeatureId, 'feature.json'); - expect(fs.existsSync(featureFilePath)).toBe(true); - - // First verify that the drag succeeded by checking for in_progress status - await expect(async () => { - const featureData = JSON.parse(fs.readFileSync(featureFilePath, 'utf-8')); - // Feature should be either in_progress (agent running) or waiting_approval (agent done) - expect(['in_progress', 'waiting_approval']).toContain(featureData.status); - }).toPass({ timeout: 15000 }); - - // ========================================================================== - // Step 3: Wait for the mock agent to complete (it's fast in mock mode) - // ========================================================================== - // The mock agent completes quickly, so we wait for it to finish - await expect(async () => { - const featureData = JSON.parse(fs.readFileSync(featureFilePath, 'utf-8')); - expect(featureData.status).toBe('waiting_approval'); - }).toPass({ timeout: 30000 }); - - // Verify feature file still exists after completion - expect(fs.existsSync(featureFilePath)).toBe(true); - const featureDataAfterComplete = JSON.parse(fs.readFileSync(featureFilePath, 'utf-8')); - console.log('Feature status after first run:', featureDataAfterComplete.status); - - // Reload to ensure clean state - await page.reload(); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - // ========================================================================== - // Step 4: Move feature back to backlog to simulate stop scenario - // ========================================================================== - // Feature is in waiting_approval, drag it back to backlog - const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]'); - const currentCard = page.locator(`[data-testid="kanban-card-${testFeatureId}"]`); - const currentDragHandle = page.locator(`[data-testid="drag-handle-${testFeatureId}"]`); - - await expect(currentCard).toBeVisible({ timeout: 10000 }); - await dragAndDropWithDndKit(page, currentDragHandle, backlogColumn); - await page.waitForTimeout(500); - - // Verify feature is in backlog - await expect(async () => { - const data = JSON.parse(fs.readFileSync(featureFilePath, 'utf-8')); - expect(data.status).toBe('backlog'); - }).toPass({ timeout: 10000 }); - - // Reload to ensure clean state - await page.reload(); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - // ========================================================================== - // Step 5: Restart the feature (drag to in_progress again) - // ========================================================================== - const restartCard = page.locator(`[data-testid="kanban-card-${testFeatureId}"]`); - await expect(restartCard).toBeVisible({ timeout: 10000 }); - - const restartDragHandle = page.locator(`[data-testid="drag-handle-${testFeatureId}"]`); - const inProgressColumnRestart = page.locator('[data-testid="kanban-column-in_progress"]'); - - // Listen for console errors to catch "Feature not found" - const consoleErrors: string[] = []; - page.on('console', (msg) => { - if (msg.type() === 'error') { - consoleErrors.push(msg.text()); - } - }); - - // Drag to in_progress to restart - await dragAndDropWithDndKit(page, restartDragHandle, inProgressColumnRestart); - - // Verify the feature file still exists - expect(fs.existsSync(featureFilePath)).toBe(true); - - // First verify that the restart drag succeeded by checking for in_progress status - await expect(async () => { - const data = JSON.parse(fs.readFileSync(featureFilePath, 'utf-8')); - // Feature should be either in_progress (agent running) or waiting_approval (agent done) - expect(['in_progress', 'waiting_approval']).toContain(data.status); - }).toPass({ timeout: 15000 }); - - // Verify no "Feature not found" errors in console - const featureNotFoundErrors = consoleErrors.filter( - (err) => err.includes('not found') || err.includes('Feature') - ); - expect(featureNotFoundErrors).toEqual([]); - - // Wait for the mock agent to complete and move to waiting_approval - await expect(async () => { - const data = JSON.parse(fs.readFileSync(featureFilePath, 'utf-8')); - expect(data.status).toBe('waiting_approval'); - }).toPass({ timeout: 30000 }); - - console.log('Feature successfully restarted after stop!'); - }); -}); diff --git a/apps/ui/tests/features/add-feature-to-backlog.spec.ts b/apps/ui/tests/features/add-feature-to-backlog.spec.ts new file mode 100644 index 00000000..2231e0be --- /dev/null +++ b/apps/ui/tests/features/add-feature-to-backlog.spec.ts @@ -0,0 +1,85 @@ +/** + * Feature Backlog E2E Test + * + * Happy path: Add a feature to the backlog + */ + +import { test, expect } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + createTempDirPath, + cleanupTempDir, + setupRealProject, + waitForNetworkIdle, + clickAddFeature, + fillAddFeatureDialog, + confirmAddFeature, +} from '../utils'; + +const TEST_TEMP_DIR = createTempDirPath('feature-backlog-test'); + +test.describe('Feature Backlog', () => { + let projectPath: string; + const projectName = `test-project-${Date.now()}`; + + test.beforeAll(async () => { + if (!fs.existsSync(TEST_TEMP_DIR)) { + fs.mkdirSync(TEST_TEMP_DIR, { recursive: true }); + } + + projectPath = path.join(TEST_TEMP_DIR, projectName); + fs.mkdirSync(projectPath, { recursive: true }); + + fs.writeFileSync( + path.join(projectPath, 'package.json'), + JSON.stringify({ name: projectName, version: '1.0.0' }, null, 2) + ); + + const automakerDir = path.join(projectPath, '.automaker'); + fs.mkdirSync(automakerDir, { recursive: true }); + fs.mkdirSync(path.join(automakerDir, 'features'), { recursive: true }); + fs.mkdirSync(path.join(automakerDir, 'context'), { recursive: true }); + + fs.writeFileSync( + path.join(automakerDir, 'categories.json'), + JSON.stringify({ categories: [] }, null, 2) + ); + + fs.writeFileSync( + path.join(automakerDir, 'app_spec.txt'), + `# ${projectName}\n\nA test project for e2e testing.` + ); + }); + + test.afterAll(async () => { + cleanupTempDir(TEST_TEMP_DIR); + }); + + test('should add a new feature to the backlog', async ({ page }) => { + const featureDescription = `Test feature ${Date.now()}`; + + await setupRealProject(page, projectPath, projectName, { setAsCurrent: true }); + + await page.goto('/board'); + await waitForNetworkIdle(page); + + await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('[data-testid="kanban-column-backlog"]')).toBeVisible({ + timeout: 5000, + }); + + await clickAddFeature(page); + await fillAddFeatureDialog(page, featureDescription); + await confirmAddFeature(page); + + // Wait for the feature to appear in the backlog + await expect(async () => { + const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]'); + const featureCard = backlogColumn.locator('[data-testid^="kanban-card-"]').filter({ + hasText: featureDescription, + }); + expect(await featureCard.count()).toBeGreaterThan(0); + }).toPass({ timeout: 10000 }); + }); +}); diff --git a/apps/ui/tests/features/edit-feature.spec.ts b/apps/ui/tests/features/edit-feature.spec.ts new file mode 100644 index 00000000..6efd1763 --- /dev/null +++ b/apps/ui/tests/features/edit-feature.spec.ts @@ -0,0 +1,139 @@ +/** + * Edit Feature E2E Test + * + * Happy path: Edit an existing feature's description and verify changes persist + */ + +import { test, expect } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + createTempDirPath, + cleanupTempDir, + setupRealProject, + waitForNetworkIdle, + clickAddFeature, + fillAddFeatureDialog, + confirmAddFeature, + clickElement, +} from '../utils'; + +const TEST_TEMP_DIR = createTempDirPath('edit-feature-test'); + +test.describe('Edit Feature', () => { + let projectPath: string; + const projectName = `test-project-${Date.now()}`; + + test.beforeAll(async () => { + if (!fs.existsSync(TEST_TEMP_DIR)) { + fs.mkdirSync(TEST_TEMP_DIR, { recursive: true }); + } + + projectPath = path.join(TEST_TEMP_DIR, projectName); + fs.mkdirSync(projectPath, { recursive: true }); + + fs.writeFileSync( + path.join(projectPath, 'package.json'), + JSON.stringify({ name: projectName, version: '1.0.0' }, null, 2) + ); + + const automakerDir = path.join(projectPath, '.automaker'); + fs.mkdirSync(automakerDir, { recursive: true }); + fs.mkdirSync(path.join(automakerDir, 'features'), { recursive: true }); + fs.mkdirSync(path.join(automakerDir, 'context'), { recursive: true }); + + fs.writeFileSync( + path.join(automakerDir, 'categories.json'), + JSON.stringify({ categories: [] }, null, 2) + ); + + fs.writeFileSync( + path.join(automakerDir, 'app_spec.txt'), + `# ${projectName}\n\nA test project for e2e testing.` + ); + }); + + test.afterAll(async () => { + cleanupTempDir(TEST_TEMP_DIR); + }); + + test('should edit an existing feature description', async ({ page }) => { + const originalDescription = `Original feature ${Date.now()}`; + const updatedDescription = `Updated feature ${Date.now()}`; + + await setupRealProject(page, projectPath, projectName, { setAsCurrent: true }); + + await page.goto('/board'); + await waitForNetworkIdle(page); + + await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('[data-testid="kanban-column-backlog"]')).toBeVisible({ + timeout: 5000, + }); + + // Create a feature first + await clickAddFeature(page); + await fillAddFeatureDialog(page, originalDescription); + await confirmAddFeature(page); + + // Wait for the feature to appear in the backlog + await expect(async () => { + const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]'); + const featureCard = backlogColumn.locator('[data-testid^="kanban-card-"]').filter({ + hasText: originalDescription, + }); + expect(await featureCard.count()).toBeGreaterThan(0); + }).toPass({ timeout: 10000 }); + + // Get the feature ID from the card + const featureCard = page + .locator('[data-testid="kanban-column-backlog"]') + .locator('[data-testid^="kanban-card-"]') + .filter({ hasText: originalDescription }) + .first(); + const cardTestId = await featureCard.getAttribute('data-testid'); + const featureId = cardTestId?.replace('kanban-card-', ''); + + // Collapse the sidebar first to avoid it intercepting clicks + const collapseSidebarButton = page.locator('button:has-text("Collapse sidebar")'); + if (await collapseSidebarButton.isVisible()) { + await collapseSidebarButton.click(); + await page.waitForTimeout(300); // Wait for sidebar animation + } + + // Click the edit button on the card using JavaScript click to bypass pointer interception + const editButton = page.locator(`[data-testid="edit-backlog-${featureId}"]`); + await expect(editButton).toBeVisible({ timeout: 5000 }); + await editButton.evaluate((el) => (el as HTMLElement).click()); + + // Wait for edit dialog to appear + await expect(page.locator('[data-testid="edit-feature-dialog"]')).toBeVisible({ + timeout: 10000, + }); + + // Update the description - the input is inside the DescriptionImageDropZone + const descriptionInput = page + .locator('[data-testid="edit-feature-dialog"]') + .getByPlaceholder('Describe the feature...'); + await expect(descriptionInput).toBeVisible({ timeout: 5000 }); + await descriptionInput.fill(updatedDescription); + + // Save changes + await clickElement(page, 'confirm-edit-feature'); + + // Wait for dialog to close + await page.waitForFunction( + () => !document.querySelector('[data-testid="edit-feature-dialog"]'), + { timeout: 5000 } + ); + + // Verify the updated description appears in the card + await expect(async () => { + const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]'); + const updatedCard = backlogColumn.locator('[data-testid^="kanban-card-"]').filter({ + hasText: updatedDescription, + }); + expect(await updatedCard.count()).toBeGreaterThan(0); + }).toPass({ timeout: 10000 }); + }); +}); diff --git a/apps/ui/tests/features/feature-manual-review-flow.spec.ts b/apps/ui/tests/features/feature-manual-review-flow.spec.ts new file mode 100644 index 00000000..5b175b35 --- /dev/null +++ b/apps/ui/tests/features/feature-manual-review-flow.spec.ts @@ -0,0 +1,119 @@ +/** + * Feature Manual Review Flow E2E Test + * + * Happy path: Manually verify a feature in the waiting_approval column + * + * This test verifies that: + * 1. A feature in waiting_approval column shows the mark as verified button + * 2. Clicking mark as verified moves the feature to the verified column + * + * Note: For waiting_approval features, the button is "mark-as-verified-{id}" not "manual-verify-{id}" + */ + +import { test, expect } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + createTempDirPath, + cleanupTempDir, + setupRealProject, + waitForNetworkIdle, + getKanbanColumn, +} from '../utils'; + +const TEST_TEMP_DIR = createTempDirPath('manual-review-test'); + +test.describe('Feature Manual Review Flow', () => { + let projectPath: string; + const projectName = `test-project-${Date.now()}`; + const featureId = 'test-feature-manual-review'; + + test.beforeAll(async () => { + if (!fs.existsSync(TEST_TEMP_DIR)) { + fs.mkdirSync(TEST_TEMP_DIR, { recursive: true }); + } + + projectPath = path.join(TEST_TEMP_DIR, projectName); + fs.mkdirSync(projectPath, { recursive: true }); + + fs.writeFileSync( + path.join(projectPath, 'package.json'), + JSON.stringify({ name: projectName, version: '1.0.0' }, null, 2) + ); + + const automakerDir = path.join(projectPath, '.automaker'); + fs.mkdirSync(automakerDir, { recursive: true }); + fs.mkdirSync(path.join(automakerDir, 'features'), { recursive: true }); + fs.mkdirSync(path.join(automakerDir, 'context'), { recursive: true }); + + fs.writeFileSync( + path.join(automakerDir, 'categories.json'), + JSON.stringify({ categories: [] }, null, 2) + ); + + fs.writeFileSync( + path.join(automakerDir, 'app_spec.txt'), + `# ${projectName}\n\nA test project for e2e testing.` + ); + + // Create a feature file that is in waiting_approval status + const featureDir = path.join(automakerDir, 'features', featureId); + fs.mkdirSync(featureDir, { recursive: true }); + + const feature = { + id: featureId, + description: 'Test feature for manual review flow', + category: 'test', + status: 'waiting_approval', + skipTests: true, + model: 'sonnet', + thinkingLevel: 'none', + createdAt: new Date().toISOString(), + branchName: '', + priority: 2, + }; + + fs.writeFileSync(path.join(featureDir, 'feature.json'), JSON.stringify(feature, null, 2)); + }); + + test.afterAll(async () => { + cleanupTempDir(TEST_TEMP_DIR); + }); + + test('should manually verify a feature in waiting_approval column', async ({ page }) => { + await setupRealProject(page, projectPath, projectName, { setAsCurrent: true }); + + await page.goto('/board'); + await waitForNetworkIdle(page); + + await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 }); + + // Verify the feature appears in the waiting_approval column + const waitingApprovalColumn = await getKanbanColumn(page, 'waiting_approval'); + await expect(waitingApprovalColumn).toBeVisible({ timeout: 5000 }); + + const featureCard = page.locator(`[data-testid="kanban-card-${featureId}"]`); + await expect(featureCard).toBeVisible({ timeout: 10000 }); + + // For waiting_approval features, the button is "mark-as-verified-{id}" + const markAsVerifiedButton = page.locator(`[data-testid="mark-as-verified-${featureId}"]`); + await expect(markAsVerifiedButton).toBeVisible({ timeout: 5000 }); + + // Click the mark as verified button + await markAsVerifiedButton.click(); + + // Wait for the feature to move to verified column + await expect(async () => { + const verifiedColumn = await getKanbanColumn(page, 'verified'); + const cardInVerified = verifiedColumn.locator(`[data-testid="kanban-card-${featureId}"]`); + expect(await cardInVerified.count()).toBe(1); + }).toPass({ timeout: 15000 }); + + // Verify the feature is no longer in waiting_approval column + await expect(async () => { + const waitingColumn = await getKanbanColumn(page, 'waiting_approval'); + const cardInWaiting = waitingColumn.locator(`[data-testid="kanban-card-${featureId}"]`); + expect(await cardInWaiting.count()).toBe(0); + }).toPass({ timeout: 5000 }); + }); +}); diff --git a/apps/ui/tests/features/feature-skip-tests-toggle.spec.ts b/apps/ui/tests/features/feature-skip-tests-toggle.spec.ts new file mode 100644 index 00000000..e6a668c6 --- /dev/null +++ b/apps/ui/tests/features/feature-skip-tests-toggle.spec.ts @@ -0,0 +1,107 @@ +/** + * Feature Skip Tests Toggle E2E Test + * + * Happy path: Create a feature with default settings (skipTests=true) and verify the badge appears + * + * Note: The app defaults to skipTests=true (manual verification required), so we don't need to + * toggle anything. We just verify the badge appears by default. + */ + +import { test, expect } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + createTempDirPath, + cleanupTempDir, + setupRealProject, + waitForNetworkIdle, + clickAddFeature, + fillAddFeatureDialog, + confirmAddFeature, + isSkipTestsBadgeVisible, +} from '../utils'; + +const TEST_TEMP_DIR = createTempDirPath('skip-tests-toggle-test'); + +test.describe('Feature Skip Tests Badge', () => { + let projectPath: string; + const projectName = `test-project-${Date.now()}`; + + test.beforeAll(async () => { + if (!fs.existsSync(TEST_TEMP_DIR)) { + fs.mkdirSync(TEST_TEMP_DIR, { recursive: true }); + } + + projectPath = path.join(TEST_TEMP_DIR, projectName); + fs.mkdirSync(projectPath, { recursive: true }); + + fs.writeFileSync( + path.join(projectPath, 'package.json'), + JSON.stringify({ name: projectName, version: '1.0.0' }, null, 2) + ); + + const automakerDir = path.join(projectPath, '.automaker'); + fs.mkdirSync(automakerDir, { recursive: true }); + fs.mkdirSync(path.join(automakerDir, 'features'), { recursive: true }); + fs.mkdirSync(path.join(automakerDir, 'context'), { recursive: true }); + + fs.writeFileSync( + path.join(automakerDir, 'categories.json'), + JSON.stringify({ categories: [] }, null, 2) + ); + + fs.writeFileSync( + path.join(automakerDir, 'app_spec.txt'), + `# ${projectName}\n\nA test project for e2e testing.` + ); + }); + + test.afterAll(async () => { + cleanupTempDir(TEST_TEMP_DIR); + }); + + test('should show skip tests badge for new feature with default settings', async ({ page }) => { + const featureDescription = `Skip tests feature ${Date.now()}`; + + await setupRealProject(page, projectPath, projectName, { setAsCurrent: true }); + + await page.goto('/board'); + await waitForNetworkIdle(page); + + await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('[data-testid="kanban-column-backlog"]')).toBeVisible({ + timeout: 5000, + }); + + // Open the add feature dialog and add feature with default settings + // Default is skipTests=true (manual verification required) + await clickAddFeature(page); + await fillAddFeatureDialog(page, featureDescription); + await confirmAddFeature(page); + + // Wait for the feature to appear in the backlog + await expect(async () => { + const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]'); + const featureCard = backlogColumn.locator('[data-testid^="kanban-card-"]').filter({ + hasText: featureDescription, + }); + expect(await featureCard.count()).toBeGreaterThan(0); + }).toPass({ timeout: 10000 }); + + // Get the feature ID from the card + const featureCard = page + .locator('[data-testid="kanban-column-backlog"]') + .locator('[data-testid^="kanban-card-"]') + .filter({ hasText: featureDescription }) + .first(); + const cardTestId = await featureCard.getAttribute('data-testid'); + const featureId = cardTestId?.replace('kanban-card-', ''); + + // Verify the skip tests badge is visible on the card (should be there by default) + expect(featureId).toBeDefined(); + await expect(async () => { + const badgeVisible = await isSkipTestsBadgeVisible(page, featureId!); + expect(badgeVisible).toBe(true); + }).toPass({ timeout: 5000 }); + }); +}); diff --git a/apps/ui/tests/git/worktree-integration.spec.ts b/apps/ui/tests/git/worktree-integration.spec.ts new file mode 100644 index 00000000..9af95638 --- /dev/null +++ b/apps/ui/tests/git/worktree-integration.spec.ts @@ -0,0 +1,60 @@ +/** + * Worktree Integration E2E Test + * + * Happy path: Display worktree selector with main branch + */ + +import { test, expect } from '@playwright/test'; +import * as fs from 'fs'; +import { + waitForNetworkIdle, + createTestGitRepo, + cleanupTempDir, + createTempDirPath, + setupProjectWithPath, + waitForBoardView, +} from '../utils'; + +const TEST_TEMP_DIR = createTempDirPath('worktree-tests'); + +interface TestRepo { + path: string; + cleanup: () => Promise; +} + +test.describe('Worktree Integration', () => { + let testRepo: TestRepo; + + test.beforeAll(async () => { + if (!fs.existsSync(TEST_TEMP_DIR)) { + fs.mkdirSync(TEST_TEMP_DIR, { recursive: true }); + } + }); + + test.beforeEach(async () => { + testRepo = await createTestGitRepo(TEST_TEMP_DIR); + }); + + test.afterEach(async () => { + if (testRepo) { + await testRepo.cleanup(); + } + }); + + test.afterAll(async () => { + cleanupTempDir(TEST_TEMP_DIR); + }); + + test('should display worktree selector with main branch', async ({ page }) => { + await setupProjectWithPath(page, testRepo.path); + await page.goto('/'); + await waitForNetworkIdle(page); + await waitForBoardView(page); + + const branchLabel = page.getByText('Branch:'); + await expect(branchLabel).toBeVisible({ timeout: 10000 }); + + const mainBranchButton = page.locator('[data-testid="worktree-branch-main"]'); + await expect(mainBranchButton).toBeVisible({ timeout: 15000 }); + }); +}); diff --git a/apps/ui/tests/kanban-responsive-scaling.spec.ts b/apps/ui/tests/kanban-responsive-scaling.spec.ts deleted file mode 100644 index ac92ae2c..00000000 --- a/apps/ui/tests/kanban-responsive-scaling.spec.ts +++ /dev/null @@ -1,291 +0,0 @@ -/** - * Kanban Board Responsive Scaling Tests - * - * Tests that the Kanban board columns scale intelligently to fill - * the available window space without dead space or content being cut off. - */ - -import { test, expect } from '@playwright/test'; -import * as fs from 'fs'; - -import { - waitForNetworkIdle, - createTestGitRepo, - cleanupTempDir, - createTempDirPath, - setupProjectWithPathNoWorktrees, - waitForBoardView, -} from './utils'; - -// Create unique temp dir for this test run -const TEST_TEMP_DIR = createTempDirPath('kanban-responsive-tests'); - -interface TestRepo { - path: string; - cleanup: () => Promise; -} - -test.describe('Kanban Responsive Scaling Tests', () => { - let testRepo: TestRepo; - - 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('kanban columns should scale to fill available width at different viewport sizes', async ({ - page, - }) => { - // Setup project and navigate to board view - await setupProjectWithPathNoWorktrees(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - // Wait for the board to fully render - await page.waitForTimeout(500); - - // Get all four kanban columns - const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]'); - const inProgressColumn = page.locator('[data-testid="kanban-column-in_progress"]'); - const waitingApprovalColumn = page.locator('[data-testid="kanban-column-waiting_approval"]'); - const verifiedColumn = page.locator('[data-testid="kanban-column-verified"]'); - - // Verify all columns are visible - await expect(backlogColumn).toBeVisible(); - await expect(inProgressColumn).toBeVisible(); - await expect(waitingApprovalColumn).toBeVisible(); - await expect(verifiedColumn).toBeVisible(); - - // Test at different viewport widths - const viewportWidths = [1024, 1280, 1440, 1920]; - - for (const width of viewportWidths) { - // Set viewport size - await page.setViewportSize({ width, height: 900 }); - await page.waitForTimeout(300); // Wait for resize to take effect - - // Get column widths - const backlogBox = await backlogColumn.boundingBox(); - const inProgressBox = await inProgressColumn.boundingBox(); - const waitingApprovalBox = await waitingApprovalColumn.boundingBox(); - const verifiedBox = await verifiedColumn.boundingBox(); - - expect(backlogBox).not.toBeNull(); - expect(inProgressBox).not.toBeNull(); - expect(waitingApprovalBox).not.toBeNull(); - expect(verifiedBox).not.toBeNull(); - - if (backlogBox && inProgressBox && waitingApprovalBox && verifiedBox) { - // All columns should have the same width - const columnWidths = [ - backlogBox.width, - inProgressBox.width, - waitingApprovalBox.width, - verifiedBox.width, - ]; - - // All columns should be equal width (within 2px tolerance for rounding) - const baseWidth = columnWidths[0]; - for (const columnWidth of columnWidths) { - expect(Math.abs(columnWidth - baseWidth)).toBeLessThan(2); - } - - // Column width should be at least minimum (280px) - // No max-width - columns scale evenly to fill available viewport - expect(baseWidth).toBeGreaterThanOrEqual(280); - - // Columns should not overlap (check x positions) - expect(inProgressBox.x).toBeGreaterThan(backlogBox.x + backlogBox.width - 5); - expect(waitingApprovalBox.x).toBeGreaterThan(inProgressBox.x + inProgressBox.width - 5); - expect(verifiedBox.x).toBeGreaterThan(waitingApprovalBox.x + waitingApprovalBox.width - 5); - } - } - }); - - test('kanban columns should be centered in the viewport', async ({ page }) => { - // Setup project and navigate to board view - await setupProjectWithPathNoWorktrees(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - // Wait for the board to fully render - await page.waitForTimeout(500); - - // Set a specific viewport size - await page.setViewportSize({ width: 1600, height: 900 }); - await page.waitForTimeout(300); - - // Get the first and last columns - const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]'); - const verifiedColumn = page.locator('[data-testid="kanban-column-verified"]'); - - const backlogBox = await backlogColumn.boundingBox(); - const verifiedBox = await verifiedColumn.boundingBox(); - - expect(backlogBox).not.toBeNull(); - expect(verifiedBox).not.toBeNull(); - - if (backlogBox && verifiedBox) { - // Get the actual container width (accounting for sidebar) - // The board-view container is inside a flex container that accounts for sidebar - const containerWidth = await page.evaluate(() => { - const boardView = document.querySelector('[data-testid="board-view"]'); - if (!boardView) return window.innerWidth; - const parent = boardView.parentElement; - return parent ? parent.clientWidth : window.innerWidth; - }); - - // Calculate the left and right margins relative to the container - // The bounding box x is relative to the viewport, so we need to find where - // the container starts relative to the viewport - const containerLeft = await page.evaluate(() => { - const boardView = document.querySelector('[data-testid="board-view"]'); - if (!boardView) return 0; - const parent = boardView.parentElement; - if (!parent) return 0; - const rect = parent.getBoundingClientRect(); - return rect.left; - }); - - // Calculate margins relative to the container - const leftMargin = backlogBox.x - containerLeft; - const rightMargin = containerWidth - (verifiedBox.x + verifiedBox.width - containerLeft); - - // The margins should be roughly equal (columns are centered) - // Allow for some tolerance due to padding and gaps - const marginDifference = Math.abs(leftMargin - rightMargin); - expect(marginDifference).toBeLessThan(50); // Should be reasonably centered - } - }); - - test('kanban columns should have no horizontal scrollbar at standard viewport width', async ({ - page, - }) => { - // Setup project and navigate to board view - await setupProjectWithPathNoWorktrees(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - // Set a standard viewport size (1400px which is the default window width) - await page.setViewportSize({ width: 1400, height: 900 }); - await page.waitForTimeout(300); - - // Check if horizontal scrollbar is present by comparing scrollWidth and clientWidth - const hasHorizontalScroll = await page.evaluate(() => { - const boardContainer = document.querySelector('[data-testid="board-view"]'); - if (!boardContainer) return false; - return boardContainer.scrollWidth > boardContainer.clientWidth; - }); - - // There should be no horizontal scroll at standard width since columns scale down - expect(hasHorizontalScroll).toBe(false); - }); - - test('kanban columns should fit at minimum width (1500px - Electron minimum)', async ({ - page, - }) => { - // Setup project and navigate to board view - await setupProjectWithPathNoWorktrees(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - // Set viewport to the new Electron minimum width (1500px) - await page.setViewportSize({ width: 1500, height: 900 }); - await page.waitForTimeout(300); - - // Get all four kanban columns - const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]'); - const verifiedColumn = page.locator('[data-testid="kanban-column-verified"]'); - - // Verify columns are visible - await expect(backlogColumn).toBeVisible(); - await expect(verifiedColumn).toBeVisible(); - - // Check if horizontal scrollbar is present - const hasHorizontalScroll = await page.evaluate(() => { - const boardContainer = document.querySelector('[data-testid="board-view"]'); - if (!boardContainer) return false; - return boardContainer.scrollWidth > boardContainer.clientWidth; - }); - - // There should be no horizontal scroll at minimum width - expect(hasHorizontalScroll).toBe(false); - - // Verify columns are at least minimum width (280px) - const backlogBox = await backlogColumn.boundingBox(); - expect(backlogBox).not.toBeNull(); - if (backlogBox) { - expect(backlogBox.width).toBeGreaterThanOrEqual(280); - } - }); - - test('kanban columns should scale correctly when sidebar is collapsed', async ({ page }) => { - // Setup project and navigate to board view - await setupProjectWithPathNoWorktrees(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - // Set a viewport size - await page.setViewportSize({ width: 1600, height: 900 }); - await page.waitForTimeout(300); - - // Get initial column width - const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]'); - const initialBox = await backlogColumn.boundingBox(); - expect(initialBox).not.toBeNull(); - - // Find and click the sidebar collapse button - const collapseButton = page.locator('[data-testid="sidebar-collapse-button"]'); - if (await collapseButton.isVisible()) { - await collapseButton.click(); - - // Wait for sidebar transition (300ms) + buffer - await page.waitForTimeout(400); - - // Get column width after collapse - const collapsedBox = await backlogColumn.boundingBox(); - expect(collapsedBox).not.toBeNull(); - - if (initialBox && collapsedBox) { - // Column should be wider or same after sidebar collapse (more space available) - // Allow for small variations due to transitions - expect(collapsedBox.width).toBeGreaterThanOrEqual(initialBox.width - 5); - - // Width should still be at least minimum - expect(collapsedBox.width).toBeGreaterThanOrEqual(280); - } - - // Verify no horizontal scrollbar after collapse - const hasHorizontalScroll = await page.evaluate(() => { - const boardContainer = document.querySelector('[data-testid="board-view"]'); - if (!boardContainer) return false; - return boardContainer.scrollWidth > boardContainer.clientWidth; - }); - expect(hasHorizontalScroll).toBe(false); - } - }); -}); diff --git a/apps/ui/tests/profiles-view.spec.ts b/apps/ui/tests/profiles-view.spec.ts deleted file mode 100644 index 110c930d..00000000 --- a/apps/ui/tests/profiles-view.spec.ts +++ /dev/null @@ -1,982 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { - setupMockProjectWithProfiles, - waitForNetworkIdle, - navigateToProfiles, - clickNewProfileButton, - clickEmptyState, - fillProfileForm, - saveProfile, - cancelProfileDialog, - clickEditProfile, - clickDeleteProfile, - confirmDeleteProfile, - cancelDeleteProfile, - fillProfileName, - fillProfileDescription, - selectIcon, - selectModel, - selectThinkingLevel, - isAddProfileDialogOpen, - isEditProfileDialogOpen, - isDeleteConfirmDialogOpen, - getProfileName, - getProfileDescription, - getProfileModel, - getProfileThinkingLevel, - isBuiltInProfile, - isEditButtonVisible, - isDeleteButtonVisible, - dragProfile, - getProfileOrder, - clickRefreshDefaults, - countCustomProfiles, - countBuiltInProfiles, - getProfileCard, - waitForSuccessToast, - waitForToast, - waitForErrorToast, - waitForDialogClose, - pressModifierEnter, - clickElement, -} from './utils'; - -test.describe('AI Profiles View', () => { - // ============================================================================ - // Profile Creation Tests - // ============================================================================ - - test.describe('Profile Creation', () => { - test.beforeEach(async ({ page }) => { - // Start with no custom profiles (only built-in) - await setupMockProjectWithProfiles(page, { customProfilesCount: 0 }); - await page.goto('/'); - await waitForNetworkIdle(page); - await navigateToProfiles(page); - }); - - test('should create profile via header button', async ({ page }) => { - // Click the "New Profile" button - await clickNewProfileButton(page); - - // Verify dialog is open - expect(await isAddProfileDialogOpen(page)).toBe(true); - - // Fill in profile data - await fillProfileForm(page, { - name: 'Test Profile', - description: 'A test profile', - icon: 'Brain', - model: 'sonnet', - thinkingLevel: 'medium', - }); - - // Save the profile - await saveProfile(page); - - // Verify success toast - await waitForSuccessToast(page, 'Profile created'); - - // Verify profile appears in the list - const customCount = await countCustomProfiles(page); - expect(customCount).toBe(1); - - // Verify profile details - get the dynamic profile ID - // (Note: Profile IDs are dynamically generated, not "custom-profile-1") - // We can verify count but skip checking the specific profile name since ID is dynamic - }); - - test('should create profile via empty state', async ({ page }) => { - // Click the empty state card - await clickEmptyState(page); - - // Verify dialog is open - expect(await isAddProfileDialogOpen(page)).toBe(true); - - // Fill and save - await fillProfileForm(page, { - name: 'Empty State Profile', - description: 'Created from empty state', - model: 'opus', - }); - - await saveProfile(page); - - // Verify profile was created - await waitForSuccessToast(page, 'Profile created'); - const customCount = await countCustomProfiles(page); - expect(customCount).toBe(1); - }); - - test('should create profile with each icon option', async ({ page }) => { - const icons = ['Brain', 'Zap', 'Scale', 'Cpu', 'Rocket', 'Sparkles']; - - for (const icon of icons) { - await clickNewProfileButton(page); - - await fillProfileForm(page, { - name: `Profile with ${icon}`, - model: 'haiku', - icon, - }); - - await saveProfile(page); - await waitForSuccessToast(page, 'Profile created'); - // Ensure dialog is fully closed before next iteration - await waitForDialogClose(page); - } - - // Verify all profiles were created - const customCount = await countCustomProfiles(page); - expect(customCount).toBe(icons.length); - }); - - test('should create profile with each model option', async ({ page }) => { - const models = ['haiku', 'sonnet', 'opus']; - - for (const model of models) { - await clickNewProfileButton(page); - - await fillProfileForm(page, { - name: `Profile with ${model}`, - model, - }); - - await saveProfile(page); - await waitForSuccessToast(page, 'Profile created'); - // Ensure dialog is fully closed before next iteration - await waitForDialogClose(page); - } - - // Verify all profiles were created - const customCount = await countCustomProfiles(page); - expect(customCount).toBe(models.length); - }); - - test('should create profile with different thinking levels', async ({ page }) => { - const levels = ['none', 'low', 'medium', 'high', 'ultrathink']; - - for (const level of levels) { - await clickNewProfileButton(page); - - await fillProfileForm(page, { - name: `Profile with ${level}`, - model: 'opus', // Opus supports all thinking levels - thinkingLevel: level, - }); - - await saveProfile(page); - await waitForSuccessToast(page, 'Profile created'); - // Ensure dialog is fully closed before next iteration - await waitForDialogClose(page); - } - - // Verify all profiles were created - const customCount = await countCustomProfiles(page); - expect(customCount).toBe(levels.length); - }); - - test('should show warning toast when selecting ultrathink', async ({ page }) => { - await clickNewProfileButton(page); - - await fillProfileForm(page, { - name: 'Ultrathink Profile', - model: 'opus', - }); - - // Select ultrathink - await selectThinkingLevel(page, 'ultrathink'); - - // Verify warning toast appears - await waitForToast(page, 'Ultrathink uses extensive reasoning'); - }); - - test('should cancel profile creation', async ({ page }) => { - await clickNewProfileButton(page); - - // Fill partial data - await fillProfileName(page, 'Cancelled Profile'); - - // Cancel - await cancelProfileDialog(page); - - // Verify dialog is closed - expect(await isAddProfileDialogOpen(page)).toBe(false); - - // Verify no profile was created - const customCount = await countCustomProfiles(page); - expect(customCount).toBe(0); - }); - - test('should close dialog on overlay click', async ({ page }) => { - await clickNewProfileButton(page); - - // Click the backdrop/overlay to close the dialog - // The dialog overlay is the background outside the dialog content - const dialogBackdrop = page.locator('[data-radix-dialog-overlay]'); - if ((await dialogBackdrop.count()) > 0) { - await dialogBackdrop.click({ position: { x: 10, y: 10 } }); - } else { - // Fallback: press Escape key - await page.keyboard.press('Escape'); - } - - // Wait for dialog to fully close (handles animation) - await waitForDialogClose(page); - - // Verify dialog is closed - expect(await isAddProfileDialogOpen(page)).toBe(false); - - // Verify no profile was created - const customCount = await countCustomProfiles(page); - expect(customCount).toBe(0); - }); - }); - - // ============================================================================ - // Profile Editing Tests - // ============================================================================ - - test.describe('Profile Editing', () => { - test.beforeEach(async ({ page }) => { - // Start with one custom profile - await setupMockProjectWithProfiles(page, { customProfilesCount: 1 }); - await page.goto('/'); - await waitForNetworkIdle(page); - await navigateToProfiles(page); - }); - - test('should edit profile name', async ({ page }) => { - // Click edit button for the custom profile - await clickEditProfile(page, 'custom-profile-1'); - - // Verify dialog is open - expect(await isEditProfileDialogOpen(page)).toBe(true); - - // Update name - await fillProfileName(page, 'Updated Profile Name'); - - // Save - await saveProfile(page); - - // Verify success toast - await waitForSuccessToast(page, 'Profile updated'); - - // Verify name was updated - const profileName = await getProfileName(page, 'custom-profile-1'); - expect(profileName).toContain('Updated Profile Name'); - }); - - test('should edit profile description', async ({ page }) => { - await clickEditProfile(page, 'custom-profile-1'); - - // Update description - await fillProfileDescription(page, 'Updated description'); - - await saveProfile(page); - await waitForSuccessToast(page, 'Profile updated'); - - // Verify description was updated - const description = await getProfileDescription(page, 'custom-profile-1'); - expect(description).toContain('Updated description'); - }); - - test('should change profile icon', async ({ page }) => { - await clickEditProfile(page, 'custom-profile-1'); - - // Change icon to a different one - await selectIcon(page, 'Rocket'); - - await saveProfile(page); - await waitForSuccessToast(page, 'Profile updated'); - - // Verify icon was changed (visual check via profile card) - const card = await getProfileCard(page, 'custom-profile-1'); - const rocketIcon = card.locator('svg[class*="lucide-rocket"]'); - expect(await rocketIcon.isVisible()).toBe(true); - }); - - test('should change profile model', async ({ page }) => { - await clickEditProfile(page, 'custom-profile-1'); - - // Change model - await selectModel(page, 'opus'); - - await saveProfile(page); - await waitForSuccessToast(page, 'Profile updated'); - - // Verify model badge was updated - const model = await getProfileModel(page, 'custom-profile-1'); - expect(model.toLowerCase()).toContain('opus'); - }); - - test('should change thinking level', async ({ page }) => { - await clickEditProfile(page, 'custom-profile-1'); - - // Ensure model supports thinking - await selectModel(page, 'sonnet'); - await selectThinkingLevel(page, 'high'); - - await saveProfile(page); - await waitForSuccessToast(page, 'Profile updated'); - - // Verify thinking level badge was updated - const thinkingLevel = await getProfileThinkingLevel(page, 'custom-profile-1'); - expect(thinkingLevel?.toLowerCase()).toContain('high'); - }); - - test('should cancel edit without saving', async ({ page }) => { - // Get original name - const originalName = await getProfileName(page, 'custom-profile-1'); - - await clickEditProfile(page, 'custom-profile-1'); - - // Change name - await fillProfileName(page, 'Should Not Save'); - - // Cancel - await cancelProfileDialog(page); - - // Verify dialog is closed - expect(await isEditProfileDialogOpen(page)).toBe(false); - - // Verify name was NOT changed - const currentName = await getProfileName(page, 'custom-profile-1'); - expect(currentName).toBe(originalName); - }); - }); - - // ============================================================================ - // Profile Deletion Tests - // ============================================================================ - - test.describe('Profile Deletion', () => { - test.beforeEach(async ({ page }) => { - // Start with 2 custom profiles - await setupMockProjectWithProfiles(page, { customProfilesCount: 2 }); - await page.goto('/'); - await waitForNetworkIdle(page); - await navigateToProfiles(page); - }); - - test('should delete profile with confirmation', async ({ page }) => { - // Get initial count - const initialCount = await countCustomProfiles(page); - expect(initialCount).toBe(2); - - // Click delete button - await clickDeleteProfile(page, 'custom-profile-1'); - - // Verify confirmation dialog is open - expect(await isDeleteConfirmDialogOpen(page)).toBe(true); - - // Confirm deletion - await confirmDeleteProfile(page); - - // Verify success toast - await waitForSuccessToast(page, 'Profile deleted'); - - // Verify profile was removed - const finalCount = await countCustomProfiles(page); - expect(finalCount).toBe(1); - }); - - test('should delete via keyboard shortcut (Cmd+Enter)', async ({ page }) => { - await clickDeleteProfile(page, 'custom-profile-1'); - - // Press Cmd/Ctrl+Enter to confirm (platform-aware) - await pressModifierEnter(page); - - // Verify profile was deleted - await waitForSuccessToast(page, 'Profile deleted'); - const finalCount = await countCustomProfiles(page); - expect(finalCount).toBe(1); - }); - - test('should cancel deletion', async ({ page }) => { - const initialCount = await countCustomProfiles(page); - - await clickDeleteProfile(page, 'custom-profile-1'); - - // Cancel deletion - await cancelDeleteProfile(page); - - // Verify dialog is closed - expect(await isDeleteConfirmDialogOpen(page)).toBe(false); - - // Verify profile was NOT deleted - const finalCount = await countCustomProfiles(page); - expect(finalCount).toBe(initialCount); - }); - - test('should not show delete button for built-in profiles', async ({ page }) => { - // Check delete button visibility for built-in profile - const isDeleteVisible = await isDeleteButtonVisible(page, 'profile-heavy-task'); - expect(isDeleteVisible).toBe(false); - }); - - test('should show delete button for custom profiles', async ({ page }) => { - // Check delete button visibility for custom profile - const isDeleteVisible = await isDeleteButtonVisible(page, 'custom-profile-1'); - expect(isDeleteVisible).toBe(true); - }); - }); - - // ============================================================================ - // Profile Reordering Tests - // ============================================================================ - - test.describe('Profile Reordering', () => { - test.beforeEach(async ({ page }) => { - // Start with 3 custom profiles for reordering - await setupMockProjectWithProfiles(page, { customProfilesCount: 3 }); - await page.goto('/'); - await waitForNetworkIdle(page); - await navigateToProfiles(page); - }); - - test('should drag first profile to last position', async ({ page }) => { - // Get initial order - custom profiles come first (0, 1, 2), then built-in (3, 4, 5) - const initialOrder = await getProfileOrder(page); - - // Drag first profile (index 0) to last position (index 5) - await dragProfile(page, 0, 5); - - // Get new order - const newOrder = await getProfileOrder(page); - - // Verify order changed - the first item should now be at a different position - expect(newOrder).not.toEqual(initialOrder); - }); - - test.skip('should drag profile to earlier position', async ({ page }) => { - // Note: Skipped because dnd-kit in grid layout doesn't reliably support - // dragging items backwards. Forward drags work correctly. - const initialOrder = await getProfileOrder(page); - - // Drag from position 3 to position 1 (moving backward) - await dragProfile(page, 3, 1); - - const newOrder = await getProfileOrder(page); - - // Verify order changed - expect(newOrder).not.toEqual(initialOrder); - }); - - test('should drag profile to middle position', async ({ page }) => { - const initialOrder = await getProfileOrder(page); - - // Drag first profile to middle position - await dragProfile(page, 0, 3); - - const newOrder = await getProfileOrder(page); - - // Verify order changed - expect(newOrder).not.toEqual(initialOrder); - }); - - test('should persist order after creating new profile', async ({ page }) => { - // Get initial order - const initialOrder = await getProfileOrder(page); - - // Reorder profiles - move first to position 3 - await dragProfile(page, 0, 3); - const orderAfterDrag = await getProfileOrder(page); - - // Verify drag worked - expect(orderAfterDrag).not.toEqual(initialOrder); - - // Create a new profile - await clickNewProfileButton(page); - await fillProfileForm(page, { - name: 'New Profile', - model: 'haiku', - }); - await saveProfile(page); - await waitForSuccessToast(page, 'Profile created'); - - // Get order after creation - new profile should be added - const orderAfterCreate = await getProfileOrder(page); - - // The new profile should be added (so we have one more profile) - expect(orderAfterCreate.length).toBe(orderAfterDrag.length + 1); - }); - - test('should show drag handle on all profiles', async ({ page }) => { - // Check for drag handles on both built-in and custom profiles - const builtInDragHandle = page.locator( - '[data-testid="profile-drag-handle-profile-heavy-task"]' - ); - const customDragHandle = page.locator('[data-testid="profile-drag-handle-custom-profile-1"]'); - - expect(await builtInDragHandle.isVisible()).toBe(true); - expect(await customDragHandle.isVisible()).toBe(true); - }); - }); - - // ============================================================================ - // Form Validation Tests - // ============================================================================ - - test.describe('Form Validation', () => { - test.beforeEach(async ({ page }) => { - await setupMockProjectWithProfiles(page, { customProfilesCount: 0 }); - await page.goto('/'); - await waitForNetworkIdle(page); - await navigateToProfiles(page); - }); - - test('should reject empty profile name', async ({ page }) => { - await clickNewProfileButton(page); - - // Try to save without entering a name - await clickElement(page, 'save-profile-button'); - - // Should show error toast - await waitForErrorToast(page, 'Please enter a profile name'); - - // Dialog should still be open - expect(await isAddProfileDialogOpen(page)).toBe(true); - }); - - test('should reject whitespace-only name', async ({ page }) => { - await clickNewProfileButton(page); - - // Enter only whitespace - await fillProfileName(page, ' '); - - // Try to save - await clickElement(page, 'save-profile-button'); - - // Should show error toast - await waitForErrorToast(page, 'Please enter a profile name'); - - // Dialog should still be open - expect(await isAddProfileDialogOpen(page)).toBe(true); - }); - - test('should accept valid profile name', async ({ page }) => { - await clickNewProfileButton(page); - - await fillProfileForm(page, { - name: 'Valid Profile Name', - model: 'haiku', - }); - - await saveProfile(page); - - // Should show success toast - await waitForSuccessToast(page, 'Profile created'); - - // Dialog should be closed - expect(await isAddProfileDialogOpen(page)).toBe(false); - }); - - test('should handle very long profile name', async ({ page }) => { - await clickNewProfileButton(page); - - // Create a 200-character name - const longName = 'A'.repeat(200); - await fillProfileName(page, longName); - await fillProfileForm(page, { model: 'haiku' }); - - await saveProfile(page); - - // Should successfully create the profile - await waitForSuccessToast(page, 'Profile created'); - }); - - test('should handle special characters in name and description', async ({ page }) => { - await clickNewProfileButton(page); - - await fillProfileForm(page, { - name: 'Test <>&" Profile', - description: 'Description with special chars: <>&"\'', - model: 'haiku', - }); - - await saveProfile(page); - - // Should successfully create - await waitForSuccessToast(page, 'Profile created'); - - // Verify name is displayed correctly (without HTML injection) - const customCount = await countCustomProfiles(page); - expect(customCount).toBe(1); - }); - - test('should allow empty description', async ({ page }) => { - await clickNewProfileButton(page); - - await fillProfileForm(page, { - name: 'Profile Without Description', - description: '', - model: 'haiku', - }); - - await saveProfile(page); - - // Should successfully create - await waitForSuccessToast(page, 'Profile created'); - }); - - test('should show thinking level controls when model supports it', async ({ page }) => { - await clickNewProfileButton(page); - - // Select a model that supports thinking (all current models do) - await selectModel(page, 'opus'); - - // Verify that the thinking level section is visible - const thinkingLevelLabel = page.locator('text="Thinking Level"'); - await expect(thinkingLevelLabel).toBeVisible(); - - // Verify thinking level options are available - const thinkingSelector = page.locator('[data-testid^="thinking-select-"]'); - await expect(thinkingSelector.first()).toBeVisible(); - }); - }); - - // ============================================================================ - // Keyboard Shortcuts Tests - // ============================================================================ - - test.describe('Keyboard Shortcuts', () => { - test.beforeEach(async ({ page }) => { - await setupMockProjectWithProfiles(page, { customProfilesCount: 1 }); - await page.goto('/'); - await waitForNetworkIdle(page); - await navigateToProfiles(page); - }); - - test('should save new profile with Cmd+Enter', async ({ page }) => { - await clickNewProfileButton(page); - - await fillProfileForm(page, { - name: 'Shortcut Profile', - model: 'haiku', - }); - - // Press Cmd/Ctrl+Enter to save (platform-aware) - await pressModifierEnter(page); - - // Should save and show success toast - await waitForSuccessToast(page, 'Profile created'); - - // Wait for dialog to fully close - await waitForDialogClose(page); - - // Dialog should be closed - expect(await isAddProfileDialogOpen(page)).toBe(false); - }); - - test('should save edit with Cmd+Enter', async ({ page }) => { - await clickEditProfile(page, 'custom-profile-1'); - - await fillProfileName(page, 'Edited via Shortcut'); - - // Press Cmd/Ctrl+Enter to save (platform-aware) - await pressModifierEnter(page); - - // Should save and show success toast - await waitForSuccessToast(page, 'Profile updated'); - }); - - test('should confirm delete with Cmd+Enter', async ({ page }) => { - await clickDeleteProfile(page, 'custom-profile-1'); - - // Press Cmd/Ctrl+Enter to confirm (platform-aware) - await pressModifierEnter(page); - - // Should delete and show success toast - await waitForSuccessToast(page, 'Profile deleted'); - }); - - test('should close dialog with Escape key', async ({ page }) => { - // Test add dialog - await clickNewProfileButton(page); - await page.keyboard.press('Escape'); - await waitForDialogClose(page); - expect(await isAddProfileDialogOpen(page)).toBe(false); - - // Test edit dialog - await clickEditProfile(page, 'custom-profile-1'); - await page.keyboard.press('Escape'); - await waitForDialogClose(page); - expect(await isEditProfileDialogOpen(page)).toBe(false); - - // Test delete dialog - await clickDeleteProfile(page, 'custom-profile-1'); - await page.keyboard.press('Escape'); - await waitForDialogClose(page); - expect(await isDeleteConfirmDialogOpen(page)).toBe(false); - }); - - test('should use correct modifier key for platform', async ({ page }) => { - await clickNewProfileButton(page); - await fillProfileForm(page, { name: 'Test', model: 'haiku' }); - - // Press the platform-specific shortcut (uses utility that handles platform detection) - await pressModifierEnter(page); - - // Should work regardless of platform - await waitForSuccessToast(page, 'Profile created'); - }); - }); - - // ============================================================================ - // Empty States Tests - // ============================================================================ - - test.describe('Empty States', () => { - test.beforeEach(async ({ page }) => { - // Start with no custom profiles - await setupMockProjectWithProfiles(page, { customProfilesCount: 0 }); - await page.goto('/'); - await waitForNetworkIdle(page); - await navigateToProfiles(page); - }); - - test('should show empty state when no custom profiles exist', async ({ page }) => { - // Check for empty state element - const emptyState = page.locator('text="No custom profiles yet. Create one to get started!"'); - expect(await emptyState.isVisible()).toBe(true); - }); - - test('should open add dialog when clicking empty state', async ({ page }) => { - await clickEmptyState(page); - - // Dialog should open - expect(await isAddProfileDialogOpen(page)).toBe(true); - }); - - test('should hide empty state after creating first profile', async ({ page }) => { - // Create a profile - await clickEmptyState(page); - await fillProfileForm(page, { name: 'First Profile', model: 'haiku' }); - await saveProfile(page); - await waitForSuccessToast(page, 'Profile created'); - - // Empty state should no longer be visible - const emptyState = page.locator('text="No custom profiles yet. Create one to get started!"'); - expect(await emptyState.isVisible()).toBe(false); - - // Profile card should be visible - const customCount = await countCustomProfiles(page); - expect(customCount).toBe(1); - }); - }); - - // ============================================================================ - // Built-in vs Custom Profiles Tests - // ============================================================================ - - test.describe('Built-in vs Custom Profiles', () => { - test.beforeEach(async ({ page }) => { - await setupMockProjectWithProfiles(page, { customProfilesCount: 1 }); - await page.goto('/'); - await waitForNetworkIdle(page); - await navigateToProfiles(page); - }); - - test('should show built-in badge on built-in profiles', async ({ page }) => { - // Check Heavy Task profile - const isBuiltIn = await isBuiltInProfile(page, 'profile-heavy-task'); - expect(isBuiltIn).toBe(true); - - // Verify lock icon is present - const card = await getProfileCard(page, 'profile-heavy-task'); - const lockIcon = card.locator('svg[class*="lucide-lock"]'); - expect(await lockIcon.isVisible()).toBe(true); - }); - - test('should not show edit button on built-in profiles', async ({ page }) => { - const isEditVisible = await isEditButtonVisible(page, 'profile-heavy-task'); - expect(isEditVisible).toBe(false); - }); - - test('should not show delete button on built-in profiles', async ({ page }) => { - const isDeleteVisible = await isDeleteButtonVisible(page, 'profile-heavy-task'); - expect(isDeleteVisible).toBe(false); - }); - - test('should show edit and delete buttons on custom profiles', async ({ page }) => { - // Check custom profile - const isEditVisible = await isEditButtonVisible(page, 'custom-profile-1'); - const isDeleteVisible = await isDeleteButtonVisible(page, 'custom-profile-1'); - - expect(isEditVisible).toBe(true); - expect(isDeleteVisible).toBe(true); - }); - }); - - // ============================================================================ - // Header Actions Tests - // ============================================================================ - - test.describe('Header Actions', () => { - test.beforeEach(async ({ page }) => { - await setupMockProjectWithProfiles(page, { customProfilesCount: 2 }); - await page.goto('/'); - await waitForNetworkIdle(page); - await navigateToProfiles(page); - }); - - test('should refresh default profiles', async ({ page }) => { - await clickRefreshDefaults(page); - - // Should show success toast - message is "Profiles refreshed" - await waitForSuccessToast(page, 'Profiles refreshed'); - - // Built-in profiles should still be visible - const builtInCount = await countBuiltInProfiles(page); - expect(builtInCount).toBe(3); - - // Custom profiles should be preserved - const customCount = await countCustomProfiles(page); - expect(customCount).toBe(2); - }); - - test('should display correct profile count badges', async ({ page }) => { - // Check for count badges by counting actual profile cards - const customCount = await countCustomProfiles(page); - const builtInCount = await countBuiltInProfiles(page); - - expect(customCount).toBe(2); - expect(builtInCount).toBe(3); - - // Total profiles should be 5 (2 custom + 3 built-in) - const totalProfiles = customCount + builtInCount; - expect(totalProfiles).toBe(5); - }); - }); - - // ============================================================================ - // Data Persistence Tests - // ============================================================================ - - test.describe('Data Persistence', () => { - test.beforeEach(async ({ page }) => { - await setupMockProjectWithProfiles(page, { customProfilesCount: 0 }); - await page.goto('/'); - await waitForNetworkIdle(page); - await navigateToProfiles(page); - }); - - test('should persist created profile after navigation', async ({ page }) => { - // Create a profile - await clickNewProfileButton(page); - await fillProfileForm(page, { - name: 'Persistent Profile', - model: 'haiku', - }); - await saveProfile(page); - await waitForSuccessToast(page, 'Profile created'); - - // Navigate away (within app, not full page reload) - await page.locator('[data-testid="nav-board"]').click(); - await waitForNetworkIdle(page); - - // Navigate back to profiles - await navigateToProfiles(page); - await waitForNetworkIdle(page); - - // Profile should still exist - const customCount = await countCustomProfiles(page); - expect(customCount).toBe(1); - }); - - test('should show correct count after creating multiple profiles', async ({ page }) => { - // Create multiple profiles - for (let i = 1; i <= 3; i++) { - await clickNewProfileButton(page); - await fillProfileForm(page, { name: `Profile ${i}`, model: 'haiku' }); - await saveProfile(page); - await waitForSuccessToast(page, 'Profile created'); - // Ensure dialog is fully closed before next iteration - await waitForDialogClose(page); - } - - // Verify all profiles exist - const customCount = await countCustomProfiles(page); - expect(customCount).toBe(3); - - // Built-in should still be there - const builtInCount = await countBuiltInProfiles(page); - expect(builtInCount).toBe(3); - }); - - test('should maintain profile order after navigation', async ({ page }) => { - // Create 3 profiles - for (let i = 1; i <= 3; i++) { - await clickNewProfileButton(page); - await fillProfileForm(page, { name: `Profile ${i}`, model: 'haiku' }); - await saveProfile(page); - await waitForSuccessToast(page, 'Profile created'); - // Ensure dialog is fully closed before next iteration - await waitForDialogClose(page); - } - - // Get order after creation - const orderAfterCreate = await getProfileOrder(page); - - // Navigate away (within app) - await page.locator('[data-testid="nav-board"]').click(); - await waitForNetworkIdle(page); - - // Navigate back - await navigateToProfiles(page); - await waitForNetworkIdle(page); - - // Verify order is maintained - const orderAfterNavigation = await getProfileOrder(page); - expect(orderAfterNavigation).toEqual(orderAfterCreate); - }); - }); - - // ============================================================================ - // Toast Notifications Tests - // ============================================================================ - - test.describe('Toast Notifications', () => { - test.beforeEach(async ({ page }) => { - await setupMockProjectWithProfiles(page, { customProfilesCount: 1 }); - await page.goto('/'); - await waitForNetworkIdle(page); - await navigateToProfiles(page); - }); - - test('should show success toast on profile creation', async ({ page }) => { - await clickNewProfileButton(page); - await fillProfileForm(page, { name: 'New Profile', model: 'haiku' }); - await saveProfile(page); - - // Verify toast with profile name - await waitForSuccessToast(page, 'Profile created'); - }); - - test('should show success toast on profile update', async ({ page }) => { - await clickEditProfile(page, 'custom-profile-1'); - await fillProfileName(page, 'Updated'); - await saveProfile(page); - - await waitForSuccessToast(page, 'Profile updated'); - }); - - test('should show success toast on profile deletion', async ({ page }) => { - await clickDeleteProfile(page, 'custom-profile-1'); - await confirmDeleteProfile(page); - - await waitForSuccessToast(page, 'Profile deleted'); - }); - - test('should show error toast on validation failure', async ({ page }) => { - await clickNewProfileButton(page); - - // Try to save without a name - await clickElement(page, 'save-profile-button'); - - // Should show error toast - await waitForErrorToast(page, 'Please enter a profile name'); - }); - }); -}); diff --git a/apps/ui/tests/profiles/profiles-crud.spec.ts b/apps/ui/tests/profiles/profiles-crud.spec.ts new file mode 100644 index 00000000..743cdeb6 --- /dev/null +++ b/apps/ui/tests/profiles/profiles-crud.spec.ts @@ -0,0 +1,43 @@ +/** + * AI Profiles E2E Test + * + * Happy path: Create a new profile + */ + +import { test, expect } from '@playwright/test'; +import { + setupMockProjectWithProfiles, + waitForNetworkIdle, + navigateToProfiles, + clickNewProfileButton, + fillProfileForm, + saveProfile, + waitForSuccessToast, + countCustomProfiles, +} from '../utils'; + +test.describe('AI Profiles', () => { + test('should create a new profile', async ({ page }) => { + await setupMockProjectWithProfiles(page, { customProfilesCount: 0 }); + await page.goto('/'); + await waitForNetworkIdle(page); + await navigateToProfiles(page); + + await clickNewProfileButton(page); + + await fillProfileForm(page, { + name: 'Test Profile', + description: 'A test profile', + icon: 'Brain', + model: 'sonnet', + thinkingLevel: 'medium', + }); + + await saveProfile(page); + + await waitForSuccessToast(page, 'Profile created'); + + const customCount = await countCustomProfiles(page); + expect(customCount).toBe(1); + }); +}); diff --git a/apps/ui/tests/project-creation.spec.ts b/apps/ui/tests/project-creation.spec.ts deleted file mode 100644 index 9d71d4ff..00000000 --- a/apps/ui/tests/project-creation.spec.ts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * Project Creation End-to-End Tests - * - * Tests the project creation flows: - * 1. Creating a new blank project from the welcome view - * 2. Creating a new project from a GitHub template - */ - -import { test, expect } from '@playwright/test'; -import * as fs from 'fs'; -import * as path from 'path'; -import { createTempDirPath, cleanupTempDir, setupWelcomeView } from './utils'; - -// Create unique temp dir for this test run -const TEST_TEMP_DIR = createTempDirPath('project-creation-test'); - -test.describe('Project Creation', () => { - test.beforeAll(async () => { - // Create test temp directory - if (!fs.existsSync(TEST_TEMP_DIR)) { - fs.mkdirSync(TEST_TEMP_DIR, { recursive: true }); - } - }); - - test.afterAll(async () => { - // Cleanup temp directory - cleanupTempDir(TEST_TEMP_DIR); - }); - - test('should create a new blank project from welcome view', async ({ page }) => { - const projectName = `test-project-${Date.now()}`; - - // Set up welcome view with workspace directory pre-configured - await setupWelcomeView(page, { workspaceDir: TEST_TEMP_DIR }); - - // Navigate to the app - await page.goto('/'); - await page.waitForLoadState('networkidle'); - - // Wait for welcome view to be visible - await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 }); - - // Click the "Create New Project" dropdown button - const createButton = page.locator('[data-testid="create-new-project"]'); - await expect(createButton).toBeVisible(); - await createButton.click(); - - // Click "Quick Setup" option from the dropdown - const quickSetupOption = page.locator('[data-testid="quick-setup-option"]'); - await expect(quickSetupOption).toBeVisible(); - await quickSetupOption.click(); - - // Wait for the new project modal to appear - const modal = page.locator('[data-testid="new-project-modal"]'); - await expect(modal).toBeVisible({ timeout: 5000 }); - - // Enter the project name - const projectNameInput = page.locator('[data-testid="project-name-input"]'); - await expect(projectNameInput).toBeVisible(); - await projectNameInput.fill(projectName); - - // Verify the workspace directory is shown (from our pre-configured localStorage) - // Wait for workspace to be loaded (it shows "Will be created at:" when ready) - await expect(page.getByText('Will be created at:')).toBeVisible({ timeout: 5000 }); - - // Click the Create Project button - const createProjectButton = page.locator('[data-testid="confirm-create-project"]'); - await expect(createProjectButton).toBeVisible(); - await createProjectButton.click(); - - // Wait for project creation to complete - // The app may show an init dialog briefly and then navigate to board view - // We just need to verify we end up on the board view with our project - - // Wait for the board view - this confirms the project was created and opened - await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 }); - - // Verify the project name appears in the project selector (sidebar) - await expect( - page.locator('[data-testid="project-selector"]').getByText(projectName) - ).toBeVisible({ timeout: 5000 }); - - // Verify the project was created in the filesystem - const projectPath = path.join(TEST_TEMP_DIR, projectName); - expect(fs.existsSync(projectPath)).toBe(true); - - // Verify .automaker directory was created - const automakerDir = path.join(projectPath, '.automaker'); - expect(fs.existsSync(automakerDir)).toBe(true); - - // Verify app_spec.txt was created - const appSpecPath = path.join(automakerDir, 'app_spec.txt'); - expect(fs.existsSync(appSpecPath)).toBe(true); - - // Verify the app_spec.txt contains the project name - const appSpecContent = fs.readFileSync(appSpecPath, 'utf-8'); - expect(appSpecContent).toContain(projectName); - }); - - test('should create a new project from GitHub template', async ({ page }) => { - // Increase timeout for this test since git clone takes time - test.setTimeout(60000); - - const projectName = `template-project-${Date.now()}`; - - // Set up welcome view with workspace directory pre-configured - await setupWelcomeView(page, { workspaceDir: TEST_TEMP_DIR }); - - // Navigate to the app - await page.goto('/'); - await page.waitForLoadState('networkidle'); - - // Wait for welcome view to be visible - await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 }); - - // Click the "Create New Project" dropdown button - const createButton = page.locator('[data-testid="create-new-project"]'); - await expect(createButton).toBeVisible(); - await createButton.click(); - - // Click "Quick Setup" option from the dropdown - const quickSetupOption = page.locator('[data-testid="quick-setup-option"]'); - await expect(quickSetupOption).toBeVisible(); - await quickSetupOption.click(); - - // Wait for the new project modal to appear - const modal = page.locator('[data-testid="new-project-modal"]'); - await expect(modal).toBeVisible({ timeout: 5000 }); - - // Enter the project name first - const projectNameInput = page.locator('[data-testid="project-name-input"]'); - await expect(projectNameInput).toBeVisible(); - await projectNameInput.fill(projectName); - - // Wait for workspace directory to be loaded - await expect(page.getByText('Will be created at:')).toBeVisible({ timeout: 5000 }); - - // Click on the "Starter Kit" tab - const starterKitTab = modal.getByText('Starter Kit'); - await expect(starterKitTab).toBeVisible(); - await starterKitTab.click(); - - // Select the first template (Automaker Starter Kit) - const firstTemplate = page.locator('[data-testid="template-automaker-starter-kit"]'); - await expect(firstTemplate).toBeVisible(); - await firstTemplate.click(); - - // Verify the template is selected (check mark should appear) - await expect(firstTemplate.locator('.lucide-check')).toBeVisible(); - - // Click the Create Project button - const createProjectButton = page.locator('[data-testid="confirm-create-project"]'); - await expect(createProjectButton).toBeVisible(); - await createProjectButton.click(); - - // Wait for git clone to complete and board view to appear - // This takes longer due to the git clone operation - await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 45000 }); - - // Verify the project name appears in the project selector (sidebar) - await expect( - page.locator('[data-testid="project-selector"]').getByText(projectName) - ).toBeVisible({ timeout: 5000 }); - - // Verify the project was cloned in the filesystem - const projectPath = path.join(TEST_TEMP_DIR, projectName); - expect(fs.existsSync(projectPath)).toBe(true); - - // Verify .automaker directory was created - const automakerDir = path.join(projectPath, '.automaker'); - expect(fs.existsSync(automakerDir)).toBe(true); - - // Verify app_spec.txt was created with template info - const appSpecPath = path.join(automakerDir, 'app_spec.txt'); - expect(fs.existsSync(appSpecPath)).toBe(true); - const appSpecContent = fs.readFileSync(appSpecPath, 'utf-8'); - expect(appSpecContent).toContain(projectName); - expect(appSpecContent).toContain('Automaker Starter Kit'); - - // Verify the template files were cloned (check for package.json which should exist in the template) - const packageJsonPath = path.join(projectPath, 'package.json'); - expect(fs.existsSync(packageJsonPath)).toBe(true); - - // Verify it's a git repository (cloned from GitHub) - const gitDir = path.join(projectPath, '.git'); - expect(fs.existsSync(gitDir)).toBe(true); - }); -}); diff --git a/apps/ui/tests/projects/new-project-creation.spec.ts b/apps/ui/tests/projects/new-project-creation.spec.ts new file mode 100644 index 00000000..d4c5e3d1 --- /dev/null +++ b/apps/ui/tests/projects/new-project-creation.spec.ts @@ -0,0 +1,53 @@ +/** + * Project Creation E2E Test + * + * Happy path: Create a new blank project from welcome view + */ + +import { test, expect } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { createTempDirPath, cleanupTempDir, setupWelcomeView } from '../utils'; + +const TEST_TEMP_DIR = createTempDirPath('project-creation-test'); + +test.describe('Project Creation', () => { + test.beforeAll(async () => { + if (!fs.existsSync(TEST_TEMP_DIR)) { + fs.mkdirSync(TEST_TEMP_DIR, { recursive: true }); + } + }); + + test.afterAll(async () => { + cleanupTempDir(TEST_TEMP_DIR); + }); + + test('should create a new blank project from welcome view', async ({ page }) => { + const projectName = `test-project-${Date.now()}`; + + await setupWelcomeView(page, { workspaceDir: TEST_TEMP_DIR }); + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 }); + + await page.locator('[data-testid="create-new-project"]').click(); + await page.locator('[data-testid="quick-setup-option"]').click(); + + await expect(page.locator('[data-testid="new-project-modal"]')).toBeVisible({ timeout: 5000 }); + + await page.locator('[data-testid="project-name-input"]').fill(projectName); + await expect(page.getByText('Will be created at:')).toBeVisible({ timeout: 5000 }); + + await page.locator('[data-testid="confirm-create-project"]').click(); + + await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 }); + await expect( + page.locator('[data-testid="project-selector"]').getByText(projectName) + ).toBeVisible({ timeout: 5000 }); + + const projectPath = path.join(TEST_TEMP_DIR, projectName); + expect(fs.existsSync(projectPath)).toBe(true); + expect(fs.existsSync(path.join(projectPath, '.automaker'))).toBe(true); + }); +}); diff --git a/apps/ui/tests/open-project.spec.ts b/apps/ui/tests/projects/open-existing-project.spec.ts similarity index 99% rename from apps/ui/tests/open-project.spec.ts rename to apps/ui/tests/projects/open-existing-project.spec.ts index 87885604..17b6c28c 100644 --- a/apps/ui/tests/open-project.spec.ts +++ b/apps/ui/tests/projects/open-existing-project.spec.ts @@ -11,7 +11,7 @@ import { test, expect } from '@playwright/test'; import * as fs from 'fs'; import * as path from 'path'; -import { createTempDirPath, cleanupTempDir, setupWelcomeView } from './utils'; +import { createTempDirPath, cleanupTempDir, setupWelcomeView } from '../utils'; // Create unique temp dir for this test run const TEST_TEMP_DIR = createTempDirPath('open-project-test'); diff --git a/apps/ui/tests/spec-editor-persistence.spec.ts b/apps/ui/tests/spec-editor-persistence.spec.ts deleted file mode 100644 index d4782161..00000000 --- a/apps/ui/tests/spec-editor-persistence.spec.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { - resetFixtureSpec, - setupProjectWithFixture, - getFixturePath, - navigateToSpecEditor, - getEditorContent, - setEditorContent, - clickSaveButton, - getByTestId, - clickElement, - fillInput, - waitForNetworkIdle, - waitForElement, -} from './utils'; - -test.describe('Spec Editor Persistence', () => { - test.beforeEach(async () => { - // Reset the fixture spec file to original content before each test - resetFixtureSpec(); - }); - - test.afterEach(async () => { - // Clean up - reset the spec file after each test - resetFixtureSpec(); - }); - - test('should open project, edit spec, save, and persist changes after refresh', async ({ - page, - }) => { - // Use the resolved fixture path - const fixturePath = getFixturePath(); - - // Step 1: Set up the project in localStorage pointing to our fixture - await setupProjectWithFixture(page, fixturePath); - - // Step 2: Navigate to the app - await page.goto('/'); - await waitForNetworkIdle(page); - - // Step 3: Verify we're on the dashboard with the project loaded - // The sidebar should show the project selector - const sidebar = await getByTestId(page, 'sidebar'); - await sidebar.waitFor({ state: 'visible', timeout: 10000 }); - - // Step 4: Click on the Spec Editor in the sidebar - await navigateToSpecEditor(page); - - // Step 5: Wait for the spec view to load (not empty state) - await waitForElement(page, 'spec-view', { timeout: 10000 }); - - // Step 6: Wait for the spec editor to load - const specEditor = await getByTestId(page, 'spec-editor'); - await specEditor.waitFor({ state: 'visible', timeout: 10000 }); - - // Step 7: Wait for CodeMirror to initialize (it has a .cm-content element) - await specEditor.locator('.cm-content').waitFor({ state: 'visible', timeout: 10000 }); - - // Step 8: Modify the editor content to "hello world" - await setEditorContent(page, 'hello world'); - - // Verify content was set before saving - const contentBeforeSave = await getEditorContent(page); - expect(contentBeforeSave.trim()).toBe('hello world'); - - // Step 9: Click the save button and wait for save to complete - await clickSaveButton(page); - - // Step 10: Refresh the page - await page.reload(); - await waitForNetworkIdle(page); - - // Step 11: Navigate back to the spec editor - // After reload, we need to wait for the app to initialize - await waitForElement(page, 'sidebar', { timeout: 10000 }); - - // Navigate to spec editor again - await navigateToSpecEditor(page); - - // Wait for CodeMirror to be ready - const specEditorAfterReload = await getByTestId(page, 'spec-editor'); - await specEditorAfterReload - .locator('.cm-content') - .waitFor({ state: 'visible', timeout: 10000 }); - - // Wait for CodeMirror content to update with the loaded spec - // The spec might need time to load into the editor after page reload - let contentMatches = false; - let attempts = 0; - const maxAttempts = 30; // Try for up to 30 seconds with 1-second intervals - - while (!contentMatches && attempts < maxAttempts) { - try { - const contentElement = page.locator('[data-testid="spec-editor"] .cm-content'); - const text = await contentElement.textContent(); - if (text && text.trim() === 'hello world') { - contentMatches = true; - break; - } - } catch { - // Element might not be ready yet, continue - } - - if (!contentMatches) { - await page.waitForTimeout(1000); - attempts++; - } - } - - // If we didn't get the right content with our polling, use the fallback - if (!contentMatches) { - await page.waitForFunction( - (expectedContent) => { - const contentElement = document.querySelector('[data-testid="spec-editor"] .cm-content'); - if (!contentElement) return false; - const text = (contentElement.textContent || '').trim(); - return text === expectedContent; - }, - 'hello world', - { timeout: 10000 } - ); - } - - // Step 12: Verify the content was persisted - const persistedContent = await getEditorContent(page); - expect(persistedContent.trim()).toBe('hello world'); - }); - - test('should handle opening project via Open Project button and file browser', async ({ - page, - }) => { - // This test covers the flow of: - // 1. Clicking Open Project button - // 2. Using the file browser to navigate to the fixture directory - // 3. Opening the project - // 4. Editing the spec - - // Set up without a current project to test the open project flow - await page.addInitScript(() => { - const mockState = { - state: { - projects: [], - currentProject: null, - currentView: 'welcome', - theme: 'dark', - sidebarOpen: true, - apiKeys: { anthropic: '', google: '' }, - chatSessions: [], - chatHistoryOpen: false, - maxConcurrency: 3, - }, - version: 0, - }; - localStorage.setItem('automaker-storage', JSON.stringify(mockState)); - - // Mark setup as complete - const setupState = { - state: { - isFirstRun: false, - setupComplete: true, - currentStep: 'complete', - skipClaudeSetup: false, - }, - version: 0, - }; - localStorage.setItem('automaker-setup', JSON.stringify(setupState)); - }); - - // Navigate to the app - await page.goto('/'); - await waitForNetworkIdle(page); - - // Wait for the sidebar to be visible - const sidebar = await getByTestId(page, 'sidebar'); - await sidebar.waitFor({ state: 'visible', timeout: 10000 }); - - // Click the Open Project button - const openProjectButton = await getByTestId(page, 'open-project-button'); - - // Check if the button is visible (it might not be in collapsed sidebar) - const isButtonVisible = await openProjectButton.isVisible().catch(() => false); - - if (isButtonVisible) { - await clickElement(page, 'open-project-button'); - - // The file browser dialog should open - // Note: In web mode, this might use the FileBrowserDialog component - // which makes requests to the backend server at /api/fs/browse - - // Wait a bit to see if a dialog appears - await page.waitForTimeout(1000); - - // Check if a dialog is visible - const dialog = page.locator('[role="dialog"]'); - const dialogVisible = await dialog.isVisible().catch(() => false); - - if (dialogVisible) { - // If file browser dialog is open, we need to navigate to the fixture path - // This depends on the current directory structure - - // For now, let's verify the dialog appeared and close it - // A full test would navigate through directories - console.log('File browser dialog opened successfully'); - - // Press Escape to close the dialog - await page.keyboard.press('Escape'); - } - } - - // For a complete e2e test with file browsing, we'd need to: - // 1. Navigate through the directory tree - // 2. Select the projectA directory - // 3. Click "Select Current Folder" - - // Since this involves actual file system navigation, - // and depends on the backend server being properly configured, - // we'll verify the basic UI elements are present - - expect(sidebar).toBeTruthy(); - }); -}); - -test.describe('Spec Editor - Full Open Project Flow', () => { - test.beforeEach(async () => { - // Reset the fixture spec file to original content before each test - resetFixtureSpec(); - }); - - test.afterEach(async () => { - // Clean up - reset the spec file after each test - resetFixtureSpec(); - }); - - // Skip in CI - file browser navigation is flaky in headless environments - test.skip('should open project via file browser, edit spec, and persist', async ({ page }) => { - // Navigate to app first - await page.goto('/'); - await waitForNetworkIdle(page); - - // Set up localStorage state (without a current project, but mark setup complete) - // Using evaluate instead of addInitScript so it only runs once - // Note: In CI, setup wizard is also skipped via NEXT_PUBLIC_SKIP_SETUP env var - await page.evaluate(() => { - const mockState = { - state: { - projects: [], - currentProject: null, - currentView: 'welcome', - theme: 'dark', - sidebarOpen: true, - apiKeys: { anthropic: '', google: '' }, - chatSessions: [], - chatHistoryOpen: false, - maxConcurrency: 3, - }, - version: 0, - }; - localStorage.setItem('automaker-storage', JSON.stringify(mockState)); - - // Mark setup as complete (fallback for when NEXT_PUBLIC_SKIP_SETUP isn't set) - const setupState = { - state: { - isFirstRun: false, - setupComplete: true, - currentStep: 'complete', - skipClaudeSetup: false, - }, - version: 0, - }; - localStorage.setItem('automaker-setup', JSON.stringify(setupState)); - }); - - // Reload to apply the localStorage state - await page.reload(); - await waitForNetworkIdle(page); - - // Wait for sidebar - await waitForElement(page, 'sidebar', { timeout: 10000 }); - - // Click the Open Project button - const openProjectButton = await getByTestId(page, 'open-project-button'); - await openProjectButton.waitFor({ state: 'visible', timeout: 10000 }); - await clickElement(page, 'open-project-button'); - - // Wait for the file browser dialog to open - const dialogTitle = page.locator('text="Select Project Directory"'); - await dialogTitle.waitFor({ state: 'visible', timeout: 10000 }); - - // Wait for the dialog to fully load (loading to complete) - await page.waitForFunction( - () => !document.body.textContent?.includes('Loading directories...'), - { timeout: 10000 } - ); - - // Use the path input to directly navigate to the fixture directory - const pathInput = await getByTestId(page, 'path-input'); - await pathInput.waitFor({ state: 'visible', timeout: 5000 }); - - // Clear the input and type the full path to the fixture - await fillInput(page, 'path-input', getFixturePath()); - - // Click the Go button to navigate to the path - await clickElement(page, 'go-to-path-button'); - - // Wait for loading to complete - await page.waitForFunction( - () => !document.body.textContent?.includes('Loading directories...'), - { timeout: 10000 } - ); - - // Verify we're in the right directory by checking the path display - const pathDisplay = page.locator('.font-mono.text-sm.truncate'); - await expect(pathDisplay).toContainText('projectA'); - - // Click "Select Current Folder" button - const selectFolderButton = page.locator('button:has-text("Select Current Folder")'); - await selectFolderButton.click(); - - // Wait for dialog to close and project to load - await page.waitForFunction(() => !document.querySelector('[role="dialog"]'), { - timeout: 10000, - }); - await page.waitForTimeout(500); - - // Navigate to spec editor - const specNav = await getByTestId(page, 'nav-spec'); - await specNav.waitFor({ state: 'visible', timeout: 10000 }); - await clickElement(page, 'nav-spec'); - - // Wait for spec view with the editor (not the empty state) - await waitForElement(page, 'spec-view', { timeout: 10000 }); - const specEditorForOpenFlow = await getByTestId(page, 'spec-editor'); - await specEditorForOpenFlow - .locator('.cm-content') - .waitFor({ state: 'visible', timeout: 10000 }); - await page.waitForTimeout(500); - - // Edit the content - await setEditorContent(page, 'hello world'); - - // Click save button - await clickSaveButton(page); - - // Refresh and verify persistence - await page.reload(); - await waitForNetworkIdle(page); - - // Navigate back to spec editor - await specNav.waitFor({ state: 'visible', timeout: 10000 }); - await clickElement(page, 'nav-spec'); - - const specEditorAfterRefresh = await getByTestId(page, 'spec-editor'); - await specEditorAfterRefresh - .locator('.cm-content') - .waitFor({ state: 'visible', timeout: 10000 }); - await page.waitForTimeout(500); - - // Verify the content persisted - const persistedContent = await getEditorContent(page); - expect(persistedContent.trim()).toBe('hello world'); - }); -}); diff --git a/apps/ui/tests/utils/git/worktree.ts b/apps/ui/tests/utils/git/worktree.ts index a71eb580..099123b4 100644 --- a/apps/ui/tests/utils/git/worktree.ts +++ b/apps/ui/tests/utils/git/worktree.ts @@ -353,7 +353,7 @@ export async function setupProjectWithPath(page: Page, projectPath: string): Pro currentStep: 'complete', skipClaudeSetup: false, }, - version: 2, // Must match app-store.ts persist version + version: 0, // setup-store.ts doesn't specify a version, so zustand defaults to 0 }; localStorage.setItem('automaker-setup', JSON.stringify(setupState)); }, projectPath); @@ -404,7 +404,7 @@ export async function setupProjectWithPathNoWorktrees( currentStep: 'complete', skipClaudeSetup: false, }, - version: 2, // Must match app-store.ts persist version + version: 0, // setup-store.ts doesn't specify a version, so zustand defaults to 0 }; localStorage.setItem('automaker-setup', JSON.stringify(setupState)); }, projectPath); @@ -459,7 +459,7 @@ export async function setupProjectWithStaleWorktree( currentStep: 'complete', skipClaudeSetup: false, }, - version: 2, // Must match app-store.ts persist version + version: 0, // setup-store.ts doesn't specify a version, so zustand defaults to 0 }; localStorage.setItem('automaker-setup', JSON.stringify(setupState)); }, projectPath); diff --git a/apps/ui/tests/utils/project/fixtures.ts b/apps/ui/tests/utils/project/fixtures.ts index 64df50d2..e25a31b7 100644 --- a/apps/ui/tests/utils/project/fixtures.ts +++ b/apps/ui/tests/utils/project/fixtures.ts @@ -107,7 +107,7 @@ export async function setupProjectWithFixture( currentStep: 'complete', skipClaudeSetup: false, }, - version: 2, // Must match app-store.ts persist version + version: 0, // setup-store.ts doesn't specify a version, so zustand defaults to 0 }; localStorage.setItem('automaker-setup', JSON.stringify(setupState)); }, projectPath); diff --git a/apps/ui/tests/utils/project/setup.ts b/apps/ui/tests/utils/project/setup.ts index 23f87d8d..dacbbc1f 100644 --- a/apps/ui/tests/utils/project/setup.ts +++ b/apps/ui/tests/utils/project/setup.ts @@ -875,7 +875,7 @@ export async function setupMockProjectWithProfiles( currentStep: 'complete', skipClaudeSetup: false, }, - version: 2, // Must match app-store.ts persist version + version: 0, // setup-store.ts doesn't specify a version, so zustand defaults to 0 }; localStorage.setItem('automaker-setup', JSON.stringify(setupState)); }, options); diff --git a/apps/ui/tests/worktree-integration.spec.ts b/apps/ui/tests/worktree-integration.spec.ts deleted file mode 100644 index 5750e852..00000000 --- a/apps/ui/tests/worktree-integration.spec.ts +++ /dev/null @@ -1,2615 +0,0 @@ -/** - * Worktree Integration Tests - * - * Tests for git worktree functionality including: - * - Creating and deleting worktrees - * - Committing changes - * - Switching branches - * - Branch listing - * - Worktree isolation - * - Feature filtering by worktree - */ - -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 shared utilities -import { - waitForNetworkIdle, - apiCreateWorktree, - apiDeleteWorktree, - apiListWorktrees, - apiCommitWorktree, - apiSwitchBranch, - apiListBranches, - createTestGitRepo, - cleanupTempDir, - createTempDirPath, - getWorktreePath, - listWorktrees, - listBranches, - setupProjectWithPath, - setupProjectWithPathNoWorktrees, - setupProjectWithStaleWorktree, - waitForBoardView, - clickAddFeature, - fillAddFeatureDialog, - confirmAddFeature, -} from './utils'; - -const execAsync = promisify(exec); - -// ============================================================================ -// Test Setup -// ============================================================================ - -// Create unique temp dir for this test run -const TEST_TEMP_DIR = createTempDirPath('worktree-tests'); - -interface TestRepo { - path: string; - cleanup: () => Promise; -} - -// Configure all tests to run serially to prevent interference -test.describe.configure({ mode: 'serial' }); - -// ============================================================================ -// Test Suite: Worktree Integration Tests -// ============================================================================ -test.describe('Worktree Integration Tests', () => { - let testRepo: TestRepo; - - 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); - }); - - // ========================================================================== - // Basic Worktree Operations - // ========================================================================== - - test('should display worktree selector with main branch', async ({ page }) => { - await setupProjectWithPath(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - // Verify the worktree selector is visible - const branchLabel = page.getByText('Branch:'); - await expect(branchLabel).toBeVisible({ timeout: 10000 }); - - // Wait for worktrees to load and main branch button to appear - // Use data-testid for more reliable selection - const mainBranchButton = page.locator('[data-testid="worktree-branch-main"]'); - await expect(mainBranchButton).toBeVisible({ timeout: 15000 }); - }); - - test('should select main branch by default when app loads with stale worktree data', async ({ - page, - }) => { - // Set up project with STALE worktree data in localStorage - // This simulates a user who previously selected a worktree that was later deleted - await setupProjectWithStaleWorktree(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - // Wait for the worktree selector to load - const branchLabel = page.getByText('Branch:'); - await expect(branchLabel).toBeVisible({ timeout: 10000 }); - - // Verify main branch button is displayed - const mainBranchButton = page.getByRole('button', { name: 'main' }).first(); - await expect(mainBranchButton).toBeVisible({ timeout: 10000 }); - - // CRITICAL: Verify the main branch button is SELECTED (has primary variant styling) - // The button should have the "bg-primary" class indicating it's selected - // When the bug exists, this will fail because stale data prevents initialization - await expect(mainBranchButton).toHaveClass(/bg-primary/, { timeout: 5000 }); - }); - - test('should create a worktree via API and verify filesystem', async ({ page }) => { - await setupProjectWithPath(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - const branchName = 'feature/test-worktree'; - const expectedWorktreePath = getWorktreePath(testRepo.path, branchName); - - const { response, data } = await apiCreateWorktree(page, testRepo.path, branchName); - - expect(response.ok()).toBe(true); - expect(data.success).toBe(true); - - // Verify worktree was created on filesystem - expect(fs.existsSync(expectedWorktreePath)).toBe(true); - - // Verify branch was created - const branches = await listBranches(testRepo.path); - expect(branches).toContain(branchName); - - // Verify worktree is listed by git - const worktrees = await listWorktrees(testRepo.path); - expect(worktrees.length).toBe(1); - expect(worktrees[0]).toBe(expectedWorktreePath); - }); - - test('should create two worktrees and list them both', async ({ page }) => { - await setupProjectWithPath(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - // Create first worktree - const { response: response1 } = await apiCreateWorktree( - page, - testRepo.path, - 'feature/worktree-one' - ); - expect(response1.ok()).toBe(true); - - // Create second worktree - const { response: response2 } = await apiCreateWorktree( - page, - testRepo.path, - 'feature/worktree-two' - ); - expect(response2.ok()).toBe(true); - - // Verify both worktrees exist - const worktrees = await listWorktrees(testRepo.path); - expect(worktrees.length).toBe(2); - - // Verify branches were created - const branches = await listBranches(testRepo.path); - expect(branches).toContain('feature/worktree-one'); - expect(branches).toContain('feature/worktree-two'); - }); - - test('should delete a worktree via API and verify cleanup', async ({ page }) => { - await setupProjectWithPath(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - // Create a worktree - const branchName = 'feature/to-delete'; - const worktreePath = getWorktreePath(testRepo.path, branchName); - - await apiCreateWorktree(page, testRepo.path, branchName); - expect(fs.existsSync(worktreePath)).toBe(true); - - // Delete it - const { response } = await apiDeleteWorktree(page, testRepo.path, worktreePath, true); - expect(response.ok()).toBe(true); - - // Verify worktree directory is removed - expect(fs.existsSync(worktreePath)).toBe(false); - - // Verify branch is deleted - const branches = await listBranches(testRepo.path); - expect(branches).not.toContain(branchName); - }); - - test('should delete worktree but keep branch when deleteBranch is false', async ({ page }) => { - await setupProjectWithPath(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - const branchName = 'feature/keep-branch'; - const worktreePath = getWorktreePath(testRepo.path, branchName); - - await apiCreateWorktree(page, testRepo.path, branchName); - expect(fs.existsSync(worktreePath)).toBe(true); - - // Delete worktree but keep branch - const { response } = await apiDeleteWorktree(page, testRepo.path, worktreePath, false); - expect(response.ok()).toBe(true); - - // Verify worktree is gone but branch remains - expect(fs.existsSync(worktreePath)).toBe(false); - const branches = await listBranches(testRepo.path); - expect(branches).toContain(branchName); - }); - - test('should list worktrees via API', async ({ page }) => { - await setupProjectWithPath(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - - // Create some worktrees first - await apiCreateWorktree(page, testRepo.path, 'feature/list-test-1'); - await apiCreateWorktree(page, testRepo.path, 'feature/list-test-2'); - - // List worktrees via API - const { response, data } = await apiListWorktrees(page, testRepo.path, true); - - expect(response.ok()).toBe(true); - expect(data.success).toBe(true); - expect(data.worktrees).toHaveLength(3); // main + 2 worktrees - - // Verify worktree details - const branches = data.worktrees.map((w) => w.branch); - expect(branches).toContain('main'); - expect(branches).toContain('feature/list-test-1'); - expect(branches).toContain('feature/list-test-2'); - }); - - // ========================================================================== - // Commit Operations - // ========================================================================== - - test('should commit changes in a worktree via API', async ({ page }) => { - await setupProjectWithPath(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - const branchName = 'feature/commit-test'; - const worktreePath = getWorktreePath(testRepo.path, branchName); - - const { response: createResponse } = await apiCreateWorktree(page, testRepo.path, branchName); - expect(createResponse.ok()).toBe(true); - - // Create a new file in the worktree - const testFilePath = path.join(worktreePath, 'test-commit.txt'); - fs.writeFileSync(testFilePath, 'This is a test file for commit'); - - // Commit the changes via API - const { response, data } = await apiCommitWorktree( - page, - worktreePath, - 'Add test file for commit integration test' - ); - - expect(response.ok()).toBe(true); - expect(data.success).toBe(true); - expect(data.result?.committed).toBe(true); - expect(data.result?.branch).toBe(branchName); - expect(data.result?.commitHash).toBeDefined(); - expect(data.result?.commitHash?.length).toBe(8); - - // Verify the commit exists in git log - const { stdout: logOutput } = await execAsync('git log --oneline -1', { - cwd: worktreePath, - }); - expect(logOutput).toContain('Add test file for commit integration test'); - }); - - test('should return no changes when committing with no modifications', async ({ page }) => { - await setupProjectWithPath(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - const branchName = 'feature/no-changes-commit'; - const worktreePath = getWorktreePath(testRepo.path, branchName); - - await apiCreateWorktree(page, testRepo.path, branchName); - - // Try to commit without any changes - const { response, data } = await apiCommitWorktree(page, worktreePath, 'Empty commit attempt'); - - expect(response.ok()).toBe(true); - expect(data.success).toBe(true); - expect(data.result?.committed).toBe(false); - expect(data.result?.message).toBe('No changes to commit'); - }); - - test('should handle multiple sequential commits in a worktree', async ({ page }) => { - await setupProjectWithPath(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - const branchName = 'feature/multi-commit'; - const worktreePath = getWorktreePath(testRepo.path, branchName); - - await apiCreateWorktree(page, testRepo.path, branchName); - - // First commit - fs.writeFileSync(path.join(worktreePath, 'file1.txt'), 'First file'); - const { data: data1 } = await apiCommitWorktree(page, worktreePath, 'First commit'); - expect(data1.result?.committed).toBe(true); - - // Second commit - fs.writeFileSync(path.join(worktreePath, 'file2.txt'), 'Second file'); - const { data: data2 } = await apiCommitWorktree(page, worktreePath, 'Second commit'); - expect(data2.result?.committed).toBe(true); - - // Third commit - fs.writeFileSync(path.join(worktreePath, 'file3.txt'), 'Third file'); - const { data: data3 } = await apiCommitWorktree(page, worktreePath, 'Third commit'); - expect(data3.result?.committed).toBe(true); - - // Verify all commits exist in log - const { stdout: logOutput } = await execAsync('git log --oneline -5', { - cwd: worktreePath, - }); - expect(logOutput).toContain('First commit'); - expect(logOutput).toContain('Second commit'); - expect(logOutput).toContain('Third commit'); - }); - - // ========================================================================== - // Branch Switching - // ========================================================================== - - test.skip('should switch branches within a worktree via API', async ({ page }) => { - await setupProjectWithPath(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - // Create a second branch in the main repo for switching - await execAsync('git branch test-switch-target', { cwd: testRepo.path }); - - // Switch to the new branch via API - const { response, data } = await apiSwitchBranch(page, testRepo.path, 'test-switch-target'); - - expect(response.ok()).toBe(true); - expect(data.success).toBe(true); - expect(data.result?.previousBranch).toBe('main'); - expect(data.result?.currentBranch).toBe('test-switch-target'); - expect(data.result?.message).toContain('Switched to branch'); - - // Verify the branch was actually switched - const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { - cwd: testRepo.path, - }); - expect(currentBranch.trim()).toBe('test-switch-target'); - - // Switch back to main - await execAsync('git checkout main', { cwd: testRepo.path }); - }); - - test('should prevent branch switch with uncommitted changes', async ({ page }) => { - await setupProjectWithPath(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - // Create a branch to switch to - await execAsync('git branch test-switch-blocked', { cwd: testRepo.path }); - - // Create uncommitted changes - const testFilePath = path.join(testRepo.path, 'uncommitted-change.txt'); - fs.writeFileSync(testFilePath, 'This file has uncommitted changes'); - await execAsync('git add uncommitted-change.txt', { cwd: testRepo.path }); - - // Try to switch branches (should fail) - const { response, data } = await apiSwitchBranch(page, testRepo.path, 'test-switch-blocked'); - - expect(response.ok()).toBe(false); - expect(data.success).toBe(false); - expect(data.error).toContain('uncommitted changes'); - expect(data.code).toBe('UNCOMMITTED_CHANGES'); - - // Clean up - reset changes - await execAsync('git reset HEAD', { cwd: testRepo.path }); - fs.unlinkSync(testFilePath); - }); - - test('should handle switching to non-existent branch', async ({ page }) => { - await setupProjectWithPath(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - // Try to switch to a branch that doesn't exist - const { response, data } = await apiSwitchBranch(page, testRepo.path, 'non-existent-branch'); - - expect(response.ok()).toBe(false); - expect(data.success).toBe(false); - expect(data.error).toContain('does not exist'); - }); - - test('should handle switching to current branch (no-op)', async ({ page }) => { - await setupProjectWithPath(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - // Try to switch to the current branch - const { response, data } = await apiSwitchBranch(page, testRepo.path, 'main'); - - expect(response.ok()).toBe(true); - expect(data.success).toBe(true); - expect(data.result?.message).toContain('Already on branch'); - }); - - // ========================================================================== - // List Branches - // ========================================================================== - - test('should list all branches via API', async ({ page }) => { - await setupProjectWithPath(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - // Create additional branches - await execAsync('git branch feature/branch-list-1', { cwd: testRepo.path }); - await execAsync('git branch feature/branch-list-2', { cwd: testRepo.path }); - await execAsync('git branch bugfix/test-branch', { cwd: testRepo.path }); - - // List branches via API - const { response, data } = await apiListBranches(page, testRepo.path); - - expect(response.ok()).toBe(true); - expect(data.success).toBe(true); - expect(data.result?.currentBranch).toBe('main'); - expect(data.result?.branches.length).toBeGreaterThanOrEqual(4); - - const branchNames = data.result?.branches.map((b) => b.name) || []; - expect(branchNames).toContain('main'); - expect(branchNames).toContain('feature/branch-list-1'); - expect(branchNames).toContain('feature/branch-list-2'); - expect(branchNames).toContain('bugfix/test-branch'); - - // Verify current branch is marked correctly - const currentBranchInfo = data.result?.branches.find((b) => b.name === 'main'); - expect(currentBranchInfo?.isCurrent).toBe(true); - }); - - // ========================================================================== - // Worktree Isolation - // ========================================================================== - - test('should isolate files between worktrees', async ({ page }) => { - await setupProjectWithPath(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - // Create two worktrees - const branch1 = 'feature/isolation-1'; - const branch2 = 'feature/isolation-2'; - const worktree1Path = getWorktreePath(testRepo.path, branch1); - const worktree2Path = getWorktreePath(testRepo.path, branch2); - - await apiCreateWorktree(page, testRepo.path, branch1); - await apiCreateWorktree(page, testRepo.path, branch2); - - // Create different files in each worktree - const file1Path = path.join(worktree1Path, 'worktree1-only.txt'); - const file2Path = path.join(worktree2Path, 'worktree2-only.txt'); - - fs.writeFileSync(file1Path, 'File only in worktree 1'); - fs.writeFileSync(file2Path, 'File only in worktree 2'); - - // Verify file1 only exists in worktree1 - expect(fs.existsSync(file1Path)).toBe(true); - expect(fs.existsSync(path.join(worktree2Path, 'worktree1-only.txt'))).toBe(false); - - // Verify file2 only exists in worktree2 - expect(fs.existsSync(file2Path)).toBe(true); - expect(fs.existsSync(path.join(worktree1Path, 'worktree2-only.txt'))).toBe(false); - - // Commit in worktree1 - await execAsync('git add worktree1-only.txt', { cwd: worktree1Path }); - await execAsync('git commit -m "Add file in worktree1"', { - cwd: worktree1Path, - }); - - // Commit in worktree2 - await execAsync('git add worktree2-only.txt', { cwd: worktree2Path }); - await execAsync('git commit -m "Add file in worktree2"', { - cwd: worktree2Path, - }); - - // Verify commits are separate - const { stdout: log1 } = await execAsync('git log --oneline -1', { - cwd: worktree1Path, - }); - const { stdout: log2 } = await execAsync('git log --oneline -1', { - cwd: worktree2Path, - }); - - expect(log1).toContain('Add file in worktree1'); - expect(log2).toContain('Add file in worktree2'); - expect(log1).not.toContain('Add file in worktree2'); - expect(log2).not.toContain('Add file in worktree1'); - }); - - test('should detect modified files count in worktree listing', async ({ page }) => { - await setupProjectWithPath(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - const branchName = 'feature/changes-detection'; - const worktreePath = getWorktreePath(testRepo.path, branchName); - - await apiCreateWorktree(page, testRepo.path, branchName); - - // Create multiple modified files - fs.writeFileSync(path.join(worktreePath, 'change1.txt'), 'Change 1'); - fs.writeFileSync(path.join(worktreePath, 'change2.txt'), 'Change 2'); - fs.writeFileSync(path.join(worktreePath, 'change3.txt'), 'Change 3'); - - // List worktrees and check for changes - const { response, data } = await apiListWorktrees(page, testRepo.path, true); - - expect(response.ok()).toBe(true); - expect(data.success).toBe(true); - - // Find the worktree we created - const changedWorktree = data.worktrees.find((w) => w.branch === branchName); - expect(changedWorktree).toBeDefined(); - expect(changedWorktree?.hasChanges).toBe(true); - expect(changedWorktree?.changedFilesCount).toBeGreaterThanOrEqual(3); - }); - - // ========================================================================== - // Existing Branch Handling - // ========================================================================== - - test('should create worktree from existing branch', async ({ page }) => { - await setupProjectWithPath(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - // First, create a branch with some commits (without worktree) - const branchName = 'feature/existing-branch'; - await execAsync(`git branch ${branchName}`, { cwd: testRepo.path }); - await execAsync(`git checkout ${branchName}`, { cwd: testRepo.path }); - fs.writeFileSync(path.join(testRepo.path, 'existing-file.txt'), 'Content from existing branch'); - await execAsync('git add existing-file.txt', { cwd: testRepo.path }); - await execAsync('git commit -m "Commit on existing branch"', { - cwd: testRepo.path, - }); - await execAsync('git checkout main', { cwd: testRepo.path }); - - // Now create a worktree for that existing branch - const expectedWorktreePath = getWorktreePath(testRepo.path, branchName); - - const { response, data } = await apiCreateWorktree(page, testRepo.path, branchName); - - expect(response.ok()).toBe(true); - expect(data.success).toBe(true); - - // Verify the worktree has the file from the existing branch - const existingFilePath = path.join(expectedWorktreePath, 'existing-file.txt'); - expect(fs.existsSync(existingFilePath)).toBe(true); - const content = fs.readFileSync(existingFilePath, 'utf-8'); - expect(content).toBe('Content from existing branch'); - }); - - test('should return existing worktree when creating with same branch name', async ({ page }) => { - await setupProjectWithPath(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - // Create first worktree - const branchName = 'feature/duplicate-test'; - const { response: response1, data: data1 } = await apiCreateWorktree( - page, - testRepo.path, - branchName - ); - expect(response1.ok()).toBe(true); - expect(data1.success).toBe(true); - expect(data1.worktree?.isNew).not.toBe(false); // New branch was created - - // Try to create another worktree with same branch name - // This should succeed and return the existing worktree (not an error) - const { response: response2, data: data2 } = await apiCreateWorktree( - page, - testRepo.path, - branchName - ); - - expect(response2.ok()).toBe(true); - expect(data2.success).toBe(true); - expect(data2.worktree?.isNew).toBe(false); // Not a new creation, returned existing - expect(data2.worktree?.branch).toBe(branchName); - }); - - // ========================================================================== - // Feature Integration - // ========================================================================== - - test('should add a feature to backlog with specific branch', async ({ page }) => { - await setupProjectWithPath(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - // Note: Worktrees are created at execution time (when feature starts), - // not when adding to backlog. We can specify a branch name without - // creating a worktree first. - const branchName = 'feature/test-branch'; - - // Click add feature button - await clickAddFeature(page); - - // Fill in the feature details with a branch name - await fillAddFeatureDialog(page, 'Test feature for worktree', { - branch: branchName, - category: 'Testing', - }); - - // Confirm - await confirmAddFeature(page); - - // Wait for the feature to appear - await page.waitForTimeout(1000); - - // Verify feature was created with correct branch by checking the filesystem - const featuresDir = path.join(testRepo.path, '.automaker', 'features'); - const featureDirs = fs.readdirSync(featuresDir); - expect(featureDirs.length).toBeGreaterThan(0); - - // Find and read the feature file - const featureDir = featureDirs[0]; - const featureFilePath = path.join(featuresDir, featureDir, 'feature.json'); - const featureData = JSON.parse(fs.readFileSync(featureFilePath, 'utf-8')); - - expect(featureData.description).toBe('Test feature for worktree'); - expect(featureData.branchName).toBe(branchName); - expect(featureData.status).toBe('backlog'); - // Verify worktreePath is not set when adding to backlog - // (worktrees are created at execution time, not when adding to backlog) - expect(featureData.worktreePath).toBeUndefined(); - }); - - test('should store branch name when adding feature with new branch (worktree created when adding feature)', async ({ - page, - }) => { - await setupProjectWithPath(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - // Use a branch name that doesn't exist yet - // Note: Worktrees are now created when features are added/edited, not at execution time - const branchName = 'feature/auto-create-worktree'; - - // Verify branch does NOT exist before we create the feature - const branchesBefore = await listBranches(testRepo.path); - expect(branchesBefore).not.toContain(branchName); - - // Click add feature button - await clickAddFeature(page); - - // Fill in the feature details with the new branch - await fillAddFeatureDialog(page, 'Feature that should auto-create worktree', { - branch: branchName, - category: 'Testing', - }); - - // Confirm - await confirmAddFeature(page); - - // Wait for feature to be saved and worktree to be created - await page.waitForTimeout(2000); - - // Verify branch WAS created when adding feature (worktrees are created when features are added/edited) - const branchesAfter = await listBranches(testRepo.path); - expect(branchesAfter).toContain(branchName); - - // Verify worktree was created - const worktreePath = getWorktreePath(testRepo.path, branchName); - expect(fs.existsSync(worktreePath)).toBe(true); - - // Verify feature was created with correct branch name stored - const featuresDir = path.join(testRepo.path, '.automaker', 'features'); - const featureDirs = fs.readdirSync(featuresDir); - expect(featureDirs.length).toBeGreaterThan(0); - - const featureDir = featureDirs.find((dir) => { - const featureFilePath = path.join(featuresDir, dir, 'feature.json'); - if (fs.existsSync(featureFilePath)) { - const data = JSON.parse(fs.readFileSync(featureFilePath, 'utf-8')); - return data.description === 'Feature that should auto-create worktree'; - } - return false; - }); - expect(featureDir).toBeDefined(); - - const featureFilePath = path.join(featuresDir, featureDir!, 'feature.json'); - const featureData = JSON.parse(fs.readFileSync(featureFilePath, 'utf-8')); - - // Verify branch name is stored - expect(featureData.branchName).toBe(branchName); - - // Verify worktreePath is NOT set (worktrees are created at execution time) - expect(featureData.worktreePath).toBeUndefined(); - - // Verify feature is in backlog status - expect(featureData.status).toBe('backlog'); - }); - - test('should auto-select worktree after creating feature with new branch', async ({ page }) => { - await setupProjectWithPath(page, testRepo.path); - await page.goto('/'); - await waitForNetworkIdle(page); - await waitForBoardView(page); - - // Use a branch name that doesn't exist yet - const branchName = 'feature/auto-select-worktree'; - - // Verify branch does NOT exist before we create the feature - const branchesBefore = await listBranches(testRepo.path); - expect(branchesBefore).not.toContain(branchName); - - // Click add feature button - await clickAddFeature(page); - - // Fill in the feature details with the new branch - await fillAddFeatureDialog(page, 'Feature with auto-select worktree', { - branch: branchName, - category: 'Testing', - }); - - // Confirm - await confirmAddFeature(page); - - // Wait for feature to be saved and worktree to be created - // Also wait for the worktree to appear in the UI and be auto-selected - await page.waitForTimeout(2000); - - // Wait for the worktree button to appear in the UI - // Worktree buttons are actual