mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Locator } from "@playwright/test";
|
||||
import { Locator } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Check if an element is scrollable (has scrollable content)
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
import { Page, Locator } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Get the settings view scrollable content area
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user