mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Merge branch 'main' of github.com:AutoMaker-Org/automaker into claude/task-dependency-graph-iPz1k
This commit is contained in:
111
.github/workflows/release.yml
vendored
Normal file
111
.github/workflows/release.yml
vendored
Normal file
@@ -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 }}
|
||||
46
apps/ui/scripts/update-version.mjs
Executable file
46
apps/ui/scripts/update-version.mjs
Executable file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Updates the version in apps/ui/package.json
|
||||
* Usage: node scripts/update-version.mjs <version>
|
||||
* 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 <version>');
|
||||
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);
|
||||
}
|
||||
89
apps/ui/tests/agent/start-new-chat-session.spec.ts
Normal file
89
apps/ui/tests/agent/start-new-chat-session.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
146
apps/ui/tests/context/add-context-image.spec.ts
Normal file
146
apps/ui/tests/context/add-context-image.spec.ts
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
67
apps/ui/tests/context/context-file-management.spec.ts
Normal file
67
apps/ui/tests/context/context-file-management.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
75
apps/ui/tests/context/delete-context-file.spec.ts
Normal file
75
apps/ui/tests/context/delete-context-file.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
// 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!');
|
||||
});
|
||||
});
|
||||
85
apps/ui/tests/features/add-feature-to-backlog.spec.ts
Normal file
85
apps/ui/tests/features/add-feature-to-backlog.spec.ts
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
139
apps/ui/tests/features/edit-feature.spec.ts
Normal file
139
apps/ui/tests/features/edit-feature.spec.ts
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
119
apps/ui/tests/features/feature-manual-review-flow.spec.ts
Normal file
119
apps/ui/tests/features/feature-manual-review-flow.spec.ts
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
107
apps/ui/tests/features/feature-skip-tests-toggle.spec.ts
Normal file
107
apps/ui/tests/features/feature-skip-tests-toggle.spec.ts
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
60
apps/ui/tests/git/worktree-integration.spec.ts
Normal file
60
apps/ui/tests/git/worktree-integration.spec.ts
Normal file
@@ -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<void>;
|
||||
}
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
43
apps/ui/tests/profiles/profiles-crud.spec.ts
Normal file
43
apps/ui/tests/profiles/profiles-crud.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
53
apps/ui/tests/projects/new-project-creation.spec.ts
Normal file
53
apps/ui/tests/projects/new-project-creation.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
2
package-lock.json
generated
2
package-lock.json
generated
@@ -1212,7 +1212,7 @@
|
||||
},
|
||||
"node_modules/@electron/node-gyp": {
|
||||
"version": "10.2.0-electron.1",
|
||||
"resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2",
|
||||
"resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2",
|
||||
"integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
|
||||
Reference in New Issue
Block a user