Merge main into kanban-scaling

Resolves merge conflicts while preserving:
- Kanban scaling improvements (window sizing, bounce prevention, debouncing)
- Main's sidebar refactoring into hooks
- Main's openInEditor functionality for VS Code integration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
trueheads
2025-12-22 01:49:45 -06:00
599 changed files with 26666 additions and 24168 deletions

View File

@@ -1,6 +1,6 @@
import { test, expect } from "@playwright/test";
import * as fs from "fs";
import * as path from "path";
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import {
resetContextDirectory,
createContextFileOnDisk,
@@ -19,19 +19,18 @@ import {
fillInput,
getByTestId,
waitForNetworkIdle,
} from "./utils";
} from './utils';
const WORKSPACE_ROOT = path.resolve(process.cwd(), "../..");
const TEST_IMAGE_SRC = path.join(WORKSPACE_ROOT, "apps/ui/public/logo.png");
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.describe.configure({ mode: 'serial' });
// ============================================================================
// Test Suite 1: Context View - File Management
// ============================================================================
test.describe("Context View - File Management", () => {
test.describe('Context View - File Management', () => {
test.beforeEach(async () => {
resetContextDirectory();
});
@@ -40,31 +39,31 @@ test.describe("Context View - File Management", () => {
resetContextDirectory();
});
test("should create a new MD context file", async ({ page }) => {
test('should create a new MD context file', async ({ page }) => {
await setupProjectWithFixture(page, getFixturePath());
await page.goto("/");
await page.goto('/');
await waitForNetworkIdle(page);
await navigateToContext(page);
// Click Add File button
await clickElement(page, "add-context-file");
await clickElement(page, 'add-context-file');
await page.waitForSelector('[data-testid="add-context-dialog"]', {
timeout: 5000,
});
// Select text type (should be default)
await clickElement(page, "add-text-type");
await clickElement(page, 'add-text-type');
// Enter filename
await fillInput(page, "new-file-name", "test-context.md");
await fillInput(page, 'new-file-name', 'test-context.md');
// Enter content
const testContent = "# Test Context\n\nThis is test content";
await fillInput(page, "new-file-content", testContent);
const testContent = '# Test Context\n\nThis is test content';
await fillInput(page, 'new-file-content', testContent);
// Click confirm
await clickElement(page, "confirm-add-file");
await clickElement(page, 'confirm-add-file');
// Wait for dialog to close
await page.waitForFunction(
@@ -73,14 +72,14 @@ test.describe("Context View - File Management", () => {
);
// Wait for file list to refresh (file should appear)
await waitForContextFile(page, "test-context.md", 10000);
await waitForContextFile(page, 'test-context.md', 10000);
// Verify file appears in list
const fileButton = await getByTestId(page, "context-file-test-context.md");
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");
await selectContextFile(page, 'test-context.md');
// Wait for content to load
await waitForFileContentToLoad(page);
@@ -98,19 +97,19 @@ test.describe("Context View - File Management", () => {
expect(editorContent).toBe(testContent);
});
test("should edit an existing MD context file", async ({ page }) => {
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);
const originalContent = '# Original Content\n\nThis will be edited.';
createContextFileOnDisk('edit-test.md', originalContent);
await setupProjectWithFixture(page, getFixturePath());
await page.goto("/");
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");
await selectContextFile(page, 'edit-test.md');
// Wait for file content to load
await waitForFileContentToLoad(page);
@@ -124,18 +123,16 @@ test.describe("Context View - File Management", () => {
});
// Modify content
const newContent = "# Modified Content\n\nThis has been edited.";
const newContent = '# Modified Content\n\nThis has been edited.';
await setContextEditorContent(page, newContent);
// Click save
await clickElement(page, "save-context-file");
await clickElement(page, 'save-context-file');
// Wait for save to complete
await page.waitForFunction(
() =>
document
.querySelector('[data-testid="save-context-file"]')
?.textContent?.includes("Saved"),
document.querySelector('[data-testid="save-context-file"]')?.textContent?.includes('Saved'),
{ timeout: 5000 }
);
@@ -147,7 +144,7 @@ test.describe("Context View - File Management", () => {
await navigateToContext(page);
// Wait for file to appear after reload and select it
await selectContextFile(page, "edit-test.md");
await selectContextFile(page, 'edit-test.md');
// Wait for content to load
await waitForFileContentToLoad(page);
@@ -164,23 +161,23 @@ test.describe("Context View - File Management", () => {
expect(persistedContent).toBe(newContent);
});
test("should remove an MD context file", async ({ page }) => {
test('should remove an MD context file', async ({ page }) => {
// Create a test file on disk first
createContextFileOnDisk("delete-test.md", "# Delete Me");
createContextFileOnDisk('delete-test.md', '# Delete Me');
await setupProjectWithFixture(page, getFixturePath());
await page.goto("/");
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 });
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");
await clickElement(page, 'delete-context-file');
// Wait for delete dialog
await page.waitForSelector('[data-testid="delete-context-dialog"]', {
@@ -188,7 +185,7 @@ test.describe("Context View - File Management", () => {
});
// Confirm deletion
await clickElement(page, "confirm-delete-file");
await clickElement(page, 'confirm-delete-file');
// Wait for dialog to close
await page.waitForFunction(
@@ -197,44 +194,41 @@ test.describe("Context View - File Management", () => {
);
// Verify file is removed from list
const deletedFile = await getByTestId(page, "context-file-delete-test.md");
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);
expect(contextFileExistsOnDisk('delete-test.md')).toBe(false);
});
test("should upload an image context file", async ({ page }) => {
test('should upload an image context file', async ({ page }) => {
await setupProjectWithFixture(page, getFixturePath());
await page.goto("/");
await page.goto('/');
await waitForNetworkIdle(page);
await navigateToContext(page);
// Click Add File button
await clickElement(page, "add-context-file");
await clickElement(page, 'add-context-file');
await page.waitForSelector('[data-testid="add-context-dialog"]', {
timeout: 5000,
});
// Select image type
await clickElement(page, "add-image-type");
await clickElement(page, 'add-image-type');
// Enter filename
await fillInput(page, "new-file-name", "test-image.png");
await fillInput(page, 'new-file-name', 'test-image.png');
// Upload image using file input
await page.setInputFiles(
'[data-testid="image-upload-input"]',
TEST_IMAGE_SRC
);
await page.setInputFiles('[data-testid="image-upload-input"]', TEST_IMAGE_SRC);
// Wait for image preview to appear (indicates upload success)
const addDialog = await getByTestId(page, "add-context-dialog");
await addDialog.locator("img").waitFor({ state: "visible" });
const addDialog = await getByTestId(page, 'add-context-dialog');
await addDialog.locator('img').waitFor({ state: 'visible' });
// Click confirm
await clickElement(page, "confirm-add-file");
await clickElement(page, 'confirm-add-file');
// Wait for dialog to close
await page.waitForFunction(
@@ -243,7 +237,7 @@ test.describe("Context View - File Management", () => {
);
// Verify file appears in list
const fileButton = await getByTestId(page, "context-file-test-image.png");
const fileButton = await getByTestId(page, 'context-file-test-image.png');
await expect(fileButton).toBeVisible();
// Click on the image to view it
@@ -253,31 +247,31 @@ test.describe("Context View - File Management", () => {
await page.waitForSelector('[data-testid="image-preview"]', {
timeout: 5000,
});
const imagePreview = await getByTestId(page, "image-preview");
const imagePreview = await getByTestId(page, 'image-preview');
await expect(imagePreview).toBeVisible();
});
test("should remove an image context file", async ({ page }) => {
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);
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 page.goto('/');
await waitForNetworkIdle(page);
await navigateToContext(page);
// Wait for the image file and select it
await selectContextFile(page, "delete-image.png");
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");
await clickElement(page, 'delete-context-file');
// Wait for delete dialog
await page.waitForSelector('[data-testid="delete-context-dialog"]', {
@@ -285,7 +279,7 @@ test.describe("Context View - File Management", () => {
});
// Confirm deletion
await clickElement(page, "confirm-delete-file");
await clickElement(page, 'confirm-delete-file');
// Wait for dialog to close
await page.waitForFunction(
@@ -294,37 +288,37 @@ test.describe("Context View - File Management", () => {
);
// Verify file is removed from list
const deletedImageFile = await getByTestId(page, "context-file-delete-image.png");
const deletedImageFile = await getByTestId(page, 'context-file-delete-image.png');
await expect(deletedImageFile).not.toBeVisible();
});
test("should toggle markdown preview mode", async ({ page }) => {
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);
'# 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 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 });
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");
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");
const markdownPreview = await getByTestId(page, 'markdown-preview');
await expect(markdownPreview).toBeVisible();
// Click to switch to edit mode
@@ -334,7 +328,7 @@ test.describe("Context View - File Management", () => {
});
// Verify editor is shown
const editor = await getByTestId(page, "context-editor");
const editor = await getByTestId(page, 'context-editor');
await expect(editor).toBeVisible();
await expect(markdownPreview).not.toBeVisible();
@@ -352,7 +346,7 @@ test.describe("Context View - File Management", () => {
// ============================================================================
// Test Suite 2: Context View - Drag and Drop
// ============================================================================
test.describe("Context View - Drag and Drop", () => {
test.describe('Context View - Drag and Drop', () => {
test.beforeEach(async () => {
resetContextDirectory();
});
@@ -361,36 +355,34 @@ test.describe("Context View - Drag and Drop", () => {
resetContextDirectory();
});
test("should handle drag and drop of MD file onto textarea in add dialog", async ({
page,
}) => {
test('should handle drag and drop of MD file onto textarea in add dialog', async ({ page }) => {
await setupProjectWithFixture(page, getFixturePath());
await page.goto("/");
await page.goto('/');
await waitForNetworkIdle(page);
await navigateToContext(page);
// Open add file dialog
await clickElement(page, "add-context-file");
await clickElement(page, 'add-context-file');
await page.waitForSelector('[data-testid="add-context-dialog"]', {
timeout: 5000,
});
// Ensure text type is selected
await clickElement(page, "add-text-type");
await clickElement(page, 'add-text-type');
// Simulate drag and drop of a .md file onto the textarea
const droppedContent = "# Dropped Content\n\nThis was dragged and dropped.";
const droppedContent = '# Dropped Content\n\nThis was dragged and dropped.';
await simulateFileDrop(
page,
'[data-testid="new-file-content"]',
"dropped-file.md",
'dropped-file.md',
droppedContent
);
// Wait for content to be populated in textarea
const textarea = await getByTestId(page, "new-file-content");
await textarea.waitFor({ state: "visible" });
const textarea = await getByTestId(page, 'new-file-content');
await textarea.waitFor({ state: 'visible' });
await expect(textarea).toHaveValue(droppedContent);
// Verify content is populated in textarea
@@ -398,13 +390,11 @@ test.describe("Context View - Drag and Drop", () => {
expect(textareaContent).toBe(droppedContent);
// Verify filename is auto-filled
const filenameValue = await page
.locator('[data-testid="new-file-name"]')
.inputValue();
expect(filenameValue).toBe("dropped-file.md");
const filenameValue = await page.locator('[data-testid="new-file-name"]').inputValue();
expect(filenameValue).toBe('dropped-file.md');
// Confirm and create the file
await clickElement(page, "confirm-add-file");
await clickElement(page, 'confirm-add-file');
// Wait for dialog to close
await page.waitForFunction(
@@ -413,15 +403,13 @@ test.describe("Context View - Drag and Drop", () => {
);
// Verify file was created
const droppedFile = await getByTestId(page, "context-file-dropped-file.md");
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,
}) => {
test('should handle drag and drop of file onto main view', async ({ page }) => {
await setupProjectWithFixture(page, getFixturePath());
await page.goto("/");
await page.goto('/');
await waitForNetworkIdle(page);
await navigateToContext(page);
@@ -432,19 +420,19 @@ test.describe("Context View - Drag and Drop", () => {
});
// Simulate drag and drop onto the drop zone
const droppedContent = "This is a text file dropped onto the main view.";
const droppedContent = 'This is a text file dropped onto the main view.';
await simulateFileDrop(
page,
'[data-testid="context-drop-zone"]',
"main-drop.txt",
'main-drop.txt',
droppedContent
);
// Wait for file to appear in the list (drag-drop triggers file creation)
await waitForContextFile(page, "main-drop.txt", 15000);
await waitForContextFile(page, 'main-drop.txt', 15000);
// Verify file appears in the file list
const fileButton = await getByTestId(page, "context-file-main-drop.txt");
const fileButton = await getByTestId(page, 'context-file-main-drop.txt');
await expect(fileButton).toBeVisible();
// Select file and verify content
@@ -461,7 +449,7 @@ test.describe("Context View - Drag and Drop", () => {
// ============================================================================
// Test Suite 3: Context View - Edge Cases
// ============================================================================
test.describe("Context View - Edge Cases", () => {
test.describe('Context View - Edge Cases', () => {
test.beforeEach(async () => {
resetContextDirectory();
});
@@ -470,33 +458,31 @@ test.describe("Context View - Edge Cases", () => {
resetContextDirectory();
});
test("should handle duplicate filename (overwrite behavior)", async ({
page,
}) => {
test('should handle duplicate filename (overwrite behavior)', async ({ page }) => {
// Create an existing file
createContextFileOnDisk("test.md", "# Original Content");
createContextFileOnDisk('test.md', '# Original Content');
await setupProjectWithFixture(page, getFixturePath());
await page.goto("/");
await page.goto('/');
await waitForNetworkIdle(page);
await navigateToContext(page);
// Verify the original file exists
const originalFile = await getByTestId(page, "context-file-test.md");
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, "add-context-file");
await clickElement(page, 'add-context-file');
await page.waitForSelector('[data-testid="add-context-dialog"]', {
timeout: 5000,
});
await clickElement(page, "add-text-type");
await fillInput(page, "new-file-name", "test.md");
await fillInput(page, "new-file-content", "# New Content - Overwritten");
await clickElement(page, 'add-text-type');
await fillInput(page, 'new-file-name', 'test.md');
await fillInput(page, 'new-file-content', '# New Content - Overwritten');
await clickElement(page, "confirm-add-file");
await clickElement(page, 'confirm-add-file');
// Wait for dialog to close
await page.waitForFunction(
@@ -521,54 +507,54 @@ test.describe("Context View - Edge Cases", () => {
});
const editorContent = await getContextEditorContent(page);
expect(editorContent).toBe("# New Content - Overwritten");
expect(editorContent).toBe('# New Content - Overwritten');
});
test("should handle special characters in filename", async ({ page }) => {
test('should handle special characters in filename', async ({ page }) => {
await setupProjectWithFixture(page, getFixturePath());
await page.goto("/");
await page.goto('/');
await waitForNetworkIdle(page);
await navigateToContext(page);
// Test file with parentheses
await clickElement(page, "add-context-file");
await clickElement(page, 'add-context-file');
await page.waitForSelector('[data-testid="add-context-dialog"]', {
timeout: 5000,
});
await clickElement(page, "add-text-type");
await fillInput(page, "new-file-name", "context (1).md");
await fillInput(page, "new-file-content", "Content with parentheses in filename");
await clickElement(page, 'add-text-type');
await fillInput(page, 'new-file-name', 'context (1).md');
await fillInput(page, 'new-file-content', 'Content with parentheses in filename');
await clickElement(page, "confirm-add-file");
await clickElement(page, 'confirm-add-file');
await page.waitForFunction(
() => !document.querySelector('[data-testid="add-context-dialog"]'),
{ timeout: 5000 }
);
// Verify file is created - use CSS escape for special characters
const fileWithParens = await getByTestId(page, "context-file-context (1).md");
const fileWithParens = await getByTestId(page, 'context-file-context (1).md');
await expect(fileWithParens).toBeVisible();
// Test file with hyphens and underscores
await clickElement(page, "add-context-file");
await clickElement(page, 'add-context-file');
await page.waitForSelector('[data-testid="add-context-dialog"]', {
timeout: 5000,
});
await clickElement(page, "add-text-type");
await fillInput(page, "new-file-name", "test-file_v2.md");
await fillInput(page, "new-file-content", "Content with hyphens and underscores");
await clickElement(page, 'add-text-type');
await fillInput(page, 'new-file-name', 'test-file_v2.md');
await fillInput(page, 'new-file-content', 'Content with hyphens and underscores');
await clickElement(page, "confirm-add-file");
await clickElement(page, 'confirm-add-file');
await page.waitForFunction(
() => !document.querySelector('[data-testid="add-context-dialog"]'),
{ timeout: 5000 }
);
// Verify file is created
const fileWithHyphens = await getByTestId(page, "context-file-test-file_v2.md");
const fileWithHyphens = await getByTestId(page, 'context-file-test-file_v2.md');
await expect(fileWithHyphens).toBeVisible();
// Verify both files are accessible
@@ -585,34 +571,34 @@ test.describe("Context View - Edge Cases", () => {
});
const content = await getContextEditorContent(page);
expect(content).toBe("Content with hyphens and underscores");
expect(content).toBe('Content with hyphens and underscores');
});
test("should handle empty content", async ({ page }) => {
test('should handle empty content', async ({ page }) => {
await setupProjectWithFixture(page, getFixturePath());
await page.goto("/");
await page.goto('/');
await waitForNetworkIdle(page);
await navigateToContext(page);
// Create file with empty content
await clickElement(page, "add-context-file");
await clickElement(page, 'add-context-file');
await page.waitForSelector('[data-testid="add-context-dialog"]', {
timeout: 5000,
});
await clickElement(page, "add-text-type");
await fillInput(page, "new-file-name", "empty-file.md");
await clickElement(page, 'add-text-type');
await fillInput(page, 'new-file-name', 'empty-file.md');
// Don't fill any content - leave it empty
await clickElement(page, "confirm-add-file");
await clickElement(page, 'confirm-add-file');
await page.waitForFunction(
() => !document.querySelector('[data-testid="add-context-dialog"]'),
{ timeout: 5000 }
);
// Verify file is created
const emptyFile = await getByTestId(page, "context-file-empty-file.md");
const emptyFile = await getByTestId(page, 'context-file-empty-file.md');
await expect(emptyFile).toBeVisible();
// Select file and verify editor shows empty content
@@ -629,38 +615,36 @@ test.describe("Context View - Edge Cases", () => {
});
const editorContent = await getContextEditorContent(page);
expect(editorContent).toBe("");
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, "");
await setContextEditorContent(page, 'temporary');
await setContextEditorContent(page, '');
// Save should work
await clickElement(page, "save-context-file");
await clickElement(page, 'save-context-file');
await page.waitForFunction(
() =>
document
.querySelector('[data-testid="save-context-file"]')
?.textContent?.includes("Saved"),
document.querySelector('[data-testid="save-context-file"]')?.textContent?.includes('Saved'),
{ timeout: 5000 }
);
});
test("should verify persistence across page refresh", async ({ page }) => {
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);
const testContent = '# Persistence Test\n\nThis content should persist.';
createContextFileOnDisk('persist-test.md', testContent);
await setupProjectWithFixture(page, getFixturePath());
await page.goto("/");
await page.goto('/');
await waitForNetworkIdle(page);
await navigateToContext(page);
// Verify file exists before refresh
await waitForContextFile(page, "persist-test.md", 10000);
await waitForContextFile(page, 'persist-test.md', 10000);
// Refresh the page
await page.reload();
@@ -670,7 +654,7 @@ test.describe("Context View - Edge Cases", () => {
await navigateToContext(page);
// Select the file after refresh (uses robust clicking mechanism)
await selectContextFile(page, "persist-test.md");
await selectContextFile(page, 'persist-test.md');
// Wait for file content to load
await waitForFileContentToLoad(page);

View File

@@ -15,11 +15,11 @@
* 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 { 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,
@@ -29,15 +29,14 @@ import {
setupProjectWithPathNoWorktrees,
waitForBoardView,
clickAddFeature,
fillAddFeatureDialog,
confirmAddFeature,
dragAndDropWithDndKit,
} from "./utils";
} from './utils';
const execAsync = promisify(exec);
// Create unique temp dir for this test run
const TEST_TEMP_DIR = createTempDirPath("feature-lifecycle-tests");
const TEST_TEMP_DIR = createTempDirPath('feature-lifecycle-tests');
interface TestRepo {
path: string;
@@ -45,9 +44,9 @@ interface TestRepo {
}
// Configure all tests to run serially
test.describe.configure({ mode: "serial" });
test.describe.configure({ mode: 'serial' });
test.describe("Feature Lifecycle Tests", () => {
test.describe('Feature Lifecycle Tests', () => {
let testRepo: TestRepo;
let featureId: string;
@@ -76,7 +75,7 @@ test.describe("Feature Lifecycle Tests", () => {
});
// 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 ({
test.skip('complete feature lifecycle: create -> in_progress -> waiting_approval -> commit -> verified -> archive -> restore -> delete', async ({
page,
}) => {
// Increase timeout for this comprehensive test
@@ -87,7 +86,7 @@ test.describe("Feature Lifecycle Tests", () => {
// ==========================================================================
// Use no-worktrees setup to avoid worktree-related filtering/initialization issues
await setupProjectWithPathNoWorktrees(page, testRepo.path);
await page.goto("/");
await page.goto('/');
await waitForNetworkIdle(page);
await waitForBoardView(page);
@@ -98,18 +97,15 @@ test.describe("Feature Lifecycle Tests", () => {
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();
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");
const featuresDir = path.join(testRepo.path, '.automaker', 'features');
// Wait for the feature to be created in the filesystem
await expect(async () => {
@@ -131,18 +127,14 @@ test.describe("Feature Lifecycle Tests", () => {
featureId = featureDirs[0];
// Now get the actual card element by testid
const featureCardByTestId = page.locator(
`[data-testid="kanban-card-${featureId}"]`
);
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"]'
);
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);
@@ -151,13 +143,10 @@ test.describe("Feature Lifecycle Tests", () => {
// 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"
)
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);
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)
@@ -165,12 +154,9 @@ test.describe("Feature Lifecycle Tests", () => {
// 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"
)
fs.readFileSync(path.join(featuresDir, featureId, 'feature.json'), 'utf-8')
);
expect(featureData.status).toBe("waiting_approval");
expect(featureData.status).toBe('waiting_approval');
}).toPass({ timeout: 30000 });
// Refresh page to ensure UI reflects the status change
@@ -181,19 +167,17 @@ test.describe("Feature Lifecycle Tests", () => {
// ==========================================================================
// Step 3: Verify feature is in waiting_approval (manual review) column
// ==========================================================================
const waitingApprovalColumn = page.locator(
'[data-testid="kanban-column-waiting_approval"]'
);
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");
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");
const yellowContent = fs.readFileSync(yellowFilePath, 'utf-8');
expect(yellowContent).toBe('yellow');
// ==========================================================================
// Step 4: Click commit and verify git status shows committed changes
@@ -207,18 +191,18 @@ test.describe("Feature Lifecycle Tests", () => {
await page.waitForTimeout(2000);
// Verify git status shows clean (changes committed)
const { stdout: gitStatus } = await execAsync("git status --porcelain", {
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);
expect(gitStatus.includes('yellow.txt')).toBe(false);
// Verify the commit exists in git log
const { stdout: gitLog } = await execAsync("git log --oneline -1", {
const { stdout: gitLog } = await execAsync('git log --oneline -1', {
cwd: testRepo.path,
});
expect(gitLog.toLowerCase()).toContain("yellow");
expect(gitLog.toLowerCase()).toContain('yellow');
// ==========================================================================
// Step 5: Verify feature moved to verified column after commit
@@ -228,21 +212,15 @@ test.describe("Feature Lifecycle Tests", () => {
await waitForNetworkIdle(page);
await waitForBoardView(page);
const verifiedColumn = page.locator(
'[data-testid="kanban-column-verified"]'
);
const cardInVerified = verifiedColumn.locator(
`[data-testid="kanban-card-${featureId}"]`
);
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}"]`
);
const completeButton = page.locator(`[data-testid="complete-${featureId}"]`);
await expect(completeButton).toBeVisible({ timeout: 5000 });
await completeButton.click();
@@ -254,39 +232,28 @@ test.describe("Feature Lifecycle Tests", () => {
// Verify feature status is completed in filesystem
const featureData = JSON.parse(
fs.readFileSync(
path.join(featuresDir, featureId, "feature.json"),
"utf-8"
)
fs.readFileSync(path.join(featuresDir, featureId, 'feature.json'), 'utf-8')
);
expect(featureData.status).toBe("completed");
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"]'
);
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"]'
);
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}"]`
);
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}"]`
);
const restoreButton = page.locator(`[data-testid="unarchive-${featureId}"]`);
await expect(restoreButton).toBeVisible({ timeout: 5000 });
await restoreButton.click();
@@ -294,47 +261,34 @@ test.describe("Feature Lifecycle Tests", () => {
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();
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}"]`
);
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"
)
fs.readFileSync(path.join(featuresDir, featureId, 'feature.json'), 'utf-8')
);
expect(restoredFeatureData.status).toBe("verified");
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}"]`
);
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"]'
);
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"]'
);
const confirmDeleteButton = page.locator('[data-testid="confirm-delete-button"]');
await confirmDeleteButton.click();
// Wait for the delete action to complete
@@ -361,7 +315,7 @@ test.describe("Feature Lifecycle Tests", () => {
// Step 1: Setup and create a feature in backlog
// ==========================================================================
await setupProjectWithPathNoWorktrees(page, testRepo.path);
await page.goto("/");
await page.goto('/');
await waitForNetworkIdle(page);
await waitForBoardView(page);
await page.waitForTimeout(1000);
@@ -370,17 +324,15 @@ test.describe("Feature Lifecycle Tests", () => {
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();
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");
const featuresDir = path.join(testRepo.path, '.automaker', 'features');
await expect(async () => {
const dirs = fs.readdirSync(featuresDir);
expect(dirs.length).toBeGreaterThan(0);
@@ -396,36 +348,26 @@ test.describe("Feature Lifecycle Tests", () => {
await waitForBoardView(page);
// Wait for the feature card to appear
const featureCard = page.locator(
`[data-testid="kanban-card-${testFeatureId}"]`
);
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"]'
);
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"
);
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"));
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);
expect(['in_progress', 'waiting_approval']).toContain(featureData.status);
}).toPass({ timeout: 15000 });
// ==========================================================================
@@ -433,19 +375,14 @@ test.describe("Feature Lifecycle Tests", () => {
// ==========================================================================
// 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");
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
);
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();
@@ -457,12 +394,8 @@ test.describe("Feature Lifecycle Tests", () => {
// ==========================================================================
// 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}"]`
);
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);
@@ -470,8 +403,8 @@ test.describe("Feature Lifecycle Tests", () => {
// Verify feature is in backlog
await expect(async () => {
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
expect(data.status).toBe("backlog");
const data = JSON.parse(fs.readFileSync(featureFilePath, 'utf-8'));
expect(data.status).toBe('backlog');
}).toPass({ timeout: 10000 });
// Reload to ensure clean state
@@ -482,55 +415,45 @@ test.describe("Feature Lifecycle Tests", () => {
// ==========================================================================
// Step 5: Restart the feature (drag to in_progress again)
// ==========================================================================
const restartCard = page.locator(
`[data-testid="kanban-card-${testFeatureId}"]`
);
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"]'
);
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") {
page.on('console', (msg) => {
if (msg.type() === 'error') {
consoleErrors.push(msg.text());
}
});
// Drag to in_progress to restart
await dragAndDropWithDndKit(
page,
restartDragHandle,
inProgressColumnRestart
);
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"));
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);
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")
(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");
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!");
console.log('Feature successfully restarted after stop!');
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { test, expect } from "@playwright/test";
import { test, expect } from '@playwright/test';
import {
resetFixtureSpec,
setupProjectWithFixture,
@@ -12,9 +12,9 @@ import {
fillInput,
waitForNetworkIdle,
waitForElement,
} from "./utils";
} from './utils';
test.describe("Spec Editor Persistence", () => {
test.describe('Spec Editor Persistence', () => {
test.beforeEach(async () => {
// Reset the fixture spec file to original content before each test
resetFixtureSpec();
@@ -25,7 +25,7 @@ test.describe("Spec Editor Persistence", () => {
resetFixtureSpec();
});
test("should open project, edit spec, save, and persist changes after refresh", async ({
test('should open project, edit spec, save, and persist changes after refresh', async ({
page,
}) => {
// Use the resolved fixture path
@@ -35,33 +35,33 @@ test.describe("Spec Editor Persistence", () => {
await setupProjectWithFixture(page, fixturePath);
// Step 2: Navigate to the app
await page.goto("/");
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 });
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 });
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 });
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 });
await specEditor.locator('.cm-content').waitFor({ state: 'visible', timeout: 10000 });
// Step 8: Modify the editor content to "hello world"
await setEditorContent(page, "hello world");
await setEditorContent(page, 'hello world');
// Verify content was set before saving
const contentBeforeSave = await getEditorContent(page);
expect(contentBeforeSave.trim()).toBe("hello world");
expect(contentBeforeSave.trim()).toBe('hello world');
// Step 9: Click the save button and wait for save to complete
await clickSaveButton(page);
@@ -72,14 +72,16 @@ test.describe("Spec Editor Persistence", () => {
// 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 });
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 });
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
@@ -91,11 +93,11 @@ test.describe("Spec Editor Persistence", () => {
try {
const contentElement = page.locator('[data-testid="spec-editor"] .cm-content');
const text = await contentElement.textContent();
if (text && text.trim() === "hello world") {
if (text && text.trim() === 'hello world') {
contentMatches = true;
break;
}
} catch (e) {
} catch {
// Element might not be ready yet, continue
}
@@ -111,20 +113,20 @@ test.describe("Spec Editor Persistence", () => {
(expectedContent) => {
const contentElement = document.querySelector('[data-testid="spec-editor"] .cm-content');
if (!contentElement) return false;
const text = (contentElement.textContent || "").trim();
const text = (contentElement.textContent || '').trim();
return text === expectedContent;
},
"hello world",
'hello world',
{ timeout: 10000 }
);
}
// Step 12: Verify the content was persisted
const persistedContent = await getEditorContent(page);
expect(persistedContent.trim()).toBe("hello world");
expect(persistedContent.trim()).toBe('hello world');
});
test("should handle opening project via Open Project button and file browser", async ({
test('should handle opening project via Open Project button and file browser', async ({
page,
}) => {
// This test covers the flow of:
@@ -139,49 +141,47 @@ test.describe("Spec Editor Persistence", () => {
state: {
projects: [],
currentProject: null,
currentView: "welcome",
theme: "dark",
currentView: 'welcome',
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
// Mark setup as complete
const setupState = {
state: {
isFirstRun: false,
setupComplete: true,
currentStep: "complete",
currentStep: 'complete',
skipClaudeSetup: false,
},
version: 0,
};
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
});
// Navigate to the app
await page.goto("/");
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 });
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");
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);
const isButtonVisible = await openProjectButton.isVisible().catch(() => false);
if (isButtonVisible) {
await clickElement(page, "open-project-button");
await clickElement(page, 'open-project-button');
// The file browser dialog should open
// Note: In web mode, this might use the FileBrowserDialog component
@@ -200,10 +200,10 @@ test.describe("Spec Editor Persistence", () => {
// 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");
console.log('File browser dialog opened successfully');
// Press Escape to close the dialog
await page.keyboard.press("Escape");
await page.keyboard.press('Escape');
}
}
@@ -220,7 +220,7 @@ test.describe("Spec Editor Persistence", () => {
});
});
test.describe("Spec Editor - Full Open Project Flow", () => {
test.describe('Spec Editor - Full Open Project Flow', () => {
test.beforeEach(async () => {
// Reset the fixture spec file to original content before each test
resetFixtureSpec();
@@ -232,11 +232,9 @@ test.describe("Spec Editor - Full Open Project Flow", () => {
});
// 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,
}) => {
test.skip('should open project via file browser, edit spec, and persist', async ({ page }) => {
// Navigate to app first
await page.goto("/");
await page.goto('/');
await waitForNetworkIdle(page);
// Set up localStorage state (without a current project, but mark setup complete)
@@ -247,29 +245,29 @@ test.describe("Spec Editor - Full Open Project Flow", () => {
state: {
projects: [],
currentProject: null,
currentView: "welcome",
theme: "dark",
currentView: 'welcome',
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
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",
currentStep: 'complete',
skipClaudeSetup: false,
},
version: 0,
};
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
});
// Reload to apply the localStorage state
@@ -277,69 +275,68 @@ test.describe("Spec Editor - Full Open Project Flow", () => {
await waitForNetworkIdle(page);
// Wait for sidebar
await waitForElement(page, "sidebar", { timeout: 10000 });
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");
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 });
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..."),
() => !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 });
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());
await fillInput(page, 'path-input', getFixturePath());
// Click the Go button to navigate to the path
await clickElement(page, "go-to-path-button");
await clickElement(page, 'go-to-path-button');
// Wait for loading to complete
await page.waitForFunction(
() => !document.body.textContent?.includes("Loading directories..."),
() => !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");
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")'
);
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.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");
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 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");
await setEditorContent(page, 'hello world');
// Click save button
await clickSaveButton(page);
@@ -349,15 +346,17 @@ test.describe("Spec Editor - Full Open Project Flow", () => {
await waitForNetworkIdle(page);
// Navigate back to spec editor
await specNav.waitFor({ state: "visible", timeout: 10000 });
await clickElement(page, "nav-spec");
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 });
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");
expect(persistedContent.trim()).toBe('hello world');
});
});

View File

@@ -3,8 +3,8 @@
* Provides type-safe wrappers around common API operations
*/
import { Page, APIResponse } from "@playwright/test";
import { API_ENDPOINTS } from "../core/constants";
import { Page, APIResponse } from '@playwright/test';
import { API_ENDPOINTS } from '../core/constants';
// ============================================================================
// Types

View File

@@ -1,12 +1,10 @@
import { Page, Locator } from "@playwright/test";
import { waitForElement, waitForElementHidden } from "../core/waiting";
import { Page, Locator } from '@playwright/test';
import { waitForElement, waitForElementHidden } from '../core/waiting';
/**
* Check if the category autocomplete dropdown is visible
*/
export async function isCategoryAutocompleteListVisible(
page: Page
): Promise<boolean> {
export async function isCategoryAutocompleteListVisible(page: Page): Promise<boolean> {
const list = page.locator('[data-testid="category-autocomplete-list"]');
return await list.isVisible();
}
@@ -18,7 +16,7 @@ export async function waitForCategoryAutocompleteList(
page: Page,
options?: { timeout?: number }
): Promise<Locator> {
return await waitForElement(page, "category-autocomplete-list", options);
return await waitForElement(page, 'category-autocomplete-list', options);
}
/**
@@ -28,19 +26,14 @@ export async function waitForCategoryAutocompleteListHidden(
page: Page,
options?: { timeout?: number }
): Promise<void> {
await waitForElementHidden(page, "category-autocomplete-list", options);
await waitForElementHidden(page, 'category-autocomplete-list', options);
}
/**
* Click a category option in the autocomplete dropdown
*/
export async function clickCategoryOption(
page: Page,
categoryName: string
): Promise<void> {
const optionTestId = `category-option-${categoryName
.toLowerCase()
.replace(/\s+/g, "-")}`;
export async function clickCategoryOption(page: Page, categoryName: string): Promise<void> {
const optionTestId = `category-option-${categoryName.toLowerCase().replace(/\s+/g, '-')}`;
const option = page.locator(`[data-testid="${optionTestId}"]`);
await option.click();
}
@@ -48,22 +41,15 @@ export async function clickCategoryOption(
/**
* Get a category option element by name
*/
export async function getCategoryOption(
page: Page,
categoryName: string
): Promise<Locator> {
const optionTestId = `category-option-${categoryName
.toLowerCase()
.replace(/\s+/g, "-")}`;
export async function getCategoryOption(page: Page, categoryName: string): Promise<Locator> {
const optionTestId = `category-option-${categoryName.toLowerCase().replace(/\s+/g, '-')}`;
return page.locator(`[data-testid="${optionTestId}"]`);
}
/**
* Click the "Create new" option for a category that doesn't exist
*/
export async function clickCreateNewCategoryOption(
page: Page
): Promise<void> {
export async function clickCreateNewCategoryOption(page: Page): Promise<void> {
const option = page.locator('[data-testid="category-option-create-new"]');
await option.click();
}
@@ -71,8 +57,6 @@ export async function clickCreateNewCategoryOption(
/**
* Get the "Create new" option element for categories
*/
export async function getCreateNewCategoryOption(
page: Page
): Promise<Locator> {
export async function getCreateNewCategoryOption(page: Page): Promise<Locator> {
return page.locator('[data-testid="category-option-create-new"]');
}

View File

@@ -1,6 +1,6 @@
import { Page, Locator } from "@playwright/test";
import { clickElement } from "../core/interactions";
import { waitForElement, waitForElementHidden } from "../core/waiting";
import { Page, Locator } from '@playwright/test';
import { clickElement } from '../core/interactions';
import { waitForElement, waitForElementHidden } from '../core/waiting';
/**
* Check if the add feature dialog is visible
@@ -33,36 +33,29 @@ export async function waitForEditFeatureDialog(
page: Page,
options?: { timeout?: number }
): Promise<Locator> {
return await waitForElement(page, "edit-feature-dialog", options);
return await waitForElement(page, 'edit-feature-dialog', options);
}
/**
* Get the edit feature description input/textarea element
*/
export async function getEditFeatureDescriptionInput(
page: Page
): Promise<Locator> {
export async function getEditFeatureDescriptionInput(page: Page): Promise<Locator> {
return page.locator('[data-testid="edit-feature-description"]');
}
/**
* Check if the edit feature description field is a textarea
*/
export async function isEditFeatureDescriptionTextarea(
page: Page
): Promise<boolean> {
export async function isEditFeatureDescriptionTextarea(page: Page): Promise<boolean> {
const element = page.locator('[data-testid="edit-feature-description"]');
const tagName = await element.evaluate((el) => el.tagName.toLowerCase());
return tagName === "textarea";
return tagName === 'textarea';
}
/**
* Open the edit dialog for a specific feature
*/
export async function openEditFeatureDialog(
page: Page,
featureId: string
): Promise<void> {
export async function openEditFeatureDialog(page: Page, featureId: string): Promise<void> {
await clickElement(page, `edit-feature-${featureId}`);
await waitForEditFeatureDialog(page);
}
@@ -70,10 +63,7 @@ export async function openEditFeatureDialog(
/**
* Fill the edit feature description field
*/
export async function fillEditFeatureDescription(
page: Page,
value: string
): Promise<void> {
export async function fillEditFeatureDescription(page: Page, value: string): Promise<void> {
const input = await getEditFeatureDescriptionInput(page);
await input.fill(value);
}
@@ -82,24 +72,20 @@ export async function fillEditFeatureDescription(
* Click the confirm edit feature button
*/
export async function confirmEditFeature(page: Page): Promise<void> {
await clickElement(page, "confirm-edit-feature");
await clickElement(page, 'confirm-edit-feature');
}
/**
* Get the delete confirmation dialog
*/
export async function getDeleteConfirmationDialog(
page: Page
): Promise<Locator> {
export async function getDeleteConfirmationDialog(page: Page): Promise<Locator> {
return page.locator('[data-testid="delete-confirmation-dialog"]');
}
/**
* Check if the delete confirmation dialog is visible
*/
export async function isDeleteConfirmationDialogVisible(
page: Page
): Promise<boolean> {
export async function isDeleteConfirmationDialogVisible(page: Page): Promise<boolean> {
const dialog = page.locator('[data-testid="delete-confirmation-dialog"]');
return await dialog.isVisible().catch(() => false);
}
@@ -111,7 +97,7 @@ export async function waitForDeleteConfirmationDialog(
page: Page,
options?: { timeout?: number }
): Promise<Locator> {
return await waitForElement(page, "delete-confirmation-dialog", options);
return await waitForElement(page, 'delete-confirmation-dialog', options);
}
/**
@@ -121,21 +107,21 @@ export async function waitForDeleteConfirmationDialogHidden(
page: Page,
options?: { timeout?: number }
): Promise<void> {
await waitForElementHidden(page, "delete-confirmation-dialog", options);
await waitForElementHidden(page, 'delete-confirmation-dialog', options);
}
/**
* Click the confirm delete button in the delete confirmation dialog
*/
export async function clickConfirmDeleteButton(page: Page): Promise<void> {
await clickElement(page, "confirm-delete-button");
await clickElement(page, 'confirm-delete-button');
}
/**
* Click the cancel delete button in the delete confirmation dialog
*/
export async function clickCancelDeleteButton(page: Page): Promise<void> {
await clickElement(page, "cancel-delete-button");
await clickElement(page, 'cancel-delete-button');
}
/**
@@ -153,7 +139,7 @@ export async function waitForFollowUpDialog(
page: Page,
options?: { timeout?: number }
): Promise<Locator> {
return await waitForElement(page, "follow-up-dialog", options);
return await waitForElement(page, 'follow-up-dialog', options);
}
/**
@@ -163,14 +149,14 @@ export async function waitForFollowUpDialogHidden(
page: Page,
options?: { timeout?: number }
): Promise<void> {
await waitForElementHidden(page, "follow-up-dialog", options);
await waitForElementHidden(page, 'follow-up-dialog', options);
}
/**
* Click the confirm follow-up button in the follow-up dialog
*/
export async function clickConfirmFollowUp(page: Page): Promise<void> {
await clickElement(page, "confirm-follow-up");
await clickElement(page, 'confirm-follow-up');
}
/**
@@ -188,7 +174,7 @@ export async function waitForProjectInitDialog(
page: Page,
options?: { timeout?: number }
): Promise<Locator> {
return await waitForElement(page, "project-init-dialog", options);
return await waitForElement(page, 'project-init-dialog', options);
}
/**

View File

@@ -1,5 +1,5 @@
import { Page, Locator } from "@playwright/test";
import { waitForElement, waitForElementHidden } from "../core/waiting";
import { Page, Locator } from '@playwright/test';
import { waitForElement, waitForElementHidden } from '../core/waiting';
/**
* Check if the agent output modal is visible
@@ -16,7 +16,7 @@ export async function waitForAgentOutputModal(
page: Page,
options?: { timeout?: number }
): Promise<Locator> {
return await waitForElement(page, "agent-output-modal", options);
return await waitForElement(page, 'agent-output-modal', options);
}
/**
@@ -26,28 +26,22 @@ export async function waitForAgentOutputModalHidden(
page: Page,
options?: { timeout?: number }
): Promise<void> {
await waitForElementHidden(page, "agent-output-modal", options);
await waitForElementHidden(page, 'agent-output-modal', options);
}
/**
* Get the modal title/description text to verify which feature's output is being shown
*/
export async function getAgentOutputModalDescription(
page: Page
): Promise<string | null> {
export async function getAgentOutputModalDescription(page: Page): Promise<string | null> {
const modal = page.locator('[data-testid="agent-output-modal"]');
const description = modal
.locator('[id="radix-\\:r.+\\:-description"]')
.first();
const description = modal.locator('[id="radix-\\:r.+\\:-description"]').first();
return await description.textContent().catch(() => null);
}
/**
* Check the dialog description content in the agent output modal
*/
export async function getOutputModalDescription(
page: Page
): Promise<string | null> {
export async function getOutputModalDescription(page: Page): Promise<string | null> {
const modalDescription = page.locator(
'[data-testid="agent-output-modal"] [data-slot="dialog-description"]'
);
@@ -57,18 +51,14 @@ export async function getOutputModalDescription(
/**
* Get the agent output modal description element
*/
export async function getAgentOutputModalDescriptionElement(
page: Page
): Promise<Locator> {
export async function getAgentOutputModalDescriptionElement(page: Page): Promise<Locator> {
return page.locator('[data-testid="agent-output-description"]');
}
/**
* Check if the agent output modal description is scrollable
*/
export async function isAgentOutputDescriptionScrollable(
page: Page
): Promise<boolean> {
export async function isAgentOutputDescriptionScrollable(page: Page): Promise<boolean> {
const description = page.locator('[data-testid="agent-output-description"]');
const scrollInfo = await description.evaluate((el) => {
return {
@@ -83,9 +73,7 @@ export async function isAgentOutputDescriptionScrollable(
/**
* Get scroll dimensions of the agent output modal description
*/
export async function getAgentOutputDescriptionScrollDimensions(
page: Page
): Promise<{
export async function getAgentOutputDescriptionScrollDimensions(page: Page): Promise<{
scrollHeight: number;
clientHeight: number;
maxHeight: string;

View File

@@ -1,5 +1,4 @@
import { Page, Locator } from "@playwright/test";
import { waitForElement } from "../core/waiting";
import { Page, Locator } from '@playwright/test';
/**
* Wait for a toast notification with specific text to appear
@@ -12,7 +11,7 @@ export async function waitForToast(
const toast = page.locator(`[data-sonner-toast]:has-text("${text}")`).first();
await toast.waitFor({
timeout: options?.timeout ?? 5000,
state: "visible",
state: 'visible',
});
return toast;
}
@@ -32,19 +31,21 @@ export async function waitForErrorToast(
if (titleText) {
// First try specific error type, then fallback to any toast with text
const errorToast = page.locator(
`[data-sonner-toast][data-type="error"]:has-text("${titleText}"), [data-sonner-toast]:has-text("${titleText}")`
).first();
const errorToast = page
.locator(
`[data-sonner-toast][data-type="error"]:has-text("${titleText}"), [data-sonner-toast]:has-text("${titleText}")`
)
.first();
await errorToast.waitFor({
timeout,
state: "visible",
state: 'visible',
});
return errorToast;
} else {
const errorToast = page.locator('[data-sonner-toast][data-type="error"]').first();
await errorToast.waitFor({
timeout,
state: "visible",
state: 'visible',
});
return errorToast;
}
@@ -53,10 +54,7 @@ export async function waitForErrorToast(
/**
* Check if an error toast is visible
*/
export async function isErrorToastVisible(
page: Page,
titleText?: string
): Promise<boolean> {
export async function isErrorToastVisible(page: Page, titleText?: string): Promise<boolean> {
const toastSelector = titleText
? `[data-sonner-toast][data-type="error"]:has-text("${titleText}")`
: '[data-sonner-toast][data-type="error"]';
@@ -81,7 +79,7 @@ export async function waitForSuccessToast(
const toast = page.locator(toastSelector).first();
await toast.waitFor({
timeout: options?.timeout ?? 5000,
state: "visible",
state: 'visible',
});
return toast;
}

View File

@@ -10,7 +10,7 @@
/**
* Base URL for the API server
*/
export const API_BASE_URL = "http://localhost:3008";
export const API_BASE_URL = 'http://localhost:3008';
/**
* API endpoints for worktree operations
@@ -74,55 +74,55 @@ export const TIMEOUTS = {
*/
export const TEST_IDS = {
// Sidebar & Navigation
sidebar: "sidebar",
navBoard: "nav-board",
navSpec: "nav-spec",
navContext: "nav-context",
navAgent: "nav-agent",
navProfiles: "nav-profiles",
settingsButton: "settings-button",
openProjectButton: "open-project-button",
sidebar: 'sidebar',
navBoard: 'nav-board',
navSpec: 'nav-spec',
navContext: 'nav-context',
navAgent: 'nav-agent',
navProfiles: 'nav-profiles',
settingsButton: 'settings-button',
openProjectButton: 'open-project-button',
// Views
boardView: "board-view",
specView: "spec-view",
contextView: "context-view",
agentView: "agent-view",
profilesView: "profiles-view",
settingsView: "settings-view",
welcomeView: "welcome-view",
setupView: "setup-view",
boardView: 'board-view',
specView: 'spec-view',
contextView: 'context-view',
agentView: 'agent-view',
profilesView: 'profiles-view',
settingsView: 'settings-view',
welcomeView: 'welcome-view',
setupView: 'setup-view',
// Board View Components
addFeatureButton: "add-feature-button",
addFeatureDialog: "add-feature-dialog",
confirmAddFeature: "confirm-add-feature",
featureBranchInput: "feature-input",
featureCategoryInput: "feature-category-input",
worktreeSelector: "worktree-selector",
addFeatureButton: 'add-feature-button',
addFeatureDialog: 'add-feature-dialog',
confirmAddFeature: 'confirm-add-feature',
featureBranchInput: 'feature-input',
featureCategoryInput: 'feature-category-input',
worktreeSelector: 'worktree-selector',
// Spec Editor
specEditor: "spec-editor",
specEditor: 'spec-editor',
// File Browser Dialog
pathInput: "path-input",
goToPathButton: "go-to-path-button",
pathInput: 'path-input',
goToPathButton: 'go-to-path-button',
// Profiles View
addProfileButton: "add-profile-button",
addProfileDialog: "add-profile-dialog",
editProfileDialog: "edit-profile-dialog",
deleteProfileConfirmDialog: "delete-profile-confirm-dialog",
saveProfileButton: "save-profile-button",
confirmDeleteProfileButton: "confirm-delete-profile-button",
cancelDeleteButton: "cancel-delete-button",
profileNameInput: "profile-name-input",
profileDescriptionInput: "profile-description-input",
refreshProfilesButton: "refresh-profiles-button",
addProfileButton: 'add-profile-button',
addProfileDialog: 'add-profile-dialog',
editProfileDialog: 'edit-profile-dialog',
deleteProfileConfirmDialog: 'delete-profile-confirm-dialog',
saveProfileButton: 'save-profile-button',
confirmDeleteProfileButton: 'confirm-delete-profile-button',
cancelDeleteButton: 'cancel-delete-button',
profileNameInput: 'profile-name-input',
profileDescriptionInput: 'profile-description-input',
refreshProfilesButton: 'refresh-profiles-button',
// Context View
contextFileList: "context-file-list",
addContextButton: "add-context-button",
contextFileList: 'context-file-list',
addContextButton: 'add-context-button',
} as const;
// ============================================================================
@@ -134,17 +134,17 @@ export const TEST_IDS = {
*/
export const CSS_SELECTORS = {
/** CodeMirror editor content area */
codeMirrorContent: ".cm-content",
codeMirrorContent: '.cm-content',
/** Dialog elements */
dialog: '[role="dialog"]',
/** Sonner toast notifications */
toast: "[data-sonner-toast]",
toast: '[data-sonner-toast]',
toastError: '[data-sonner-toast][data-type="error"]',
toastSuccess: '[data-sonner-toast][data-type="success"]',
/** Command/combobox input (shadcn-ui cmdk) */
commandInput: "[cmdk-input]",
commandInput: '[cmdk-input]',
/** Radix dialog overlay */
dialogOverlay: "[data-radix-dialog-overlay]",
dialogOverlay: '[data-radix-dialog-overlay]',
} as const;
// ============================================================================
@@ -155,8 +155,8 @@ export const CSS_SELECTORS = {
* localStorage keys used by the application
*/
export const STORAGE_KEYS = {
appStorage: "automaker-storage",
setupStorage: "automaker-setup",
appStorage: 'automaker-storage',
setupStorage: 'automaker-setup',
} as const;
// ============================================================================
@@ -169,7 +169,7 @@ export const STORAGE_KEYS = {
* @returns Sanitized name suitable for directory paths
*/
export function sanitizeBranchName(branchName: string): string {
return branchName.replace(/[^a-zA-Z0-9_-]/g, "-");
return branchName.replace(/[^a-zA-Z0-9_-]/g, '-');
}
// ============================================================================
@@ -180,8 +180,8 @@ export function sanitizeBranchName(branchName: string): string {
* Default values used in test setup
*/
export const DEFAULTS = {
projectName: "Test Project",
projectPath: "/mock/test-project",
theme: "dark" as const,
projectName: 'Test Project',
projectPath: '/mock/test-project',
theme: 'dark' as const,
maxConcurrency: 3,
} as const;

View File

@@ -1,22 +1,16 @@
import { Page, Locator } from "@playwright/test";
import { Page, Locator } from '@playwright/test';
/**
* Get an element by its data-testid attribute
*/
export async function getByTestId(
page: Page,
testId: string
): Promise<Locator> {
export async function getByTestId(page: Page, testId: string): Promise<Locator> {
return page.locator(`[data-testid="${testId}"]`);
}
/**
* Get a button by its text content
*/
export async function getButtonByText(
page: Page,
text: string
): Promise<Locator> {
export async function getButtonByText(page: Page, text: string): Promise<Locator> {
return page.locator(`button:has-text("${text}")`);
}
@@ -25,7 +19,7 @@ export async function getButtonByText(
*/
export async function getCategoryAutocompleteInput(
page: Page,
testId: string = "feature-category-input"
testId: string = 'feature-category-input'
): Promise<Locator> {
return page.locator(`[data-testid="${testId}"]`);
}
@@ -33,8 +27,6 @@ export async function getCategoryAutocompleteInput(
/**
* Get the category autocomplete dropdown list
*/
export async function getCategoryAutocompleteList(
page: Page
): Promise<Locator> {
export async function getCategoryAutocompleteList(page: Page): Promise<Locator> {
return page.locator('[data-testid="category-autocomplete-list"]');
}

View File

@@ -1,12 +1,12 @@
import { Page } from "@playwright/test";
import { getByTestId, getButtonByText } from "./elements";
import { Page } from '@playwright/test';
import { getByTestId, getButtonByText } from './elements';
/**
* Get the platform-specific modifier key (Meta for Mac, Control for Windows/Linux)
* This is used for keyboard shortcuts like Cmd+Enter or Ctrl+Enter
*/
export function getPlatformModifier(): "Meta" | "Control" {
return process.platform === "darwin" ? "Meta" : "Control";
export function getPlatformModifier(): 'Meta' | 'Control' {
return process.platform === 'darwin' ? 'Meta' : 'Control';
}
/**
@@ -28,10 +28,7 @@ export async function clickElement(page: Page, testId: string): Promise<void> {
/**
* Click a button by its text content
*/
export async function clickButtonByText(
page: Page,
text: string
): Promise<void> {
export async function clickButtonByText(page: Page, text: string): Promise<void> {
const button = await getButtonByText(page, text);
await button.click();
}
@@ -39,11 +36,7 @@ export async function clickButtonByText(
/**
* Fill an input field by its data-testid attribute
*/
export async function fillInput(
page: Page,
testId: string,
value: string
): Promise<void> {
export async function fillInput(page: Page, testId: string, value: string): Promise<void> {
const input = await getByTestId(page, testId);
await input.fill(value);
}
@@ -75,11 +68,11 @@ export async function focusOnInput(page: Page, testId: string): Promise<void> {
* Waits for dialog to be removed from DOM rather than using arbitrary timeout
*/
export async function closeDialogWithEscape(page: Page): Promise<void> {
await page.keyboard.press("Escape");
await page.keyboard.press('Escape');
// Wait for any dialog overlay to disappear
await page
.locator('[data-radix-dialog-overlay], [role="dialog"]')
.waitFor({ state: "hidden", timeout: 5000 })
.waitFor({ state: 'hidden', timeout: 5000 })
.catch(() => {
// Dialog may have already closed or not exist
});

View File

@@ -1,11 +1,11 @@
import { Page, Locator } from "@playwright/test";
import { Page, Locator } from '@playwright/test';
/**
* Wait for the page to reach network idle state
* This is commonly used after navigation or page reload to ensure all network requests have completed
*/
export async function waitForNetworkIdle(page: Page): Promise<void> {
await page.waitForLoadState("networkidle");
await page.waitForLoadState('networkidle');
}
/**
@@ -14,12 +14,12 @@ export async function waitForNetworkIdle(page: Page): Promise<void> {
export async function waitForElement(
page: Page,
testId: string,
options?: { timeout?: number; state?: "attached" | "visible" | "hidden" }
options?: { timeout?: number; state?: 'attached' | 'visible' | 'hidden' }
): Promise<Locator> {
const element = page.locator(`[data-testid="${testId}"]`);
await element.waitFor({
timeout: options?.timeout ?? 5000,
state: options?.state ?? "visible",
state: options?.state ?? 'visible',
});
return element;
}
@@ -35,6 +35,6 @@ export async function waitForElementHidden(
const element = page.locator(`[data-testid="${testId}"]`);
await element.waitFor({
timeout: options?.timeout ?? 5000,
state: "hidden",
state: 'hidden',
});
}

View File

@@ -1,4 +1,4 @@
import { Page, Locator } from "@playwright/test";
import { Page, Locator } from '@playwright/test';
/**
* Perform a drag and drop operation that works with @dnd-kit
@@ -13,8 +13,8 @@ export async function dragAndDropWithDndKit(
targetLocator: Locator
): Promise<void> {
// Ensure elements are visible and stable before getting bounding boxes
await sourceLocator.waitFor({ state: "visible", timeout: 5000 });
await targetLocator.waitFor({ state: "visible", timeout: 5000 });
await sourceLocator.waitFor({ state: 'visible', timeout: 5000 });
await targetLocator.waitFor({ state: 'visible', timeout: 5000 });
// Small delay to ensure layout is stable
await page.waitForTimeout(100);
@@ -23,7 +23,7 @@ export async function dragAndDropWithDndKit(
const targetBox = await targetLocator.boundingBox();
if (!sourceBox || !targetBox) {
throw new Error("Could not find source or target element bounds");
throw new Error('Could not find source or target element bounds');
}
// Start drag from the center of the source element

View File

@@ -1,4 +1,4 @@
import { Page, Locator } from "@playwright/test";
import { Page, Locator } from '@playwright/test';
/**
* Get the skip tests checkbox element in the add feature dialog
@@ -20,8 +20,8 @@ export async function toggleSkipTestsCheckbox(page: Page): Promise<void> {
*/
export async function isSkipTestsChecked(page: Page): Promise<boolean> {
const checkbox = page.locator('[data-testid="skip-tests-checkbox"]');
const state = await checkbox.getAttribute("data-state");
return state === "checked";
const state = await checkbox.getAttribute('data-state');
return state === 'checked';
}
/**
@@ -44,17 +44,14 @@ export async function toggleEditSkipTestsCheckbox(page: Page): Promise<void> {
*/
export async function isEditSkipTestsChecked(page: Page): Promise<boolean> {
const checkbox = page.locator('[data-testid="edit-skip-tests-checkbox"]');
const state = await checkbox.getAttribute("data-state");
return state === "checked";
const state = await checkbox.getAttribute('data-state');
return state === 'checked';
}
/**
* Check if the skip tests badge is visible on a kanban card
*/
export async function isSkipTestsBadgeVisible(
page: Page,
featureId: string
): Promise<boolean> {
export async function isSkipTestsBadgeVisible(page: Page, featureId: string): Promise<boolean> {
const badge = page.locator(`[data-testid="skip-tests-badge-${featureId}"]`);
return await badge.isVisible().catch(() => false);
}
@@ -62,20 +59,14 @@ export async function isSkipTestsBadgeVisible(
/**
* Get the skip tests badge element for a kanban card
*/
export async function getSkipTestsBadge(
page: Page,
featureId: string
): Promise<Locator> {
export async function getSkipTestsBadge(page: Page, featureId: string): Promise<Locator> {
return page.locator(`[data-testid="skip-tests-badge-${featureId}"]`);
}
/**
* Click the manual verify button for a skipTests feature
*/
export async function clickManualVerify(
page: Page,
featureId: string
): Promise<void> {
export async function clickManualVerify(page: Page, featureId: string): Promise<void> {
const button = page.locator(`[data-testid="manual-verify-${featureId}"]`);
await button.click();
}
@@ -83,10 +74,7 @@ export async function clickManualVerify(
/**
* Check if the manual verify button is visible for a feature
*/
export async function isManualVerifyButtonVisible(
page: Page,
featureId: string
): Promise<boolean> {
export async function isManualVerifyButtonVisible(page: Page, featureId: string): Promise<boolean> {
const button = page.locator(`[data-testid="manual-verify-${featureId}"]`);
return await button.isVisible().catch(() => false);
}
@@ -94,10 +82,7 @@ export async function isManualVerifyButtonVisible(
/**
* Click the move back button for a verified skipTests feature
*/
export async function clickMoveBack(
page: Page,
featureId: string
): Promise<void> {
export async function clickMoveBack(page: Page, featureId: string): Promise<void> {
const button = page.locator(`[data-testid="move-back-${featureId}"]`);
await button.click();
}
@@ -105,10 +90,7 @@ export async function clickMoveBack(
/**
* Check if the move back button is visible for a feature
*/
export async function isMoveBackButtonVisible(
page: Page,
featureId: string
): Promise<boolean> {
export async function isMoveBackButtonVisible(page: Page, featureId: string): Promise<boolean> {
const button = page.locator(`[data-testid="move-back-${featureId}"]`);
return await button.isVisible().catch(() => false);
}

View File

@@ -1,12 +1,9 @@
import { Page, Locator } from "@playwright/test";
import { Page, Locator } from '@playwright/test';
/**
* Get the count up timer element for a specific feature card
*/
export async function getTimerForFeature(
page: Page,
featureId: string
): Promise<Locator> {
export async function getTimerForFeature(page: Page, featureId: string): Promise<Locator> {
const card = page.locator(`[data-testid="kanban-card-${featureId}"]`);
return card.locator('[data-testid="count-up-timer"]');
}
@@ -26,10 +23,7 @@ export async function getTimerDisplayForFeature(
/**
* Check if a timer is visible for a specific feature
*/
export async function isTimerVisibleForFeature(
page: Page,
featureId: string
): Promise<boolean> {
export async function isTimerVisibleForFeature(page: Page, featureId: string): Promise<boolean> {
const card = page.locator(`[data-testid="kanban-card-${featureId}"]`);
const timer = card.locator('[data-testid="count-up-timer"]');
return await timer.isVisible().catch(() => false);

View File

@@ -1,22 +1,16 @@
import { Page, Locator } from "@playwright/test";
import { Page, Locator } from '@playwright/test';
/**
* Get the follow-up button for a waiting_approval feature
*/
export async function getFollowUpButton(
page: Page,
featureId: string
): Promise<Locator> {
export async function getFollowUpButton(page: Page, featureId: string): Promise<Locator> {
return page.locator(`[data-testid="follow-up-${featureId}"]`);
}
/**
* Click the follow-up button for a waiting_approval feature
*/
export async function clickFollowUpButton(
page: Page,
featureId: string
): Promise<void> {
export async function clickFollowUpButton(page: Page, featureId: string): Promise<void> {
const button = page.locator(`[data-testid="follow-up-${featureId}"]`);
await button.click();
}
@@ -24,10 +18,7 @@ export async function clickFollowUpButton(
/**
* Check if the follow-up button is visible for a feature
*/
export async function isFollowUpButtonVisible(
page: Page,
featureId: string
): Promise<boolean> {
export async function isFollowUpButtonVisible(page: Page, featureId: string): Promise<boolean> {
const button = page.locator(`[data-testid="follow-up-${featureId}"]`);
return await button.isVisible().catch(() => false);
}
@@ -35,20 +26,14 @@ export async function isFollowUpButtonVisible(
/**
* Get the commit button for a waiting_approval feature
*/
export async function getCommitButton(
page: Page,
featureId: string
): Promise<Locator> {
export async function getCommitButton(page: Page, featureId: string): Promise<Locator> {
return page.locator(`[data-testid="commit-${featureId}"]`);
}
/**
* Click the commit button for a waiting_approval feature
*/
export async function clickCommitButton(
page: Page,
featureId: string
): Promise<void> {
export async function clickCommitButton(page: Page, featureId: string): Promise<void> {
const button = page.locator(`[data-testid="commit-${featureId}"]`);
await button.click();
}
@@ -56,10 +41,7 @@ export async function clickCommitButton(
/**
* Check if the commit button is visible for a feature
*/
export async function isCommitButtonVisible(
page: Page,
featureId: string
): Promise<boolean> {
export async function isCommitButtonVisible(page: Page, featureId: string): Promise<boolean> {
const button = page.locator(`[data-testid="commit-${featureId}"]`);
return await button.isVisible().catch(() => false);
}
@@ -74,9 +56,7 @@ export async function getWaitingApprovalColumn(page: Page): Promise<Locator> {
/**
* Check if the waiting_approval column is visible
*/
export async function isWaitingApprovalColumnVisible(
page: Page
): Promise<boolean> {
export async function isWaitingApprovalColumnVisible(page: Page): Promise<boolean> {
const column = page.locator('[data-testid="kanban-column-waiting_approval"]');
return await column.isVisible().catch(() => false);
}

View File

@@ -1,4 +1,4 @@
import { Page } from "@playwright/test";
import { Page } from '@playwright/test';
/**
* Simulate drag and drop of a file onto an element
@@ -8,7 +8,7 @@ export async function simulateFileDrop(
targetSelector: string,
fileName: string,
fileContent: string,
mimeType: string = "text/plain"
mimeType: string = 'text/plain'
): Promise<void> {
await page.evaluate(
({ selector, content, name, mime }) => {
@@ -21,13 +21,13 @@ export async function simulateFileDrop(
// Dispatch drag events
target.dispatchEvent(
new DragEvent("dragover", {
new DragEvent('dragover', {
dataTransfer,
bubbles: true,
})
);
target.dispatchEvent(
new DragEvent("drop", {
new DragEvent('drop', {
dataTransfer,
bubbles: true,
})
@@ -45,7 +45,7 @@ export async function simulateImagePaste(
page: Page,
targetSelector: string,
imageBase64: string,
mimeType: string = "image/png"
mimeType: string = 'image/png'
): Promise<void> {
await page.evaluate(
({ selector, base64, mime }) => {
@@ -62,14 +62,14 @@ export async function simulateImagePaste(
const blob = new Blob([byteArray], { type: mime });
// Create a File from Blob
const file = new File([blob], "pasted-image.png", { type: mime });
const file = new File([blob], 'pasted-image.png', { type: mime });
// Create a DataTransfer with clipboard items
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
// Create ClipboardEvent with the image data
const clipboardEvent = new ClipboardEvent("paste", {
const clipboardEvent = new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: dataTransfer,

View File

@@ -3,12 +3,12 @@
* Provides helpers for creating test git repos and managing worktrees
*/
import * as fs from "fs";
import * as path from "path";
import { exec } from "child_process";
import { promisify } from "util";
import { Page } from "@playwright/test";
import { sanitizeBranchName, TIMEOUTS } from "../core/constants";
import * as fs from 'fs';
import * as path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
import { Page } from '@playwright/test';
import { sanitizeBranchName, TIMEOUTS } from '../core/constants';
const execAsync = promisify(exec);
@@ -40,8 +40,8 @@ export interface FeatureData {
*/
function getWorkspaceRoot(): string {
const cwd = process.cwd();
if (cwd.includes("apps/ui")) {
return path.resolve(cwd, "../..");
if (cwd.includes('apps/ui')) {
return path.resolve(cwd, '../..');
}
return cwd;
}
@@ -49,9 +49,9 @@ function getWorkspaceRoot(): string {
/**
* Create a unique temp directory path for tests
*/
export function createTempDirPath(prefix: string = "temp-worktree-tests"): string {
export function createTempDirPath(prefix: string = 'temp-worktree-tests'): string {
const uniqueId = `${process.pid}-${Math.random().toString(36).substring(2, 9)}`;
return path.join(getWorkspaceRoot(), "test", `${prefix}-${uniqueId}`);
return path.join(getWorkspaceRoot(), 'test', `${prefix}-${uniqueId}`);
}
/**
@@ -59,7 +59,7 @@ export function createTempDirPath(prefix: string = "temp-worktree-tests"): strin
*/
export function getWorktreePath(projectPath: string, branchName: string): string {
const sanitizedName = sanitizeBranchName(branchName);
return path.join(projectPath, ".worktrees", sanitizedName);
return path.join(projectPath, '.worktrees', sanitizedName);
}
// ============================================================================
@@ -79,25 +79,25 @@ export async function createTestGitRepo(tempDir: string): Promise<TestRepo> {
fs.mkdirSync(tmpDir, { recursive: true });
// Initialize git repo
await execAsync("git init", { cwd: tmpDir });
await execAsync('git init', { cwd: tmpDir });
await execAsync('git config user.email "test@example.com"', { cwd: tmpDir });
await execAsync('git config user.name "Test User"', { cwd: tmpDir });
// Create initial commit
fs.writeFileSync(path.join(tmpDir, "README.md"), "# Test Project\n");
await execAsync("git add .", { cwd: tmpDir });
fs.writeFileSync(path.join(tmpDir, 'README.md'), '# Test Project\n');
await execAsync('git add .', { cwd: tmpDir });
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir });
// Create main branch explicitly
await execAsync("git branch -M main", { cwd: tmpDir });
await execAsync('git branch -M main', { cwd: tmpDir });
// Create .automaker directories
const automakerDir = path.join(tmpDir, ".automaker");
const featuresDir = path.join(automakerDir, "features");
const automakerDir = path.join(tmpDir, '.automaker');
const featuresDir = path.join(automakerDir, 'features');
fs.mkdirSync(featuresDir, { recursive: true });
// Create empty categories.json to avoid ENOENT errors in tests
fs.writeFileSync(path.join(automakerDir, "categories.json"), "[]");
fs.writeFileSync(path.join(automakerDir, 'categories.json'), '[]');
return {
path: tmpDir,
@@ -113,16 +113,16 @@ export async function createTestGitRepo(tempDir: string): Promise<TestRepo> {
export async function cleanupTestRepo(repoPath: string): Promise<void> {
try {
// Remove all worktrees first
const { stdout } = await execAsync("git worktree list --porcelain", {
const { stdout } = await execAsync('git worktree list --porcelain', {
cwd: repoPath,
}).catch(() => ({ stdout: "" }));
}).catch(() => ({ stdout: '' }));
const worktrees = stdout
.split("\n\n")
.split('\n\n')
.slice(1) // Skip main worktree
.map((block) => {
const pathLine = block.split("\n").find((line) => line.startsWith("worktree "));
return pathLine ? pathLine.replace("worktree ", "") : null;
const pathLine = block.split('\n').find((line) => line.startsWith('worktree '));
return pathLine ? pathLine.replace('worktree ', '') : null;
})
.filter(Boolean);
@@ -139,7 +139,7 @@ export async function cleanupTestRepo(repoPath: string): Promise<void> {
// Remove the repository
fs.rmSync(repoPath, { recursive: true, force: true });
} catch (error) {
console.error("Failed to cleanup test repo:", error);
console.error('Failed to cleanup test repo:', error);
}
}
@@ -171,18 +171,18 @@ export async function gitExec(
*/
export async function listWorktrees(repoPath: string): Promise<string[]> {
try {
const { stdout } = await execAsync("git worktree list --porcelain", {
const { stdout } = await execAsync('git worktree list --porcelain', {
cwd: repoPath,
});
return stdout
.split("\n\n")
.split('\n\n')
.slice(1) // Skip main worktree
.map((block) => {
const pathLine = block.split("\n").find((line) => line.startsWith("worktree "));
const pathLine = block.split('\n').find((line) => line.startsWith('worktree '));
if (!pathLine) return null;
// Normalize path separators to OS native (git on Windows returns forward slashes)
const worktreePath = pathLine.replace("worktree ", "");
const worktreePath = pathLine.replace('worktree ', '');
return path.normalize(worktreePath);
})
.filter(Boolean) as string[];
@@ -195,10 +195,10 @@ export async function listWorktrees(repoPath: string): Promise<string[]> {
* Get list of git branches
*/
export async function listBranches(repoPath: string): Promise<string[]> {
const { stdout } = await execAsync("git branch --list", { cwd: repoPath });
const { stdout } = await execAsync('git branch --list', { cwd: repoPath });
return stdout
.split("\n")
.map((line) => line.trim().replace(/^[*+]\s*/, ""))
.split('\n')
.map((line) => line.trim().replace(/^[*+]\s*/, ''))
.filter(Boolean);
}
@@ -206,7 +206,7 @@ export async function listBranches(repoPath: string): Promise<string[]> {
* Get the current branch name
*/
export async function getCurrentBranch(repoPath: string): Promise<string> {
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd: repoPath });
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: repoPath });
return stdout.trim();
}
@@ -233,7 +233,7 @@ export async function createWorktreeDirectly(
worktreePath?: string
): Promise<string> {
const sanitizedName = sanitizeBranchName(branchName);
const targetPath = worktreePath || path.join(repoPath, ".worktrees", sanitizedName);
const targetPath = worktreePath || path.join(repoPath, '.worktrees', sanitizedName);
await execAsync(`git worktree add "${targetPath}" -b ${branchName}`, { cwd: repoPath });
return targetPath;
@@ -257,7 +257,7 @@ export async function commitFile(
* Get the latest commit message
*/
export async function getLatestCommitMessage(repoPath: string): Promise<string> {
const { stdout } = await execAsync("git log --oneline -1", { cwd: repoPath });
const { stdout } = await execAsync('git log --oneline -1', { cwd: repoPath });
return stdout.trim();
}
@@ -268,32 +268,36 @@ export async function getLatestCommitMessage(repoPath: string): Promise<string>
/**
* Create a feature file in the test repo
*/
export function createTestFeature(repoPath: string, featureId: string, featureData: FeatureData): void {
const featuresDir = path.join(repoPath, ".automaker", "features");
export function createTestFeature(
repoPath: string,
featureId: string,
featureData: FeatureData
): void {
const featuresDir = path.join(repoPath, '.automaker', 'features');
const featureDir = path.join(featuresDir, featureId);
fs.mkdirSync(featureDir, { recursive: true });
fs.writeFileSync(path.join(featureDir, "feature.json"), JSON.stringify(featureData, null, 2));
fs.writeFileSync(path.join(featureDir, 'feature.json'), JSON.stringify(featureData, null, 2));
}
/**
* Read a feature file from the test repo
*/
export function readTestFeature(repoPath: string, featureId: string): FeatureData | null {
const featureFilePath = path.join(repoPath, ".automaker", "features", featureId, "feature.json");
const featureFilePath = path.join(repoPath, '.automaker', 'features', featureId, 'feature.json');
if (!fs.existsSync(featureFilePath)) {
return null;
}
return JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
return JSON.parse(fs.readFileSync(featureFilePath, 'utf-8'));
}
/**
* List all feature directories in the test repo
*/
export function listTestFeatures(repoPath: string): string[] {
const featuresDir = path.join(repoPath, ".automaker", "features");
const featuresDir = path.join(repoPath, '.automaker', 'features');
if (!fs.existsSync(featuresDir)) {
return [];
@@ -312,8 +316,8 @@ export function listTestFeatures(repoPath: string): string[] {
export async function setupProjectWithPath(page: Page, projectPath: string): Promise<void> {
await page.addInitScript((pathArg: string) => {
const mockProject = {
id: "test-project-worktree",
name: "Worktree Test Project",
id: 'test-project-worktree',
name: 'Worktree Test Project',
path: pathArg,
lastOpened: new Date().toISOString(),
};
@@ -322,36 +326,36 @@ export async function setupProjectWithPath(page: Page, projectPath: string): Pro
state: {
projects: [mockProject],
currentProject: mockProject,
currentView: "board",
theme: "dark",
currentView: 'board',
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
aiProfiles: [],
useWorktrees: true, // Enable worktree feature for tests
currentWorktreeByProject: {
[pathArg]: { path: null, branch: "main" }, // Initialize to main branch
[pathArg]: { path: null, branch: 'main' }, // Initialize to main branch
},
worktreesByProject: {},
},
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
// Mark setup as complete to skip the setup wizard
const setupState = {
state: {
isFirstRun: false,
setupComplete: true,
currentStep: "complete",
currentStep: 'complete',
skipClaudeSetup: false,
},
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
}, projectPath);
}
@@ -359,11 +363,14 @@ export async function setupProjectWithPath(page: Page, projectPath: string): Pro
* Set up localStorage with a project pointing to a test repo with worktrees DISABLED
* Use this to test scenarios where the worktree feature flag is off
*/
export async function setupProjectWithPathNoWorktrees(page: Page, projectPath: string): Promise<void> {
export async function setupProjectWithPathNoWorktrees(
page: Page,
projectPath: string
): Promise<void> {
await page.addInitScript((pathArg: string) => {
const mockProject = {
id: "test-project-no-worktree",
name: "Test Project (No Worktrees)",
id: 'test-project-no-worktree',
name: 'Test Project (No Worktrees)',
path: pathArg,
lastOpened: new Date().toISOString(),
};
@@ -372,10 +379,10 @@ export async function setupProjectWithPathNoWorktrees(page: Page, projectPath: s
state: {
projects: [mockProject],
currentProject: mockProject,
currentView: "board",
theme: "dark",
currentView: 'board',
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
@@ -387,19 +394,19 @@ export async function setupProjectWithPathNoWorktrees(page: Page, projectPath: s
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
// Mark setup as complete to skip the setup wizard
const setupState = {
state: {
isFirstRun: false,
setupComplete: true,
currentStep: "complete",
currentStep: 'complete',
skipClaudeSetup: false,
},
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
}, projectPath);
}
@@ -408,11 +415,14 @@ export async function setupProjectWithPathNoWorktrees(page: Page, projectPath: s
* The currentWorktreeByProject points to a worktree path that no longer exists
* This simulates the scenario where a user previously selected a worktree that was later deleted
*/
export async function setupProjectWithStaleWorktree(page: Page, projectPath: string): Promise<void> {
export async function setupProjectWithStaleWorktree(
page: Page,
projectPath: string
): Promise<void> {
await page.addInitScript((pathArg: string) => {
const mockProject = {
id: "test-project-stale-worktree",
name: "Stale Worktree Test Project",
id: 'test-project-stale-worktree',
name: 'Stale Worktree Test Project',
path: pathArg,
lastOpened: new Date().toISOString(),
};
@@ -421,10 +431,10 @@ export async function setupProjectWithStaleWorktree(page: Page, projectPath: str
state: {
projects: [mockProject],
currentProject: mockProject,
currentView: "board",
theme: "dark",
currentView: 'board',
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
@@ -432,26 +442,26 @@ export async function setupProjectWithStaleWorktree(page: Page, projectPath: str
useWorktrees: true, // Enable worktree feature for tests
currentWorktreeByProject: {
// This is STALE data - pointing to a worktree path that doesn't exist
[pathArg]: { path: "/non/existent/worktree/path", branch: "feature/deleted-branch" },
[pathArg]: { path: '/non/existent/worktree/path', branch: 'feature/deleted-branch' },
},
worktreesByProject: {},
},
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
// Mark setup as complete to skip the setup wizard
const setupState = {
state: {
isFirstRun: false,
setupComplete: true,
currentStep: "complete",
currentStep: 'complete',
skipClaudeSetup: false,
},
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
}, projectPath);
}
@@ -477,8 +487,6 @@ export async function waitForBoardView(page: Page): Promise<void> {
await page.waitForFunction(
() => {
const boardView = document.querySelector('[data-testid="board-view"]');
const noProject = document.querySelector('[data-testid="board-view-no-project"]');
const loading = document.querySelector('[data-testid="board-view-loading"]');
// Return true only when board-view is visible (store hydrated with project)
return boardView !== null;
},
@@ -490,8 +498,10 @@ export async function waitForBoardView(page: Page): Promise<void> {
* Wait for the worktree selector to be visible
*/
export async function waitForWorktreeSelector(page: Page): Promise<void> {
await page.waitForSelector('[data-testid="worktree-selector"]', { timeout: TIMEOUTS.medium }).catch(() => {
// Fallback: wait for "Branch:" text
return page.getByText("Branch:").waitFor({ timeout: TIMEOUTS.medium });
});
await page
.waitForSelector('[data-testid="worktree-selector"]', { timeout: TIMEOUTS.medium })
.catch(() => {
// Fallback: wait for "Branch:" text
return page.getByText('Branch:').waitFor({ timeout: TIMEOUTS.medium });
});
}

View File

@@ -1,11 +1,9 @@
import { Page, Locator } from "@playwright/test";
import { Page, Locator } from '@playwright/test';
/**
* Get the concurrency slider container
*/
export async function getConcurrencySliderContainer(
page: Page
): Promise<Locator> {
export async function getConcurrencySliderContainer(page: Page): Promise<Locator> {
return page.locator('[data-testid="concurrency-slider-container"]');
}
@@ -37,7 +35,7 @@ export async function setConcurrencyValue(
const sliderBounds = await slider.boundingBox();
if (!sliderBounds) {
throw new Error("Concurrency slider not found or not visible");
throw new Error('Concurrency slider not found or not visible');
}
// Calculate position for target value

View File

@@ -1,5 +1,5 @@
import { Page, Locator } from "@playwright/test";
import { clickElement } from "../core/interactions";
import { Page, Locator } from '@playwright/test';
import { clickElement } from '../core/interactions';
/**
* Get the log viewer header element (contains type counts and expand/collapse buttons)
@@ -26,30 +26,21 @@ export async function getLogEntriesContainer(page: Page): Promise<Locator> {
/**
* Get a log entry by its type
*/
export async function getLogEntryByType(
page: Page,
type: string
): Promise<Locator> {
export async function getLogEntryByType(page: Page, type: string): Promise<Locator> {
return page.locator(`[data-testid="log-entry-${type}"]`).first();
}
/**
* Get all log entries of a specific type
*/
export async function getAllLogEntriesByType(
page: Page,
type: string
): Promise<Locator> {
export async function getAllLogEntriesByType(page: Page, type: string): Promise<Locator> {
return page.locator(`[data-testid="log-entry-${type}"]`);
}
/**
* Count log entries of a specific type
*/
export async function countLogEntriesByType(
page: Page,
type: string
): Promise<number> {
export async function countLogEntriesByType(page: Page, type: string): Promise<number> {
const entries = page.locator(`[data-testid="log-entry-${type}"]`);
return await entries.count();
}
@@ -57,20 +48,14 @@ export async function countLogEntriesByType(
/**
* Get the log type count badge by type
*/
export async function getLogTypeCountBadge(
page: Page,
type: string
): Promise<Locator> {
export async function getLogTypeCountBadge(page: Page, type: string): Promise<Locator> {
return page.locator(`[data-testid="log-type-count-${type}"]`);
}
/**
* Check if a log type count badge is visible
*/
export async function isLogTypeCountBadgeVisible(
page: Page,
type: string
): Promise<boolean> {
export async function isLogTypeCountBadgeVisible(page: Page, type: string): Promise<boolean> {
const badge = page.locator(`[data-testid="log-type-count-${type}"]`);
return await badge.isVisible().catch(() => false);
}
@@ -79,14 +64,14 @@ export async function isLogTypeCountBadgeVisible(
* Click the expand all button in the log viewer
*/
export async function clickLogExpandAll(page: Page): Promise<void> {
await clickElement(page, "log-expand-all");
await clickElement(page, 'log-expand-all');
}
/**
* Click the collapse all button in the log viewer
*/
export async function clickLogCollapseAll(page: Page): Promise<void> {
await clickElement(page, "log-collapse-all");
await clickElement(page, 'log-collapse-all');
}
/**
@@ -107,31 +92,22 @@ export async function isLogEntryBadgeVisible(page: Page): Promise<boolean> {
/**
* Get the view mode toggle button (parsed/raw)
*/
export async function getViewModeButton(
page: Page,
mode: "parsed" | "raw"
): Promise<Locator> {
export async function getViewModeButton(page: Page, mode: 'parsed' | 'raw'): Promise<Locator> {
return page.locator(`[data-testid="view-mode-${mode}"]`);
}
/**
* Click a view mode toggle button
*/
export async function clickViewModeButton(
page: Page,
mode: "parsed" | "raw"
): Promise<void> {
export async function clickViewModeButton(page: Page, mode: 'parsed' | 'raw'): Promise<void> {
await clickElement(page, `view-mode-${mode}`);
}
/**
* Check if a view mode button is active (selected)
*/
export async function isViewModeActive(
page: Page,
mode: "parsed" | "raw"
): Promise<boolean> {
export async function isViewModeActive(page: Page, mode: 'parsed' | 'raw'): Promise<boolean> {
const button = page.locator(`[data-testid="view-mode-${mode}"]`);
const classes = await button.getAttribute("class");
return classes?.includes("text-purple-300") ?? false;
const classes = await button.getAttribute('class');
return classes?.includes('text-purple-300') ?? false;
}

View File

@@ -1,4 +1,4 @@
import { Locator } from "@playwright/test";
import { Locator } from '@playwright/test';
/**
* Check if an element is scrollable (has scrollable content)

View File

@@ -1,49 +1,49 @@
// Re-export all utilities from their respective modules
// Core utilities
export * from "./core/elements";
export * from "./core/interactions";
export * from "./core/waiting";
export * from "./core/constants";
export * from './core/elements';
export * from './core/interactions';
export * from './core/waiting';
export * from './core/constants';
// API utilities
export * from "./api/client";
export * from './api/client';
// Git utilities
export * from "./git/worktree";
export * from './git/worktree';
// Project utilities
export * from "./project/setup";
export * from "./project/fixtures";
export * from './project/setup';
export * from './project/fixtures';
// Navigation utilities
export * from "./navigation/views";
export * from './navigation/views';
// View-specific utilities
export * from "./views/board";
export * from "./views/context";
export * from "./views/spec-editor";
export * from "./views/agent";
export * from "./views/settings";
export * from "./views/setup";
export * from "./views/profiles";
export * from './views/board';
export * from './views/context';
export * from './views/spec-editor';
export * from './views/agent';
export * from './views/settings';
export * from './views/setup';
export * from './views/profiles';
// Component utilities
export * from "./components/dialogs";
export * from "./components/toasts";
export * from "./components/modals";
export * from "./components/autocomplete";
export * from './components/dialogs';
export * from './components/toasts';
export * from './components/modals';
export * from './components/autocomplete';
// Feature utilities
export * from "./features/kanban";
export * from "./features/timers";
export * from "./features/skip-tests";
export * from "./features/waiting-approval";
export * from './features/kanban';
export * from './features/timers';
export * from './features/skip-tests';
export * from './features/waiting-approval';
// Helper utilities
export * from "./helpers/scroll";
export * from "./helpers/log-viewer";
export * from "./helpers/concurrency";
export * from './helpers/scroll';
export * from './helpers/log-viewer';
export * from './helpers/concurrency';
// File utilities
export * from "./files/drag-drop";
export * from './files/drag-drop';

View File

@@ -1,6 +1,6 @@
import { Page } from "@playwright/test";
import { clickElement } from "../core/interactions";
import { waitForElement } from "../core/waiting";
import { Page } from '@playwright/test';
import { clickElement } from '../core/interactions';
import { waitForElement } from '../core/waiting';
/**
* Navigate to the board/kanban view
@@ -8,11 +8,11 @@ import { waitForElement } from "../core/waiting";
*/
export async function navigateToBoard(page: Page): Promise<void> {
// Navigate directly to /board route
await page.goto("/board");
await page.waitForLoadState("networkidle");
await page.goto('/board');
await page.waitForLoadState('networkidle');
// Wait for the board view to be visible
await waitForElement(page, "board-view", { timeout: 10000 });
await waitForElement(page, 'board-view', { timeout: 10000 });
}
/**
@@ -21,11 +21,23 @@ export async function navigateToBoard(page: Page): Promise<void> {
*/
export async function navigateToContext(page: Page): Promise<void> {
// Navigate directly to /context route
await page.goto("/context");
await page.waitForLoadState("networkidle");
await page.goto('/context');
await page.waitForLoadState('networkidle');
// Wait for loading to complete (if present)
const loadingElement = page.locator('[data-testid="context-view-loading"]');
try {
const loadingVisible = await loadingElement.isVisible({ timeout: 2000 });
if (loadingVisible) {
// Wait for loading to disappear (context view will appear)
await loadingElement.waitFor({ state: 'hidden', timeout: 10000 });
}
} catch {
// Loading element not found or already hidden, continue
}
// Wait for the context view to be visible
await waitForElement(page, "context-view", { timeout: 10000 });
await waitForElement(page, 'context-view', { timeout: 10000 });
}
/**
@@ -34,8 +46,8 @@ export async function navigateToContext(page: Page): Promise<void> {
*/
export async function navigateToSpec(page: Page): Promise<void> {
// Navigate directly to /spec route
await page.goto("/spec");
await page.waitForLoadState("networkidle");
await page.goto('/spec');
await page.waitForLoadState('networkidle');
// Wait for loading state to complete first (if present)
const loadingElement = page.locator('[data-testid="spec-view-loading"]');
@@ -43,7 +55,7 @@ export async function navigateToSpec(page: Page): Promise<void> {
const loadingVisible = await loadingElement.isVisible({ timeout: 2000 });
if (loadingVisible) {
// Wait for loading to disappear (spec view or empty state will appear)
await loadingElement.waitFor({ state: "hidden", timeout: 10000 });
await loadingElement.waitFor({ state: 'hidden', timeout: 10000 });
}
} catch {
// Loading element not found or already hidden, continue
@@ -53,8 +65,8 @@ export async function navigateToSpec(page: Page): Promise<void> {
// The spec-view element appears when loading is complete and spec exists
// The spec-view-empty element appears when loading is complete and spec doesn't exist
await Promise.race([
waitForElement(page, "spec-view", { timeout: 10000 }).catch(() => null),
waitForElement(page, "spec-view-empty", { timeout: 10000 }).catch(() => null),
waitForElement(page, 'spec-view', { timeout: 10000 }).catch(() => null),
waitForElement(page, 'spec-view-empty', { timeout: 10000 }).catch(() => null),
]);
}
@@ -64,11 +76,11 @@ export async function navigateToSpec(page: Page): Promise<void> {
*/
export async function navigateToAgent(page: Page): Promise<void> {
// Navigate directly to /agent route
await page.goto("/agent");
await page.waitForLoadState("networkidle");
await page.goto('/agent');
await page.waitForLoadState('networkidle');
// Wait for the agent view to be visible
await waitForElement(page, "agent-view", { timeout: 10000 });
await waitForElement(page, 'agent-view', { timeout: 10000 });
}
/**
@@ -77,11 +89,11 @@ export async function navigateToAgent(page: Page): Promise<void> {
*/
export async function navigateToSettings(page: Page): Promise<void> {
// Navigate directly to /settings route
await page.goto("/settings");
await page.waitForLoadState("networkidle");
await page.goto('/settings');
await page.waitForLoadState('networkidle');
// Wait for the settings view to be visible
await waitForElement(page, "settings-view", { timeout: 10000 });
await waitForElement(page, 'settings-view', { timeout: 10000 });
}
/**
@@ -90,31 +102,27 @@ export async function navigateToSettings(page: Page): Promise<void> {
*/
export async function navigateToSetup(page: Page): Promise<void> {
// Dynamic import to avoid circular dependency
const { setupFirstRun } = await import("../project/setup");
const { setupFirstRun } = await import('../project/setup');
await setupFirstRun(page);
await page.goto("/");
await page.waitForLoadState("networkidle");
await waitForElement(page, "setup-view", { timeout: 10000 });
await page.goto('/');
await page.waitForLoadState('networkidle');
await waitForElement(page, 'setup-view', { timeout: 10000 });
}
/**
* Navigate to the welcome view (clear project selection)
*/
export async function navigateToWelcome(page: Page): Promise<void> {
await page.goto("/");
await page.waitForLoadState("networkidle");
await waitForElement(page, "welcome-view", { timeout: 10000 });
await page.goto('/');
await page.waitForLoadState('networkidle');
await waitForElement(page, 'welcome-view', { timeout: 10000 });
}
/**
* Navigate to a specific view using the sidebar navigation
*/
export async function navigateToView(
page: Page,
viewId: string
): Promise<void> {
const navSelector =
viewId === "settings" ? "settings-button" : `nav-${viewId}`;
export async function navigateToView(page: Page, viewId: string): Promise<void> {
const navSelector = viewId === 'settings' ? 'settings-button' : `nav-${viewId}`;
await clickElement(page, navSelector);
await page.waitForTimeout(100);
}
@@ -125,7 +133,7 @@ export async function navigateToView(
export async function getCurrentView(page: Page): Promise<string | null> {
// Get the current view from zustand store via localStorage
const storage = await page.evaluate(() => {
const item = localStorage.getItem("automaker-storage");
const item = localStorage.getItem('automaker-storage');
return item ? JSON.parse(item) : null;
});

View File

@@ -1,22 +1,22 @@
import { Page } from "@playwright/test";
import * as fs from "fs";
import * as path from "path";
import { Page } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
/**
* Resolve the workspace root - handle both running from apps/ui and from root
*/
export function getWorkspaceRoot(): string {
const cwd = process.cwd();
if (cwd.includes("apps/ui")) {
return path.resolve(cwd, "../..");
if (cwd.includes('apps/ui')) {
return path.resolve(cwd, '../..');
}
return cwd;
}
const WORKSPACE_ROOT = getWorkspaceRoot();
const FIXTURE_PATH = path.join(WORKSPACE_ROOT, "test/fixtures/projectA");
const SPEC_FILE_PATH = path.join(FIXTURE_PATH, ".automaker/app_spec.txt");
const CONTEXT_PATH = path.join(FIXTURE_PATH, ".automaker/context");
const FIXTURE_PATH = path.join(WORKSPACE_ROOT, 'test/fixtures/projectA');
const SPEC_FILE_PATH = path.join(FIXTURE_PATH, '.automaker/app_spec.txt');
const CONTEXT_PATH = path.join(FIXTURE_PATH, '.automaker/context');
// Original spec content for resetting between tests
const ORIGINAL_SPEC_CONTENT = `<app_spec>
@@ -76,8 +76,8 @@ export async function setupProjectWithFixture(
): Promise<void> {
await page.addInitScript((pathArg: string) => {
const mockProject = {
id: "test-project-fixture",
name: "projectA",
id: 'test-project-fixture',
name: 'projectA',
path: pathArg,
lastOpened: new Date().toISOString(),
};
@@ -86,10 +86,10 @@ export async function setupProjectWithFixture(
state: {
projects: [mockProject],
currentProject: mockProject,
currentView: "board",
theme: "dark",
currentView: 'board',
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
@@ -97,19 +97,19 @@ export async function setupProjectWithFixture(
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
// Also mark setup as complete (fallback for when NEXT_PUBLIC_SKIP_SETUP isn't set)
const setupState = {
state: {
isFirstRun: false,
setupComplete: true,
currentStep: "complete",
currentStep: 'complete',
skipClaudeSetup: false,
},
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
}, projectPath);
}

View File

@@ -1,4 +1,4 @@
import { Page } from "@playwright/test";
import { Page } from '@playwright/test';
/**
* Set up a mock project in localStorage to bypass the welcome screen
@@ -7,9 +7,9 @@ import { Page } from "@playwright/test";
export async function setupMockProject(page: Page): Promise<void> {
await page.addInitScript(() => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
id: 'test-project-1',
name: 'Test Project',
path: '/mock/test-project',
lastOpened: new Date().toISOString(),
};
@@ -17,9 +17,9 @@ export async function setupMockProject(page: Page): Promise<void> {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
@@ -27,7 +27,7 @@ export async function setupMockProject(page: Page): Promise<void> {
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
});
}
@@ -40,9 +40,9 @@ export async function setupMockProjectWithConcurrency(
): Promise<void> {
await page.addInitScript((maxConcurrency: number) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
id: 'test-project-1',
name: 'Test Project',
path: '/mock/test-project',
lastOpened: new Date().toISOString(),
};
@@ -50,9 +50,9 @@ export async function setupMockProjectWithConcurrency(
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: maxConcurrency,
@@ -60,7 +60,7 @@ export async function setupMockProjectWithConcurrency(
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
}, concurrency);
}
@@ -70,20 +70,14 @@ export async function setupMockProjectWithConcurrency(
export async function setupMockProjectAtConcurrencyLimit(
page: Page,
maxConcurrency: number = 1,
runningTasks: string[] = ["running-task-1"]
runningTasks: string[] = ['running-task-1']
): Promise<void> {
await page.addInitScript(
({
maxConcurrency,
runningTasks,
}: {
maxConcurrency: number;
runningTasks: string[];
}) => {
({ maxConcurrency, runningTasks }: { maxConcurrency: number; runningTasks: string[] }) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
id: 'test-project-1',
name: 'Test Project',
path: '/mock/test-project',
lastOpened: new Date().toISOString(),
};
@@ -91,9 +85,9 @@ export async function setupMockProjectAtConcurrencyLimit(
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: maxConcurrency,
@@ -104,7 +98,7 @@ export async function setupMockProjectAtConcurrencyLimit(
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
},
{ maxConcurrency, runningTasks }
);
@@ -122,16 +116,16 @@ export async function setupMockProjectWithFeatures(
id: string;
category: string;
description: string;
status: "backlog" | "in_progress" | "verified";
status: 'backlog' | 'in_progress' | 'verified';
steps?: string[];
}>;
}
): Promise<void> {
await page.addInitScript((opts: typeof options) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
id: 'test-project-1',
name: 'Test Project',
path: '/mock/test-project',
lastOpened: new Date().toISOString(),
};
@@ -141,9 +135,9 @@ export async function setupMockProjectWithFeatures(
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: opts?.maxConcurrency ?? 3,
@@ -155,7 +149,7 @@ export async function setupMockProjectWithFeatures(
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
// Also store features in a global variable that the mock electron API can use
// This is needed because the board-view loads features from the file system
@@ -170,20 +164,14 @@ export async function setupMockProjectWithFeatures(
export async function setupMockProjectWithContextFile(
page: Page,
featureId: string,
contextContent: string = "# Agent Context\n\nPrevious implementation work..."
contextContent: string = '# Agent Context\n\nPrevious implementation work...'
): Promise<void> {
await page.addInitScript(
({
featureId,
contextContent,
}: {
featureId: string;
contextContent: string;
}) => {
({ featureId, contextContent }: { featureId: string; contextContent: string }) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
id: 'test-project-1',
name: 'Test Project',
path: '/mock/test-project',
lastOpened: new Date().toISOString(),
};
@@ -191,9 +179,9 @@ export async function setupMockProjectWithContextFile(
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
@@ -201,7 +189,7 @@ export async function setupMockProjectWithContextFile(
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
// Set up mock file system with a context file for the feature
// This will be used by the mock electron API
@@ -228,7 +216,7 @@ export async function setupMockProjectWithInProgressFeatures(
id: string;
category: string;
description: string;
status: "backlog" | "in_progress" | "verified";
status: 'backlog' | 'in_progress' | 'verified';
steps?: string[];
startedAt?: string;
}>;
@@ -236,9 +224,9 @@ export async function setupMockProjectWithInProgressFeatures(
): Promise<void> {
await page.addInitScript((opts: typeof options) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
id: 'test-project-1',
name: 'Test Project',
path: '/mock/test-project',
lastOpened: new Date().toISOString(),
};
@@ -248,9 +236,9 @@ export async function setupMockProjectWithInProgressFeatures(
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: opts?.maxConcurrency ?? 3,
@@ -262,7 +250,7 @@ export async function setupMockProjectWithInProgressFeatures(
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
// Also store features in a global variable that the mock electron API can use
// This is needed because the board-view loads features from the file system
@@ -273,15 +261,12 @@ export async function setupMockProjectWithInProgressFeatures(
/**
* Set up a mock project with a specific current view for route persistence testing
*/
export async function setupMockProjectWithView(
page: Page,
view: string
): Promise<void> {
export async function setupMockProjectWithView(page: Page, view: string): Promise<void> {
await page.addInitScript((currentView: string) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
id: 'test-project-1',
name: 'Test Project',
path: '/mock/test-project',
lastOpened: new Date().toISOString(),
};
@@ -290,9 +275,9 @@ export async function setupMockProjectWithView(
projects: [mockProject],
currentProject: mockProject,
currentView: currentView,
theme: "dark",
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
@@ -300,7 +285,7 @@ export async function setupMockProjectWithView(
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
}, view);
}
@@ -313,38 +298,36 @@ export async function setupEmptyLocalStorage(page: Page): Promise<void> {
state: {
projects: [],
currentProject: null,
currentView: "welcome",
theme: "dark",
currentView: 'welcome',
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
},
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
});
}
/**
* Set up mock projects in localStorage but with no current project (for recent projects list)
*/
export async function setupMockProjectsWithoutCurrent(
page: Page
): Promise<void> {
export async function setupMockProjectsWithoutCurrent(page: Page): Promise<void> {
await page.addInitScript(() => {
const mockProjects = [
{
id: "test-project-1",
name: "Test Project 1",
path: "/mock/test-project-1",
id: 'test-project-1',
name: 'Test Project 1',
path: '/mock/test-project-1',
lastOpened: new Date().toISOString(),
},
{
id: "test-project-2",
name: "Test Project 2",
path: "/mock/test-project-2",
id: 'test-project-2',
name: 'Test Project 2',
path: '/mock/test-project-2',
lastOpened: new Date(Date.now() - 86400000).toISOString(), // 1 day ago
},
];
@@ -353,10 +336,10 @@ export async function setupMockProjectsWithoutCurrent(
state: {
projects: mockProjects,
currentProject: null,
currentView: "welcome",
theme: "dark",
currentView: 'welcome',
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
@@ -364,7 +347,7 @@ export async function setupMockProjectsWithoutCurrent(
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
});
}
@@ -380,7 +363,7 @@ export async function setupMockProjectWithSkipTestsFeatures(
id: string;
category: string;
description: string;
status: "backlog" | "in_progress" | "verified";
status: 'backlog' | 'in_progress' | 'verified';
steps?: string[];
startedAt?: string;
skipTests?: boolean;
@@ -389,9 +372,9 @@ export async function setupMockProjectWithSkipTestsFeatures(
): Promise<void> {
await page.addInitScript((opts: typeof options) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
id: 'test-project-1',
name: 'Test Project',
path: '/mock/test-project',
lastOpened: new Date().toISOString(),
};
@@ -401,9 +384,9 @@ export async function setupMockProjectWithSkipTestsFeatures(
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: opts?.maxConcurrency ?? 3,
@@ -415,7 +398,7 @@ export async function setupMockProjectWithSkipTestsFeatures(
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
}, options);
}
@@ -441,10 +424,10 @@ export async function setupMockMultipleProjects(
state: {
projects: mockProjects,
currentProject: mockProjects[0],
currentView: "board",
theme: "dark",
currentView: 'board',
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
@@ -452,7 +435,7 @@ export async function setupMockMultipleProjects(
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
}, projectCount);
}
@@ -465,17 +448,11 @@ export async function setupMockProjectWithAgentOutput(
outputContent: string
): Promise<void> {
await page.addInitScript(
({
featureId,
outputContent,
}: {
featureId: string;
outputContent: string;
}) => {
({ featureId, outputContent }: { featureId: string; outputContent: string }) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
id: 'test-project-1',
name: 'Test Project',
path: '/mock/test-project',
lastOpened: new Date().toISOString(),
};
@@ -483,9 +460,9 @@ export async function setupMockProjectWithAgentOutput(
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
@@ -493,7 +470,7 @@ export async function setupMockProjectWithAgentOutput(
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
// Set up mock file system with output content for the feature
// Now uses features/{id}/agent-output.md path
@@ -519,7 +496,7 @@ export async function setupMockProjectWithWaitingApprovalFeatures(
id: string;
category: string;
description: string;
status: "backlog" | "in_progress" | "waiting_approval" | "verified";
status: 'backlog' | 'in_progress' | 'waiting_approval' | 'verified';
steps?: string[];
startedAt?: string;
skipTests?: boolean;
@@ -528,9 +505,9 @@ export async function setupMockProjectWithWaitingApprovalFeatures(
): Promise<void> {
await page.addInitScript((opts: typeof options) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
id: 'test-project-1',
name: 'Test Project',
path: '/mock/test-project',
lastOpened: new Date().toISOString(),
};
@@ -540,9 +517,9 @@ export async function setupMockProjectWithWaitingApprovalFeatures(
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: opts?.maxConcurrency ?? 3,
@@ -554,7 +531,7 @@ export async function setupMockProjectWithWaitingApprovalFeatures(
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
// Also store features in a global variable that the mock electron API can use
(window as any).__mockFeatures = mockFeatures;
@@ -567,20 +544,20 @@ export async function setupMockProjectWithWaitingApprovalFeatures(
export async function setupFirstRun(page: Page): Promise<void> {
await page.addInitScript(() => {
// Clear any existing setup state to simulate first run
localStorage.removeItem("automaker-setup");
localStorage.removeItem("automaker-storage");
localStorage.removeItem('automaker-setup');
localStorage.removeItem('automaker-storage');
// Set up the setup store state for first run
const setupState = {
state: {
isFirstRun: true,
setupComplete: false,
currentStep: "welcome",
currentStep: 'welcome',
claudeCliStatus: null,
claudeAuthStatus: null,
claudeInstallProgress: {
isInstalling: false,
currentStep: "",
currentStep: '',
progress: 0,
output: [],
},
@@ -589,28 +566,28 @@ export async function setupFirstRun(page: Page): Promise<void> {
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
// Also set up app store to show setup view
const appState = {
state: {
projects: [],
currentProject: null,
theme: "dark",
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
isAutoModeRunning: false,
runningAutoTasks: [],
autoModeActivityLog: [],
currentView: "setup",
currentView: 'setup',
},
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-storage", JSON.stringify(appState));
localStorage.setItem('automaker-storage', JSON.stringify(appState));
});
}
@@ -624,13 +601,13 @@ export async function setupComplete(page: Page): Promise<void> {
state: {
isFirstRun: false,
setupComplete: true,
currentStep: "complete",
currentStep: 'complete',
skipClaudeSetup: false,
},
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
});
}
@@ -647,45 +624,44 @@ export async function setupMockProjectWithProfiles(
): Promise<void> {
await page.addInitScript((opts: typeof options) => {
const mockProject = {
id: "test-project-1",
name: "Test Project",
path: "/mock/test-project",
id: 'test-project-1',
name: 'Test Project',
path: '/mock/test-project',
lastOpened: new Date().toISOString(),
};
// Default built-in profiles (same as DEFAULT_AI_PROFILES from app-store.ts)
const builtInProfiles = [
{
id: "profile-heavy-task",
name: "Heavy Task",
id: 'profile-heavy-task',
name: 'Heavy Task',
description:
"Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.",
model: "opus" as const,
thinkingLevel: "ultrathink" as const,
provider: "claude" as const,
'Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.',
model: 'opus' as const,
thinkingLevel: 'ultrathink' as const,
provider: 'claude' as const,
isBuiltIn: true,
icon: "Brain",
icon: 'Brain',
},
{
id: "profile-balanced",
name: "Balanced",
description:
"Claude Sonnet with medium thinking for typical development tasks.",
model: "sonnet" as const,
thinkingLevel: "medium" as const,
provider: "claude" as const,
id: 'profile-balanced',
name: 'Balanced',
description: 'Claude Sonnet with medium thinking for typical development tasks.',
model: 'sonnet' as const,
thinkingLevel: 'medium' as const,
provider: 'claude' as const,
isBuiltIn: true,
icon: "Scale",
icon: 'Scale',
},
{
id: "profile-quick-edit",
name: "Quick Edit",
description: "Claude Haiku for fast, simple edits and minor fixes.",
model: "haiku" as const,
thinkingLevel: "none" as const,
provider: "claude" as const,
id: 'profile-quick-edit',
name: 'Quick Edit',
description: 'Claude Haiku for fast, simple edits and minor fixes.',
model: 'haiku' as const,
thinkingLevel: 'none' as const,
provider: 'claude' as const,
isBuiltIn: true,
icon: "Zap",
icon: 'Zap',
},
];
@@ -697,56 +673,51 @@ export async function setupMockProjectWithProfiles(
id: `custom-profile-${i + 1}`,
name: `Custom Profile ${i + 1}`,
description: `Test custom profile ${i + 1}`,
model: ["haiku", "sonnet", "opus"][i % 3] as
| "haiku"
| "sonnet"
| "opus",
thinkingLevel: ["none", "low", "medium", "high"][i % 4] as
| "none"
| "low"
| "medium"
| "high",
provider: "claude" as const,
model: ['haiku', 'sonnet', 'opus'][i % 3] as 'haiku' | 'sonnet' | 'opus',
thinkingLevel: ['none', 'low', 'medium', 'high'][i % 4] as
| 'none'
| 'low'
| 'medium'
| 'high',
provider: 'claude' as const,
isBuiltIn: false,
icon: ["Brain", "Zap", "Scale", "Cpu", "Rocket", "Sparkles"][i % 6],
icon: ['Brain', 'Zap', 'Scale', 'Cpu', 'Rocket', 'Sparkles'][i % 6],
});
}
// Combine profiles (built-in first, then custom)
const includeBuiltIn = opts?.includeBuiltIn !== false; // Default to true
const aiProfiles = includeBuiltIn
? [...builtInProfiles, ...customProfiles]
: customProfiles;
const aiProfiles = includeBuiltIn ? [...builtInProfiles, ...customProfiles] : customProfiles;
const mockState = {
state: {
projects: [mockProject],
currentProject: mockProject,
theme: "dark",
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: "", google: "", openai: "" },
apiKeys: { anthropic: '', google: '', openai: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
aiProfiles: aiProfiles,
features: [],
currentView: "board", // Start at board, will navigate to profiles
currentView: 'board', // Start at board, will navigate to profiles
},
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
// Also mark setup as complete to skip the setup wizard
const setupState = {
state: {
isFirstRun: false,
setupComplete: true,
currentStep: "complete",
currentStep: 'complete',
skipClaudeSetup: false,
},
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
}, options);
}

View File

@@ -1,5 +1,5 @@
import { Page, Locator } from "@playwright/test";
import { waitForElement } from "../core/waiting";
import { Page, Locator } from '@playwright/test';
import { waitForElement } from '../core/waiting';
/**
* Get the session list element
@@ -26,20 +26,14 @@ export async function clickNewSessionButton(page: Page): Promise<void> {
/**
* Get a session item by its ID
*/
export async function getSessionItem(
page: Page,
sessionId: string
): Promise<Locator> {
export async function getSessionItem(page: Page, sessionId: string): Promise<Locator> {
return page.locator(`[data-testid="session-item-${sessionId}"]`);
}
/**
* Click the archive button for a session
*/
export async function clickArchiveSession(
page: Page,
sessionId: string
): Promise<void> {
export async function clickArchiveSession(page: Page, sessionId: string): Promise<void> {
const button = page.locator(`[data-testid="archive-session-${sessionId}"]`);
await button.click();
}
@@ -47,9 +41,7 @@ export async function clickArchiveSession(
/**
* Check if the no session placeholder is visible
*/
export async function isNoSessionPlaceholderVisible(
page: Page
): Promise<boolean> {
export async function isNoSessionPlaceholderVisible(page: Page): Promise<boolean> {
const placeholder = page.locator('[data-testid="no-session-placeholder"]');
return await placeholder.isVisible();
}
@@ -61,7 +53,7 @@ export async function waitForNoSessionPlaceholder(
page: Page,
options?: { timeout?: number }
): Promise<Locator> {
return await waitForElement(page, "no-session-placeholder", options);
return await waitForElement(page, 'no-session-placeholder', options);
}
/**
@@ -76,23 +68,18 @@ export async function isMessageListVisible(page: Page): Promise<boolean> {
* Count the number of session items in the session list
*/
export async function countSessionItems(page: Page): Promise<number> {
const sessionList = page.locator(
'[data-testid="session-list"] [data-testid^="session-item-"]'
);
const sessionList = page.locator('[data-testid="session-list"] [data-testid^="session-item-"]');
return await sessionList.count();
}
/**
* Wait for a new session to be created (by checking if a session item appears)
*/
export async function waitForNewSession(
page: Page,
options?: { timeout?: number }
): Promise<void> {
export async function waitForNewSession(page: Page, options?: { timeout?: number }): Promise<void> {
// Wait for any session item to appear
const sessionItem = page.locator('[data-testid^="session-item-"]').first();
await sessionItem.waitFor({
timeout: options?.timeout ?? 5000,
state: "visible",
state: 'visible',
});
}

View File

@@ -1,32 +1,23 @@
import { Page, Locator } from "@playwright/test";
import { Page, Locator } from '@playwright/test';
/**
* Get a kanban card by feature ID
*/
export async function getKanbanCard(
page: Page,
featureId: string
): Promise<Locator> {
export async function getKanbanCard(page: Page, featureId: string): Promise<Locator> {
return page.locator(`[data-testid="kanban-card-${featureId}"]`);
}
/**
* Get a kanban column by its ID
*/
export async function getKanbanColumn(
page: Page,
columnId: string
): Promise<Locator> {
export async function getKanbanColumn(page: Page, columnId: string): Promise<Locator> {
return page.locator(`[data-testid="kanban-column-${columnId}"]`);
}
/**
* Get the width of a kanban column
*/
export async function getKanbanColumnWidth(
page: Page,
columnId: string
): Promise<number> {
export async function getKanbanColumnWidth(page: Page, columnId: string): Promise<number> {
const column = page.locator(`[data-testid="kanban-column-${columnId}"]`);
const box = await column.boundingBox();
return box?.width ?? 0;
@@ -35,19 +26,16 @@ export async function getKanbanColumnWidth(
/**
* Check if a kanban column has CSS columns (masonry) layout
*/
export async function hasKanbanColumnMasonryLayout(
page: Page,
columnId: string
): Promise<boolean> {
export async function hasKanbanColumnMasonryLayout(page: Page, columnId: string): Promise<boolean> {
const column = page.locator(`[data-testid="kanban-column-${columnId}"]`);
const contentDiv = column.locator("> div").nth(1); // Second child is the content area
const contentDiv = column.locator('> div').nth(1); // Second child is the content area
const columnCount = await contentDiv.evaluate((el) => {
const style = window.getComputedStyle(el);
return style.columnCount;
});
return columnCount === "2";
return columnCount === '2';
}
/**
@@ -58,11 +46,8 @@ export async function dragKanbanCard(
featureId: string,
targetColumnId: string
): Promise<void> {
const card = page.locator(`[data-testid="kanban-card-${featureId}"]`);
const dragHandle = page.locator(`[data-testid="drag-handle-${featureId}"]`);
const targetColumn = page.locator(
`[data-testid="kanban-column-${targetColumnId}"]`
);
const targetColumn = page.locator(`[data-testid="kanban-column-${targetColumnId}"]`);
// Perform drag and drop
await dragHandle.dragTo(targetColumn);
@@ -71,15 +56,10 @@ export async function dragKanbanCard(
/**
* Click the view output button on a kanban card
*/
export async function clickViewOutput(
page: Page,
featureId: string
): Promise<void> {
export async function clickViewOutput(page: Page, featureId: string): Promise<void> {
// Try the running version first, then the in-progress version
const runningBtn = page.locator(`[data-testid="view-output-${featureId}"]`);
const inProgressBtn = page.locator(
`[data-testid="view-output-inprogress-${featureId}"]`
);
const inProgressBtn = page.locator(`[data-testid="view-output-inprogress-${featureId}"]`);
if (await runningBtn.isVisible()) {
await runningBtn.click();
@@ -104,10 +84,7 @@ export async function isDragHandleVisibleForFeature(
/**
* Get the drag handle element for a specific feature card
*/
export async function getDragHandleForFeature(
page: Page,
featureId: string
): Promise<Locator> {
export async function getDragHandleForFeature(page: Page, featureId: string): Promise<Locator> {
return page.locator(`[data-testid="drag-handle-${featureId}"]`);
}
@@ -134,9 +111,7 @@ export async function fillAddFeatureDialog(
options?: { branch?: string; category?: string }
): Promise<void> {
// Fill description (using the dropzone textarea)
const descriptionInput = page
.locator('[data-testid="add-feature-dialog"] textarea')
.first();
const descriptionInput = page.locator('[data-testid="add-feature-dialog"] textarea').first();
await descriptionInput.fill(description);
// Fill branch if provided (it's a combobox autocomplete)
@@ -145,36 +120,34 @@ export async function fillAddFeatureDialog(
const otherBranchRadio = page
.locator('[data-testid="feature-radio-group"]')
.locator('[id="feature-other"]');
await otherBranchRadio.waitFor({ state: "visible", timeout: 5000 });
await otherBranchRadio.waitFor({ state: 'visible', timeout: 5000 });
await otherBranchRadio.click();
// Wait for the branch input to appear
await page.waitForTimeout(300);
// Now click on the branch input (autocomplete)
const branchInput = page.locator('[data-testid="feature-input"]');
await branchInput.waitFor({ state: "visible", timeout: 5000 });
await branchInput.waitFor({ state: 'visible', timeout: 5000 });
await branchInput.click();
// Wait for the popover to open
await page.waitForTimeout(300);
// Type in the command input
const commandInput = page.locator("[cmdk-input]");
const commandInput = page.locator('[cmdk-input]');
await commandInput.fill(options.branch);
// Press Enter to select/create the branch
await commandInput.press("Enter");
await commandInput.press('Enter');
// Wait for popover to close
await page.waitForTimeout(200);
}
// Fill category if provided (it's also a combobox autocomplete)
if (options?.category) {
const categoryButton = page.locator(
'[data-testid="feature-category-input"]'
);
const categoryButton = page.locator('[data-testid="feature-category-input"]');
await categoryButton.click();
await page.waitForTimeout(300);
const commandInput = page.locator("[cmdk-input]");
const commandInput = page.locator('[cmdk-input]');
await commandInput.fill(options.category);
await commandInput.press("Enter");
await commandInput.press('Enter');
await page.waitForTimeout(200);
}
}
@@ -185,10 +158,9 @@ export async function fillAddFeatureDialog(
export async function confirmAddFeature(page: Page): Promise<void> {
await page.click('[data-testid="confirm-add-feature"]');
// Wait for dialog to close
await page.waitForFunction(
() => !document.querySelector('[data-testid="add-feature-dialog"]'),
{ timeout: 5000 }
);
await page.waitForFunction(() => !document.querySelector('[data-testid="add-feature-dialog"]'), {
timeout: 5000,
});
}
/**
@@ -218,12 +190,9 @@ export async function getWorktreeSelector(page: Page): Promise<Locator> {
/**
* Click on a branch button in the worktree selector
*/
export async function selectWorktreeBranch(
page: Page,
branchName: string
): Promise<void> {
const branchButton = page.getByRole("button", {
name: new RegExp(branchName, "i"),
export async function selectWorktreeBranch(page: Page, branchName: string): Promise<void> {
const branchButton = page.getByRole('button', {
name: new RegExp(branchName, 'i'),
});
await branchButton.click();
await page.waitForTimeout(500); // Wait for UI to update
@@ -232,9 +201,7 @@ export async function selectWorktreeBranch(
/**
* Get the currently selected branch in the worktree selector
*/
export async function getSelectedWorktreeBranch(
page: Page
): Promise<string | null> {
export async function getSelectedWorktreeBranch(page: Page): Promise<string | null> {
// The main branch button has aria-pressed="true" when selected
const selectedButton = page.locator(
'[data-testid="worktree-selector"] button[aria-pressed="true"]'
@@ -246,12 +213,9 @@ export async function getSelectedWorktreeBranch(
/**
* Check if a branch button is visible in the worktree selector
*/
export async function isWorktreeBranchVisible(
page: Page,
branchName: string
): Promise<boolean> {
const branchButton = page.getByRole("button", {
name: new RegExp(branchName, "i"),
export async function isWorktreeBranchVisible(page: Page, branchName: string): Promise<boolean> {
const branchButton = page.getByRole('button', {
name: new RegExp(branchName, 'i'),
});
return await branchButton.isVisible().catch(() => false);
}

View File

@@ -1,8 +1,8 @@
import { Page, Locator } from "@playwright/test";
import { clickElement, fillInput } from "../core/interactions";
import { waitForElement, waitForElementHidden } from "../core/waiting";
import { getByTestId } from "../core/elements";
import { expect } from "@playwright/test";
import { Page, Locator } from '@playwright/test';
import { clickElement, fillInput } from '../core/interactions';
import { waitForElement, waitForElementHidden } from '../core/waiting';
import { getByTestId } from '../core/elements';
import { expect } from '@playwright/test';
/**
* Get the context file list element
@@ -14,10 +14,7 @@ export async function getContextFileList(page: Page): Promise<Locator> {
/**
* Click on a context file in the list
*/
export async function clickContextFile(
page: Page,
fileName: string
): Promise<void> {
export async function clickContextFile(page: Page, fileName: string): Promise<void> {
const fileButton = page.locator(`[data-testid="context-file-${fileName}"]`);
await fileButton.click();
}
@@ -33,18 +30,15 @@ export async function getContextEditor(page: Page): Promise<Locator> {
* Get the context editor content
*/
export async function getContextEditorContent(page: Page): Promise<string> {
const editor = await getByTestId(page, "context-editor");
const editor = await getByTestId(page, 'context-editor');
return await editor.inputValue();
}
/**
* Set the context editor content
*/
export async function setContextEditorContent(
page: Page,
content: string
): Promise<void> {
const editor = await getByTestId(page, "context-editor");
export async function setContextEditorContent(page: Page, content: string): Promise<void> {
const editor = await getByTestId(page, 'context-editor');
await editor.fill(content);
}
@@ -52,8 +46,8 @@ export async function setContextEditorContent(
* Open the add context file dialog
*/
export async function openAddContextFileDialog(page: Page): Promise<void> {
await clickElement(page, "add-context-file");
await waitForElement(page, "add-context-dialog");
await clickElement(page, 'add-context-file');
await waitForElement(page, 'add-context-dialog');
}
/**
@@ -65,11 +59,11 @@ export async function createContextFile(
content: string
): Promise<void> {
await openAddContextFileDialog(page);
await clickElement(page, "add-text-type");
await fillInput(page, "new-file-name", filename);
await fillInput(page, "new-file-content", content);
await clickElement(page, "confirm-add-file");
await waitForElementHidden(page, "add-context-dialog");
await clickElement(page, 'add-text-type');
await fillInput(page, 'new-file-name', filename);
await fillInput(page, 'new-file-content', content);
await clickElement(page, 'confirm-add-file');
await waitForElementHidden(page, 'add-context-dialog');
}
/**
@@ -81,34 +75,32 @@ export async function createContextImage(
imagePath: string
): Promise<void> {
await openAddContextFileDialog(page);
await clickElement(page, "add-image-type");
await fillInput(page, "new-file-name", filename);
await clickElement(page, 'add-image-type');
await fillInput(page, 'new-file-name', filename);
await page.setInputFiles('[data-testid="image-upload-input"]', imagePath);
await clickElement(page, "confirm-add-file");
await waitForElementHidden(page, "add-context-dialog");
await clickElement(page, 'confirm-add-file');
await waitForElementHidden(page, 'add-context-dialog');
}
/**
* Delete a context file via the UI (must be selected first)
*/
export async function deleteSelectedContextFile(page: Page): Promise<void> {
await clickElement(page, "delete-context-file");
await waitForElement(page, "delete-context-dialog");
await clickElement(page, "confirm-delete-file");
await waitForElementHidden(page, "delete-context-dialog");
await clickElement(page, 'delete-context-file');
await waitForElement(page, 'delete-context-dialog');
await clickElement(page, 'confirm-delete-file');
await waitForElementHidden(page, 'delete-context-dialog');
}
/**
* Save the current context file
*/
export async function saveContextFile(page: Page): Promise<void> {
await clickElement(page, "save-context-file");
await clickElement(page, 'save-context-file');
// Wait for save to complete (button shows "Saved")
await page.waitForFunction(
() =>
document
.querySelector('[data-testid="save-context-file"]')
?.textContent?.includes("Saved"),
document.querySelector('[data-testid="save-context-file"]')?.textContent?.includes('Saved'),
{ timeout: 5000 }
);
}
@@ -117,7 +109,7 @@ export async function saveContextFile(page: Page): Promise<void> {
* Toggle markdown preview mode
*/
export async function toggleContextPreviewMode(page: Page): Promise<void> {
await clickElement(page, "toggle-preview-mode");
await clickElement(page, 'toggle-preview-mode');
}
/**
@@ -129,7 +121,7 @@ export async function waitForContextFile(
timeout: number = 10000
): Promise<void> {
const locator = page.locator(`[data-testid="context-file-${filename}"]`);
await locator.waitFor({ state: "visible", timeout });
await locator.waitFor({ state: 'visible', timeout });
}
/**
@@ -142,13 +134,13 @@ export async function selectContextFile(
timeout: number = 10000
): Promise<void> {
const fileButton = await getByTestId(page, `context-file-${filename}`);
await fileButton.waitFor({ state: "visible", timeout });
await fileButton.waitFor({ state: 'visible', timeout });
// Use JavaScript click to ensure React onClick handler fires
await fileButton.evaluate((el) => (el as HTMLButtonElement).click());
// Wait for the file to be selected (toolbar with delete button becomes visible)
const deleteButton = await getByTestId(page, "delete-context-file");
const deleteButton = await getByTestId(page, 'delete-context-file');
await expect(deleteButton).toBeVisible({
timeout,
});
@@ -173,11 +165,11 @@ export async function switchToEditMode(page: Page): Promise<void> {
// First wait for content to load
await waitForFileContentToLoad(page);
const markdownPreview = await getByTestId(page, "markdown-preview");
const markdownPreview = await getByTestId(page, 'markdown-preview');
const isPreview = await markdownPreview.isVisible().catch(() => false);
if (isPreview) {
await clickElement(page, "toggle-preview-mode");
await clickElement(page, 'toggle-preview-mode');
await page.waitForSelector('[data-testid="context-editor"]', {
timeout: 5000,
});

View File

@@ -1,19 +1,19 @@
import { Page, Locator } from "@playwright/test";
import { clickElement, fillInput } from "../core/interactions";
import { waitForElement, waitForElementHidden } from "../core/waiting";
import { getByTestId } from "../core/elements";
import { navigateToView } from "../navigation/views";
import { Page, Locator } from '@playwright/test';
import { clickElement, fillInput } from '../core/interactions';
import { waitForElement, waitForElementHidden } from '../core/waiting';
import { getByTestId } from '../core/elements';
import { navigateToView } from '../navigation/views';
/**
* Navigate to the profiles view
*/
export async function navigateToProfiles(page: Page): Promise<void> {
// Click the profiles navigation button
await navigateToView(page, "profiles");
await navigateToView(page, 'profiles');
// Wait for profiles view to be visible
await page.waitForSelector('[data-testid="profiles-view"]', {
state: "visible",
state: 'visible',
timeout: 10000,
});
}
@@ -25,10 +25,7 @@ export async function navigateToProfiles(page: Page): Promise<void> {
/**
* Get a specific profile card by ID
*/
export async function getProfileCard(
page: Page,
profileId: string
): Promise<Locator> {
export async function getProfileCard(page: Page, profileId: string): Promise<Locator> {
return getByTestId(page, `profile-card-${profileId}`);
}
@@ -84,10 +81,10 @@ export async function getCustomProfileIds(page: Page): Promise<string[]> {
const builtInText = card.locator('text="Built-in"');
const isBuiltIn = (await builtInText.count()) > 0;
if (!isBuiltIn) {
const testId = await card.getAttribute("data-testid");
const testId = await card.getAttribute('data-testid');
if (testId) {
// Extract ID from "profile-card-{id}"
const profileId = testId.replace("profile-card-", "");
const profileId = testId.replace('profile-card-', '');
customIds.push(profileId);
}
}
@@ -112,8 +109,8 @@ export async function getFirstCustomProfileId(page: Page): Promise<string | null
* Click the "New Profile" button in the header
*/
export async function clickNewProfileButton(page: Page): Promise<void> {
await clickElement(page, "add-profile-button");
await waitForElement(page, "add-profile-dialog");
await clickElement(page, 'add-profile-button');
await waitForElement(page, 'add-profile-dialog');
}
/**
@@ -124,7 +121,7 @@ export async function clickEmptyState(page: Page): Promise<void> {
'.group.rounded-xl.border.border-dashed[class*="cursor-pointer"]'
);
await emptyState.click();
await waitForElement(page, "add-profile-dialog");
await waitForElement(page, 'add-profile-dialog');
}
/**
@@ -161,10 +158,10 @@ export async function fillProfileForm(
* Click the save button to create/update a profile
*/
export async function saveProfile(page: Page): Promise<void> {
await clickElement(page, "save-profile-button");
await clickElement(page, 'save-profile-button');
// Wait for dialog to close
await waitForElementHidden(page, "add-profile-dialog").catch(() => {});
await waitForElementHidden(page, "edit-profile-dialog").catch(() => {});
await waitForElementHidden(page, 'add-profile-dialog').catch(() => {});
await waitForElementHidden(page, 'edit-profile-dialog').catch(() => {});
}
/**
@@ -175,46 +172,40 @@ export async function cancelProfileDialog(page: Page): Promise<void> {
const cancelButton = page.locator('button:has-text("Cancel")');
await cancelButton.click();
// Wait for dialog to close
await waitForElementHidden(page, "add-profile-dialog").catch(() => {});
await waitForElementHidden(page, "edit-profile-dialog").catch(() => {});
await waitForElementHidden(page, 'add-profile-dialog').catch(() => {});
await waitForElementHidden(page, 'edit-profile-dialog').catch(() => {});
}
/**
* Click the edit button for a specific profile
*/
export async function clickEditProfile(
page: Page,
profileId: string
): Promise<void> {
export async function clickEditProfile(page: Page, profileId: string): Promise<void> {
await clickElement(page, `edit-profile-${profileId}`);
await waitForElement(page, "edit-profile-dialog");
await waitForElement(page, 'edit-profile-dialog');
}
/**
* Click the delete button for a specific profile
*/
export async function clickDeleteProfile(
page: Page,
profileId: string
): Promise<void> {
export async function clickDeleteProfile(page: Page, profileId: string): Promise<void> {
await clickElement(page, `delete-profile-${profileId}`);
await waitForElement(page, "delete-profile-confirm-dialog");
await waitForElement(page, 'delete-profile-confirm-dialog');
}
/**
* Confirm profile deletion in the dialog
*/
export async function confirmDeleteProfile(page: Page): Promise<void> {
await clickElement(page, "confirm-delete-profile-button");
await waitForElementHidden(page, "delete-profile-confirm-dialog");
await clickElement(page, 'confirm-delete-profile-button');
await waitForElementHidden(page, 'delete-profile-confirm-dialog');
}
/**
* Cancel profile deletion
*/
export async function cancelDeleteProfile(page: Page): Promise<void> {
await clickElement(page, "cancel-delete-button");
await waitForElementHidden(page, "delete-profile-confirm-dialog");
await clickElement(page, 'cancel-delete-button');
await waitForElementHidden(page, 'delete-profile-confirm-dialog');
}
// ============================================================================
@@ -224,21 +215,15 @@ export async function cancelDeleteProfile(page: Page): Promise<void> {
/**
* Fill the profile name field
*/
export async function fillProfileName(
page: Page,
name: string
): Promise<void> {
await fillInput(page, "profile-name-input", name);
export async function fillProfileName(page: Page, name: string): Promise<void> {
await fillInput(page, 'profile-name-input', name);
}
/**
* Fill the profile description field
*/
export async function fillProfileDescription(
page: Page,
description: string
): Promise<void> {
await fillInput(page, "profile-description-input", description);
export async function fillProfileDescription(page: Page, description: string): Promise<void> {
await fillInput(page, 'profile-description-input', description);
}
/**
@@ -261,10 +246,7 @@ export async function selectModel(page: Page, modelId: string): Promise<void> {
* Select a thinking level for the profile
* @param level - Thinking level: none, low, medium, high, ultrathink
*/
export async function selectThinkingLevel(
page: Page,
level: string
): Promise<void> {
export async function selectThinkingLevel(page: Page, level: string): Promise<void> {
await clickElement(page, `thinking-select-${level}`);
}
@@ -273,11 +255,9 @@ export async function selectThinkingLevel(
*/
export async function getSelectedIcon(page: Page): Promise<string | null> {
// Find the icon button with primary background
const selectedIcon = page.locator(
'[data-testid^="icon-select-"][class*="bg-primary"]'
);
const testId = await selectedIcon.getAttribute("data-testid");
return testId ? testId.replace("icon-select-", "") : null;
const selectedIcon = page.locator('[data-testid^="icon-select-"][class*="bg-primary"]');
const testId = await selectedIcon.getAttribute('data-testid');
return testId ? testId.replace('icon-select-', '') : null;
}
/**
@@ -285,25 +265,19 @@ export async function getSelectedIcon(page: Page): Promise<string | null> {
*/
export async function getSelectedModel(page: Page): Promise<string | null> {
// Find the model button with primary background
const selectedModel = page.locator(
'[data-testid^="model-select-"][class*="bg-primary"]'
);
const testId = await selectedModel.getAttribute("data-testid");
return testId ? testId.replace("model-select-", "") : null;
const selectedModel = page.locator('[data-testid^="model-select-"][class*="bg-primary"]');
const testId = await selectedModel.getAttribute('data-testid');
return testId ? testId.replace('model-select-', '') : null;
}
/**
* Get the currently selected thinking level
*/
export async function getSelectedThinkingLevel(
page: Page
): Promise<string | null> {
export async function getSelectedThinkingLevel(page: Page): Promise<string | null> {
// Find the thinking level button with amber background
const selectedLevel = page.locator(
'[data-testid^="thinking-select-"][class*="bg-amber-500"]'
);
const testId = await selectedLevel.getAttribute("data-testid");
return testId ? testId.replace("thinking-select-", "") : null;
const selectedLevel = page.locator('[data-testid^="thinking-select-"][class*="bg-amber-500"]');
const testId = await selectedLevel.getAttribute('data-testid');
return testId ? testId.replace('thinking-select-', '') : null;
}
// ============================================================================
@@ -314,7 +288,7 @@ export async function getSelectedThinkingLevel(
* Check if the add profile dialog is open
*/
export async function isAddProfileDialogOpen(page: Page): Promise<boolean> {
const dialog = await getByTestId(page, "add-profile-dialog");
const dialog = await getByTestId(page, 'add-profile-dialog');
return await dialog.isVisible().catch(() => false);
}
@@ -322,7 +296,7 @@ export async function isAddProfileDialogOpen(page: Page): Promise<boolean> {
* Check if the edit profile dialog is open
*/
export async function isEditProfileDialogOpen(page: Page): Promise<boolean> {
const dialog = await getByTestId(page, "edit-profile-dialog");
const dialog = await getByTestId(page, 'edit-profile-dialog');
return await dialog.isVisible().catch(() => false);
}
@@ -330,7 +304,7 @@ export async function isEditProfileDialogOpen(page: Page): Promise<boolean> {
* Check if the delete confirmation dialog is open
*/
export async function isDeleteConfirmDialogOpen(page: Page): Promise<boolean> {
const dialog = await getByTestId(page, "delete-profile-confirm-dialog");
const dialog = await getByTestId(page, 'delete-profile-confirm-dialog');
return await dialog.isVisible().catch(() => false);
}
@@ -341,17 +315,15 @@ export async function isDeleteConfirmDialogOpen(page: Page): Promise<boolean> {
export async function waitForDialogClose(page: Page): Promise<void> {
// Wait for all profile dialogs to be hidden
await Promise.all([
waitForElementHidden(page, "add-profile-dialog").catch(() => {}),
waitForElementHidden(page, "edit-profile-dialog").catch(() => {}),
waitForElementHidden(page, "delete-profile-confirm-dialog").catch(
() => {}
),
waitForElementHidden(page, 'add-profile-dialog').catch(() => {}),
waitForElementHidden(page, 'edit-profile-dialog').catch(() => {}),
waitForElementHidden(page, 'delete-profile-confirm-dialog').catch(() => {}),
]);
// Also wait for any Radix dialog overlay to be removed (handles animation)
await page
.locator('[data-radix-dialog-overlay]')
.waitFor({ state: "hidden", timeout: 2000 })
.waitFor({ state: 'hidden', timeout: 2000 })
.catch(() => {
// Overlay may not exist
});
@@ -364,39 +336,30 @@ export async function waitForDialogClose(page: Page): Promise<void> {
/**
* Get the profile name from a card
*/
export async function getProfileName(
page: Page,
profileId: string
): Promise<string> {
export async function getProfileName(page: Page, profileId: string): Promise<string> {
const card = await getProfileCard(page, profileId);
const nameElement = card.locator("h3");
return await nameElement.textContent().then((text) => text?.trim() || "");
const nameElement = card.locator('h3');
return await nameElement.textContent().then((text) => text?.trim() || '');
}
/**
* Get the profile description from a card
*/
export async function getProfileDescription(
page: Page,
profileId: string
): Promise<string> {
export async function getProfileDescription(page: Page, profileId: string): Promise<string> {
const card = await getProfileCard(page, profileId);
const descElement = card.locator("p").first();
return await descElement.textContent().then((text) => text?.trim() || "");
const descElement = card.locator('p').first();
return await descElement.textContent().then((text) => text?.trim() || '');
}
/**
* Get the profile model badge text from a card
*/
export async function getProfileModel(
page: Page,
profileId: string
): Promise<string> {
export async function getProfileModel(page: Page, profileId: string): Promise<string> {
const card = await getProfileCard(page, profileId);
const modelBadge = card.locator(
'span[class*="border-primary"]:has-text("haiku"), span[class*="border-primary"]:has-text("sonnet"), span[class*="border-primary"]:has-text("opus")'
);
return await modelBadge.textContent().then((text) => text?.trim() || "");
return await modelBadge.textContent().then((text) => text?.trim() || '');
}
/**
@@ -410,16 +373,13 @@ export async function getProfileThinkingLevel(
const thinkingBadge = card.locator('span[class*="border-amber-500"]');
const isVisible = await thinkingBadge.isVisible().catch(() => false);
if (!isVisible) return null;
return await thinkingBadge.textContent().then((text) => text?.trim() || "");
return await thinkingBadge.textContent().then((text) => text?.trim() || '');
}
/**
* Check if a profile has the built-in badge
*/
export async function isBuiltInProfile(
page: Page,
profileId: string
): Promise<boolean> {
export async function isBuiltInProfile(page: Page, profileId: string): Promise<boolean> {
const card = await getProfileCard(page, profileId);
const builtInBadge = card.locator('span:has-text("Built-in")');
return await builtInBadge.isVisible().catch(() => false);
@@ -428,17 +388,14 @@ export async function isBuiltInProfile(
/**
* Check if the edit button is visible for a profile
*/
export async function isEditButtonVisible(
page: Page,
profileId: string
): Promise<boolean> {
export async function isEditButtonVisible(page: Page, profileId: string): Promise<boolean> {
const card = await getProfileCard(page, profileId);
// Hover over card to make buttons visible
await card.hover();
const editButton = await getByTestId(page, `edit-profile-${profileId}`);
// Wait for button to become visible after hover (handles CSS transition)
try {
await editButton.waitFor({ state: "visible", timeout: 2000 });
await editButton.waitFor({ state: 'visible', timeout: 2000 });
return true;
} catch {
return false;
@@ -448,17 +405,14 @@ export async function isEditButtonVisible(
/**
* Check if the delete button is visible for a profile
*/
export async function isDeleteButtonVisible(
page: Page,
profileId: string
): Promise<boolean> {
export async function isDeleteButtonVisible(page: Page, profileId: string): Promise<boolean> {
const card = await getProfileCard(page, profileId);
// Hover over card to make buttons visible
await card.hover();
const deleteButton = await getByTestId(page, `delete-profile-${profileId}`);
// Wait for button to become visible after hover (handles CSS transition)
try {
await deleteButton.waitFor({ state: "visible", timeout: 2000 });
await deleteButton.waitFor({ state: 'visible', timeout: 2000 });
return true;
} catch {
return false;
@@ -480,11 +434,7 @@ export async function isDeleteButtonVisible(
* @param fromIndex - 0-based index of the profile to drag
* @param toIndex - 0-based index of the target position
*/
export async function dragProfile(
page: Page,
fromIndex: number,
toIndex: number
): Promise<void> {
export async function dragProfile(page: Page, fromIndex: number, toIndex: number): Promise<void> {
// Get all profile cards
const cards = await page.locator('[data-testid^="profile-card-"]').all();
@@ -501,14 +451,14 @@ export async function dragProfile(
const dragHandle = fromCard.locator('[data-testid^="profile-drag-handle-"]');
// Ensure drag handle is visible and ready
await dragHandle.waitFor({ state: "visible", timeout: 5000 });
await dragHandle.waitFor({ state: 'visible', timeout: 5000 });
// Get bounding boxes
const handleBox = await dragHandle.boundingBox();
const toBox = await toCard.boundingBox();
if (!handleBox || !toBox) {
throw new Error("Unable to get bounding boxes for drag operation");
throw new Error('Unable to get bounding boxes for drag operation');
}
// Start position (center of drag handle)
@@ -549,10 +499,10 @@ export async function getProfileOrder(page: Page): Promise<string[]> {
const ids: string[] = [];
for (const card of cards) {
const testId = await card.getAttribute("data-testid");
const testId = await card.getAttribute('data-testid');
if (testId) {
// Extract profile ID from data-testid="profile-card-{id}"
const profileId = testId.replace("profile-card-", "");
const profileId = testId.replace('profile-card-', '');
ids.push(profileId);
}
}
@@ -568,5 +518,5 @@ export async function getProfileOrder(page: Page): Promise<string[]> {
* Click the "Refresh Defaults" button
*/
export async function clickRefreshDefaults(page: Page): Promise<void> {
await clickElement(page, "refresh-profiles-button");
await clickElement(page, 'refresh-profiles-button');
}

View File

@@ -1,4 +1,4 @@
import { Page, Locator } from "@playwright/test";
import { Page, Locator } from '@playwright/test';
/**
* Get the settings view scrollable content area

View File

@@ -1,20 +1,19 @@
import { Page, Locator } from "@playwright/test";
import { getByTestId } from "../core/elements";
import { waitForElement } from "../core/waiting";
import { setupFirstRun } from "../project/setup";
import { Page, Locator } from '@playwright/test';
import { getByTestId } from '../core/elements';
import { waitForElement } from '../core/waiting';
/**
* Wait for setup view to be visible
*/
export async function waitForSetupView(page: Page): Promise<Locator> {
return waitForElement(page, "setup-view", { timeout: 10000 });
return waitForElement(page, 'setup-view', { timeout: 10000 });
}
/**
* Click "Get Started" button on setup welcome step
*/
export async function clickSetupGetStarted(page: Page): Promise<void> {
const button = await getByTestId(page, "setup-start-button");
const button = await getByTestId(page, 'setup-start-button');
await button.click();
}
@@ -22,7 +21,7 @@ export async function clickSetupGetStarted(page: Page): Promise<void> {
* Click continue on Claude setup step
*/
export async function clickClaudeContinue(page: Page): Promise<void> {
const button = await getByTestId(page, "claude-next-button");
const button = await getByTestId(page, 'claude-next-button');
await button.click();
}
@@ -30,46 +29,40 @@ export async function clickClaudeContinue(page: Page): Promise<void> {
* Click finish on setup complete step
*/
export async function clickSetupFinish(page: Page): Promise<void> {
const button = await getByTestId(page, "setup-finish-button");
const button = await getByTestId(page, 'setup-finish-button');
await button.click();
}
/**
* Enter Anthropic API key in setup
*/
export async function enterAnthropicApiKey(
page: Page,
apiKey: string
): Promise<void> {
export async function enterAnthropicApiKey(page: Page, apiKey: string): Promise<void> {
// Click "Use Anthropic API Key Instead" button
const useApiKeyButton = await getByTestId(page, "use-api-key-button");
const useApiKeyButton = await getByTestId(page, 'use-api-key-button');
await useApiKeyButton.click();
// Enter the API key
const input = await getByTestId(page, "anthropic-api-key-input");
const input = await getByTestId(page, 'anthropic-api-key-input');
await input.fill(apiKey);
// Click save button
const saveButton = await getByTestId(page, "save-anthropic-key-button");
const saveButton = await getByTestId(page, 'save-anthropic-key-button');
await saveButton.click();
}
/**
* Enter OpenAI API key in setup
*/
export async function enterOpenAIApiKey(
page: Page,
apiKey: string
): Promise<void> {
export async function enterOpenAIApiKey(page: Page, apiKey: string): Promise<void> {
// Click "Enter OpenAI API Key" button
const useApiKeyButton = await getByTestId(page, "use-openai-key-button");
const useApiKeyButton = await getByTestId(page, 'use-openai-key-button');
await useApiKeyButton.click();
// Enter the API key
const input = await getByTestId(page, "openai-api-key-input");
const input = await getByTestId(page, 'openai-api-key-input');
await input.fill(apiKey);
// Click save button
const saveButton = await getByTestId(page, "save-openai-key-button");
const saveButton = await getByTestId(page, 'save-openai-key-button');
await saveButton.click();
}

View File

@@ -1,6 +1,6 @@
import { Page, Locator } from "@playwright/test";
import { clickElement } from "../core/interactions";
import { navigateToSpec } from "../navigation/views";
import { Page, Locator } from '@playwright/test';
import { clickElement } from '../core/interactions';
import { navigateToSpec } from '../navigation/views';
/**
* Get the spec editor element
@@ -20,10 +20,7 @@ export async function getSpecEditorContent(page: Page): Promise<string> {
/**
* Set the spec editor content
*/
export async function setSpecEditorContent(
page: Page,
content: string
): Promise<void> {
export async function setSpecEditorContent(page: Page, content: string): Promise<void> {
const editor = await getSpecEditor(page);
await editor.fill(content);
}
@@ -32,14 +29,14 @@ export async function setSpecEditorContent(
* Click the save spec button
*/
export async function clickSaveSpec(page: Page): Promise<void> {
await clickElement(page, "save-spec");
await clickElement(page, 'save-spec');
}
/**
* Click the reload spec button
*/
export async function clickReloadSpec(page: Page): Promise<void> {
await clickElement(page, "reload-spec");
await clickElement(page, 'reload-spec');
}
/**
@@ -47,7 +44,7 @@ export async function clickReloadSpec(page: Page): Promise<void> {
*/
export async function getDisplayedSpecPath(page: Page): Promise<string | null> {
const specView = page.locator('[data-testid="spec-view"]');
const pathElement = specView.locator("p.text-muted-foreground").first();
const pathElement = specView.locator('p.text-muted-foreground').first();
return await pathElement.textContent();
}
@@ -66,11 +63,11 @@ export async function getEditorContent(page: Page): Promise<string> {
// CodeMirror uses a contenteditable div with class .cm-content
// Wait for it to be visible and then read its textContent
const contentElement = page.locator('[data-testid="spec-editor"] .cm-content');
await contentElement.waitFor({ state: "visible", timeout: 10000 });
await contentElement.waitFor({ state: 'visible', timeout: 10000 });
// Read the content - CodeMirror should have updated its DOM by now
const content = await contentElement.textContent();
return content || "";
return content || '';
}
/**
@@ -85,14 +82,14 @@ export async function setEditorContent(page: Page, content: string): Promise<voi
await page.waitForTimeout(200);
// Select all content (Cmd+A on Mac, Ctrl+A on others)
const isMac = process.platform === "darwin";
await page.keyboard.press(isMac ? "Meta+a" : "Control+a");
const isMac = process.platform === 'darwin';
await page.keyboard.press(isMac ? 'Meta+a' : 'Control+a');
// Wait for selection
await page.waitForTimeout(100);
// Delete the selected content first
await page.keyboard.press("Backspace");
await page.keyboard.press('Backspace');
// Wait for deletion
await page.waitForTimeout(100);
@@ -115,7 +112,7 @@ export async function clickSaveButton(page: Page): Promise<void> {
await page.waitForFunction(
() => {
const btn = document.querySelector('[data-testid="save-spec"]');
return btn?.textContent?.includes("Saved");
return btn?.textContent?.includes('Saved');
},
{ timeout: 5000 }
);

File diff suppressed because it is too large Load Diff